feature/private-assets #2

Merged
crowetic merged 3 commits from feature/private-assets into main 2025-12-04 21:14:37 +00:00
25 changed files with 1443 additions and 537 deletions
+65 -14
View File
@@ -21,9 +21,10 @@ import {
import { useAlert } from '../alerts'; import { useAlert } from '../alerts';
import TagChip from '../asset/TagChip'; import TagChip from '../asset/TagChip';
import { useAuth, objectToBase64, Spacer } from 'qapp-core'; import { useAuth, objectToBase64, Spacer, Service } from 'qapp-core';
// import { useTheme } from '@mui/material'; // import { useTheme } from '@mui/material';
import { assetCommentsPrefix, assetCommentId } from '../../constants/qdnConstants'; import { assetCommentsPrefix, assetCommentId } from '../../constants/qdnConstants';
import { addPrivateMagic, stripPrivateMagic } from '../../constants/qdeckIdentifiers';
import { uniqueId6 } from '../../utils/ids'; import { uniqueId6 } from '../../utils/ids';
import { base64ToObject } from '../../utils/data'; import { base64ToObject } from '../../utils/data';
import { fetchAccountAvatarDataUrl } from '../../utils/qdnAvatar'; import { fetchAccountAvatarDataUrl } from '../../utils/qdnAvatar';
@@ -118,6 +119,8 @@ export interface CommentsSectionProps {
primaryGroupId: number; primaryGroupId: number;
isIssuer?: boolean; isIssuer?: boolean;
issuerName: string | null; issuerName: string | null;
isPrivate?: boolean;
privateGroupId?: number;
type?: string; type?: string;
pageSize?: number; // default 10 roots per page pageSize?: number; // default 10 roots per page
collapsible?: boolean; // default false collapsible?: boolean; // default false
@@ -254,19 +257,21 @@ async function fetchHtmlComment(
name: string, name: string,
identifier: string, identifier: string,
prefix: string, prefix: string,
service: Service,
createdFallback?: number, createdFallback?: number,
updatedFallback?: number updatedFallback?: number
): Promise<ThreadComment | null> { ): Promise<ThreadComment | null> {
try { try {
const b64 = await qortalRequest({ const b64 = await qortalRequest({
action: 'FETCH_QDN_RESOURCE', action: 'FETCH_QDN_RESOURCE',
service: 'DOCUMENT', service,
name, name,
identifier, identifier,
encoding: 'base64', encoding: 'base64',
} as any); } as any);
const rawLen = base64ByteLength(b64); const cleaned = typeof b64 === 'string' ? stripPrivateMagic(b64) : b64;
const rawLen = base64ByteLength(cleaned as string);
if (rawLen <= DELETED_SENTINEL_LEN) { if (rawLen <= DELETED_SENTINEL_LEN) {
const id = stripPrefixId(identifier, prefix); const id = stripPrefixId(identifier, prefix);
@@ -282,7 +287,7 @@ async function fetchHtmlComment(
} as ThreadCommentWithFlags; } as ThreadCommentWithFlags;
} }
const obj = await base64ToObject(b64); const obj = await base64ToObject(cleaned);
if (!obj || typeof obj !== 'object') return null; if (!obj || typeof obj !== 'object') return null;
const html = String((obj as any).html ?? '').trim(); const html = String((obj as any).html ?? '').trim();
@@ -323,6 +328,8 @@ export default function CommentsSection({
primaryGroupId, primaryGroupId,
// isIssuer, // isIssuer,
issuerName, issuerName,
isPrivate,
privateGroupId,
pageSize: pageSizeProp, pageSize: pageSizeProp,
collapsible = true, collapsible = true,
defaultCollapsed = false, defaultCollapsed = false,
@@ -389,6 +396,12 @@ export default function CommentsSection({
const end = Math.min(start + pageSize, totalRoots); const end = Math.min(start + pageSize, totalRoots);
const pageRoots = forestRootsDesc.slice(start, end); const pageRoots = forestRootsDesc.slice(start, end);
const effectiveGroupId =
Number.isFinite(privateGroupId) && privateGroupId
? privateGroupId
: Number.isFinite(primaryGroupId) && primaryGroupId
? primaryGroupId
: undefined;
const inputs = { const inputs = {
primaryGroupId, primaryGroupId,
MINTER_GROUP_ID, MINTER_GROUP_ID,
@@ -509,7 +522,8 @@ export default function CommentsSection({
setError(null); setError(null);
try { try {
// 1) Find all identifiers quickly (cheap) // 1) Find all identifiers quickly (cheap)
const hitsAll = await searchSimpleByIdentifierPrefix('DOCUMENT', prefix, 0); const svc = isPrivate ? 'DOCUMENT_PRIVATE' : ('DOCUMENT' as Service);
const hitsAll = await searchSimpleByIdentifierPrefix(svc, prefix, 0);
if (cancelled) return; if (cancelled) return;
// Sort stable (you had this already) // Sort stable (you had this already)
@@ -533,7 +547,7 @@ export default function CommentsSection({
hits.map((h) => hits.map((h) =>
limit(async () => { limit(async () => {
if (cancelled) return; if (cancelled) return;
const doc = await fetchHtmlComment(h.name, h.identifier, prefix); const doc = await fetchHtmlComment(h.name, h.identifier, prefix, svc);
if (!doc) return; if (!doc) return;
// de-dupe by id // de-dupe by id
if (docs.find((d) => d.id === doc.id)) return; if (docs.find((d) => d.id === doc.id)) return;
@@ -565,7 +579,7 @@ export default function CommentsSection({
const candidateNames = namesByIdentifier.get(fullIdentifier) ?? []; const candidateNames = namesByIdentifier.get(fullIdentifier) ?? [];
let found: ThreadComment | null = null; let found: ThreadComment | null = null;
for (const nm of candidateNames) { for (const nm of candidateNames) {
const doc = await fetchHtmlComment(nm, fullIdentifier, prefix); const doc = await fetchHtmlComment(nm, fullIdentifier, prefix, svc);
if (doc) { if (doc) {
found = doc; found = doc;
break; break;
@@ -574,7 +588,7 @@ export default function CommentsSection({
if (!found && docs.length) { if (!found && docs.length) {
const uniqueAuthors = Array.from(new Set(docs.map((x) => x.author).filter(Boolean))); const uniqueAuthors = Array.from(new Set(docs.map((x) => x.author).filter(Boolean)));
for (const nm of uniqueAuthors) { for (const nm of uniqueAuthors) {
const doc = await fetchHtmlComment(nm, fullIdentifier, prefix); const doc = await fetchHtmlComment(nm, fullIdentifier, prefix, svc);
if (doc) { if (doc) {
found = doc; found = doc;
break; break;
@@ -696,13 +710,25 @@ export default function CommentsSection({
const identifier = assetCommentId(assetId, id); const identifier = assetCommentId(assetId, id);
const data64 = await objectToBase64(entry); const data64 = await objectToBase64(entry);
const service = isPrivate ? ('DOCUMENT_PRIVATE' as Service) : ('DOCUMENT' as Service);
let finalData = data64;
if (isPrivate) {
if (!effectiveGroupId) throw new Error('Missing private group for comment publish.');
const encrypted = await qortalRequest({
action: 'ENCRYPT_QORTAL_GROUP_DATA',
base64: data64,
groupId: effectiveGroupId,
isAdmins: false,
});
finalData = addPrivateMagic(encrypted);
}
await qortalRequest({ await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE', action: 'PUBLISH_QDN_RESOURCE',
name: userName, name: userName,
service: 'DOCUMENT', service,
identifier, identifier,
data64, data64: finalData,
} as any); } as any);
// optimistic UI: add the newly published comment at the top // optimistic UI: add the newly published comment at the top
@@ -764,13 +790,25 @@ export default function CommentsSection({
const identifier = assetCommentId(assetId, editTarget.id); const identifier = assetCommentId(assetId, editTarget.id);
const data64 = await objectToBase64(entry); const data64 = await objectToBase64(entry);
const service = isPrivate ? ('DOCUMENT_PRIVATE' as Service) : ('DOCUMENT' as Service);
let finalData = data64;
if (isPrivate) {
if (!effectiveGroupId) throw new Error('Missing private group for comment publish.');
const encrypted = await qortalRequest({
action: 'ENCRYPT_QORTAL_GROUP_DATA',
base64: data64,
groupId: effectiveGroupId,
isAdmins: false,
});
finalData = addPrivateMagic(encrypted);
}
await qortalRequest({ await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE', action: 'PUBLISH_QDN_RESOURCE',
name: userName, name: userName,
service: 'DOCUMENT', service,
identifier, identifier,
data64, data64: finalData,
} as any); } as any);
// Optimistic local update // Optimistic local update
@@ -810,12 +848,25 @@ export default function CommentsSection({
// Publish tiny sentinel payload (1 raw byte) base64("x") = "eA==" // Publish tiny sentinel payload (1 raw byte) base64("x") = "eA=="
const data64 = btoa(DELETED_SENTINEL_RAW); const data64 = btoa(DELETED_SENTINEL_RAW);
const service = isPrivate ? ('DOCUMENT_PRIVATE' as Service) : ('DOCUMENT' as Service);
let finalData = data64;
if (isPrivate) {
if (!effectiveGroupId) throw new Error('Missing private group for comment publish.');
const encrypted = await qortalRequest({
action: 'ENCRYPT_QORTAL_GROUP_DATA',
base64: data64,
groupId: effectiveGroupId,
isAdmins: false,
});
finalData = addPrivateMagic(encrypted);
}
await qortalRequest({ await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE', action: 'PUBLISH_QDN_RESOURCE',
name: userName, name: userName,
service: 'DOCUMENT', service,
identifier, identifier,
data64, data64: finalData,
} as any); } as any);
// Optimistic local mark // Optimistic local mark
@@ -0,0 +1,23 @@
import QdnPublishStatus from './QdnPublishStatus';
import { usePublishQueue } from '../../state/publishQueue';
type Props = {
fallbackLabel?: string;
};
export default function PublishQueueStatus({ fallbackLabel }: Props) {
const state = usePublishQueue();
const activeJob = state.activeJobId
? state.jobs.find((job) => job.id === state.activeJobId)
: null;
if (!activeJob) return null;
return (
<QdnPublishStatus
progress={activeJob.progress}
throttle={activeJob.throttle}
contextLabel={activeJob.label || fallbackLabel}
/>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Box, Button, LinearProgress, Typography } from '@mui/material'; import { Box, Button, LinearProgress, Typography } from '@mui/material';
import type { PublishJobProgress, PublishJobStatus } from '../../utils/qdnProgressivePublisher'; import type { PublishJobProgress, PublishJobStatus } from '../../utils/qdnProgressivePublisher';
import type { PublishThrottleState } from '../../hooks/useQdnProgressivePublisher'; import type { PublishThrottleState } from '../../state/publishQueue';
type Props = { type Props = {
progress: PublishJobProgress | null; progress: PublishJobProgress | null;
+10 -127
View File
@@ -23,8 +23,9 @@ import {
} from '@mui/material'; } from '@mui/material';
import { useAuth } from 'qapp-core'; import { useAuth } from 'qapp-core';
import TiptapEditor from '../TipTapEditor'; import TiptapEditor from '../TipTapEditor';
import QdnPublishStatus from '../common/QdnPublishStatus'; import PublishQueueStatus from '../common/PublishQueueStatus';
import { prepareHtmlForPublish } from '../../utils/publicationPublisher'; import { prepareHtmlForPublish } from '../../utils/publicationPublisher';
import { invalidateAnnouncementCache, dispatchNewsRefreshEvent } from '../../utils/news';
import { objectToBase64 } from '../../utils/data'; import { objectToBase64 } from '../../utils/data';
import { sendNotification } from '../../notifications/notificationService'; import { sendNotification } from '../../notifications/notificationService';
import { NOTIF_GROUP_ID } from '../../notifications/notifyIndex'; import { NOTIF_GROUP_ID } from '../../notifications/notifyIndex';
@@ -32,10 +33,9 @@ import { qaAnnouncementPrefix } from '../../constants/qdnConstants';
import { uniqueId6 } from '../../utils/ids'; import { uniqueId6 } from '../../utils/ids';
import { getAccountGroups, type GroupSummary } from '../../utils/qortalApi'; import { getAccountGroups, type GroupSummary } from '../../utils/qortalApi';
import type { NotifScope } from '../../types/notifications'; import type { NotifScope } from '../../types/notifications';
import { QmailPartialError } from '../../utils/qmailNotifications';
import type { NotificationRecipient } from '../../utils/notificationRecipients'; import type { NotificationRecipient } from '../../utils/notificationRecipients';
import { prepareQmailRecipients } from '../../utils/qmailRecipientCache'; import { prepareQmailRecipients } from '../../utils/qmailRecipientCache';
import { useQdnProgressivePublisher } from '../../hooks/useQdnProgressivePublisher'; import { enqueueQdnPublishJob } from '../../state/publishQueue';
import { PublishJobError } from '../../utils/qdnProgressivePublisher'; import { PublishJobError } from '../../utils/qdnProgressivePublisher';
type Props = { type Props = {
@@ -46,22 +46,6 @@ type Props = {
const APP_HOME_LINK = 'qortal://APP/Q-Assets'; const APP_HOME_LINK = 'qortal://APP/Q-Assets';
type QmailPartial = {
title: string;
identifier: string;
sent: number;
total: number;
savedAt: number;
};
function saveQmailPartial(info: QmailPartial) {
try {
localStorage.setItem('qassets_qmail_partial', JSON.stringify(info));
} catch {
/* ignore */
}
}
export default function AnnouncementDialog({ export default function AnnouncementDialog({
open, open,
onClose, onClose,
@@ -79,52 +63,6 @@ export default function AnnouncementDialog({
const [groupOptions, setGroupOptions] = useState<GroupSummary[]>([]); const [groupOptions, setGroupOptions] = useState<GroupSummary[]>([]);
const [groupsLoading, setGroupsLoading] = useState(false); const [groupsLoading, setGroupsLoading] = useState(false);
const [notificationGroupId, setNotificationGroupId] = useState<number | ''>(''); const [notificationGroupId, setNotificationGroupId] = useState<number | ''>('');
const {
publish: publishAnnouncementResources,
progress: qdnProgress,
throttle: qdnThrottle,
} = useQdnProgressivePublisher();
const [qmailThrottle, setQmailThrottle] = useState<{
sent: number;
total: number;
secondsLeft: number;
resolver: (v: boolean) => void;
identifier: string;
title: string;
} | null>(null);
useEffect(() => {
if (!qmailThrottle) return;
const id = setInterval(() => {
setQmailThrottle((prev) => {
if (!prev) return prev;
const next = prev.secondsLeft - 1;
if (next <= 0) {
prev.resolver(true);
return null;
}
return { ...prev, secondsLeft: next };
});
}, 1000);
return () => clearInterval(id);
}, [qmailThrottle]);
const cancelQmailThrottle = () => {
if (!qmailThrottle) return;
saveQmailPartial({
title: qmailThrottle.title,
identifier: qmailThrottle.identifier,
sent: qmailThrottle.sent,
total: qmailThrottle.total,
savedAt: Date.now(),
});
qmailThrottle.resolver(false);
setQmailThrottle(null);
setBusy(false);
setErr(
`Q-Mail paused after ${qmailThrottle.sent}/${qmailThrottle.total}. Saved for re-publish later.`
);
};
useEffect(() => { useEffect(() => {
if (!address) { if (!address) {
@@ -177,7 +115,7 @@ export default function AnnouncementDialog({
const announcementBase64 = await objectToBase64(annPayload); const announcementBase64 = await objectToBase64(annPayload);
await publishAnnouncementResources({ const queued = enqueueQdnPublishJob({
label: 'Announcement publish', label: 'Announcement publish',
resources: [ resources: [
{ {
@@ -188,23 +126,10 @@ export default function AnnouncementDialog({
}, },
], ],
}); });
if (!queued) throw new Error('Unable to queue announcement publish.');
const qmailOptions = () => ({ await queued.completion;
batchSize: 10, invalidateAnnouncementCache();
onThrottle: (ctx: { sent: number; total: number; delayMs: number; nextIndex: number }) => dispatchNewsRefreshEvent();
new Promise<boolean>((resolve) => {
setQmailThrottle({
sent: ctx.sent,
total: ctx.total,
secondsLeft: Math.ceil(ctx.delayMs / 1000),
resolver: resolve,
identifier,
title,
});
}),
onProgress: ({ sent, total }: { sent: number; total: number }) =>
setQmailThrottle((prev) => (prev ? { ...prev, sent, total } : prev)),
});
if ((notifyMail || notifyChat) && address) { if ((notifyMail || notifyChat) && address) {
const extraGroupId = const extraGroupId =
@@ -258,7 +183,6 @@ export default function AnnouncementDialog({
publisher, publisher,
qdnResource: { publisher: userName, identifier }, qdnResource: { publisher: userName, identifier },
links, links,
qmailOptions: qmailOptions(),
deliveries: { deliveries: {
internal: { enabled: true, chatPingGroupId: notifyChat ? NOTIF_GROUP_ID : undefined }, internal: { enabled: true, chatPingGroupId: notifyChat ? NOTIF_GROUP_ID : undefined },
qmail: notifyMail qmail: notifyMail
@@ -283,7 +207,6 @@ export default function AnnouncementDialog({
publisher, publisher,
qdnResource: { publisher: userName, identifier }, qdnResource: { publisher: userName, identifier },
links, links,
qmailOptions: qmailOptions(),
deliveries: { deliveries: {
internal: { enabled: true }, internal: { enabled: true },
qmail: notifyMail qmail: notifyMail
@@ -319,18 +242,6 @@ export default function AnnouncementDialog({
const declineReported = lower.includes('user declined request'); const declineReported = lower.includes('user declined request');
if (e instanceof PublishJobError) { if (e instanceof PublishJobError) {
setErr(e.message || 'Announcement publishing cancelled.'); setErr(e.message || 'Announcement publishing cancelled.');
} else if (e instanceof QmailPartialError || e?.code === 'QMAIL_PARTIAL') {
saveQmailPartial({
title,
identifier,
sent: e.sent ?? 0,
total: e.total ?? 0,
savedAt: Date.now(),
});
setErr(
e?.message ||
`Q-Mail paused after ${e.sent ?? 0}/${e.total ?? 0}. Saved progress for re-publish.`
);
} else { } else {
setErr( setErr(
declineReported declineReported
@@ -339,7 +250,7 @@ export default function AnnouncementDialog({
); );
} }
} finally { } finally {
if (!qmailThrottle && !qdnThrottle) setBusy(false); setBusy(false);
} }
} }
@@ -442,35 +353,7 @@ export default function AnnouncementDialog({
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
</Box> </Box>
<QdnPublishStatus <PublishQueueStatus fallbackLabel="Publishing announcement" />
progress={qdnProgress}
throttle={qdnThrottle}
contextLabel="Publishing announcement"
/>
{qmailThrottle && (
<Box
sx={{
p: 1,
border: (t) => `1px solid ${t.palette.warning.main}`,
borderRadius: 1,
bgcolor: (t) => t.palette.warning.light,
color: (t) => t.palette.getContrastText(t.palette.warning.light),
}}
>
<Typography variant="body2" fontWeight={700}>
Q-Mail throttled
</Typography>
<Typography variant="body2">
Sent {qmailThrottle.sent}/{qmailThrottle.total}. Auto-retrying in{' '}
{qmailThrottle.secondsLeft}s.
</Typography>
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
<Button variant="contained" color="warning" onClick={cancelQmailThrottle}>
Cancel / Pause
</Button>
</Box>
</Box>
)}
{err && <Box sx={{ color: 'error.main', fontSize: 13 }}>{err}</Box>} {err && <Box sx={{ color: 'error.main', fontSize: 13 }}>{err}</Box>}
</Box> </Box>
</DialogContent> </DialogContent>
+56 -33
View File
@@ -14,7 +14,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { useAuth } from 'qapp-core'; import { useAuth } from 'qapp-core';
import TiptapEditor from '../TipTapEditor'; import TiptapEditor from '../TipTapEditor';
import QdnPublishStatus from '../common/QdnPublishStatus'; import PublishQueueStatus from '../common/PublishQueueStatus';
import { prepareHtmlForPublish } from '../../utils/publicationPublisher'; import { prepareHtmlForPublish } from '../../utils/publicationPublisher';
import { assetNewsItemId } from '../../constants/qdnConstants'; import { assetNewsItemId } from '../../constants/qdnConstants';
import { isNameAdminOfGroupId } from '../../utils/access'; import { isNameAdminOfGroupId } from '../../utils/access';
@@ -22,8 +22,10 @@ import { uniqueId6 } from '../../utils/ids';
import { useAlert } from '../alerts'; import { useAlert } from '../alerts';
import { publishScopedNotification } from '../../utils/notificationPublisher'; import { publishScopedNotification } from '../../utils/notificationPublisher';
import { objectToBase64 } from '../../utils/data'; import { objectToBase64 } from '../../utils/data';
import { useQdnProgressivePublisher } from '../../hooks/useQdnProgressivePublisher'; import { enqueueQdnPublishJob } from '../../state/publishQueue';
import { PublishJobError } from '../../utils/qdnProgressivePublisher'; import { PublishJobError } from '../../utils/qdnProgressivePublisher';
import { resolveAssetPublicationById } from '../../utils/resolveAssetPublication';
import { addPrivateMagic } from '../../constants/qdeckIdentifiers';
export default function NewsPublisher({ export default function NewsPublisher({
assetId, assetId,
@@ -50,31 +52,38 @@ export default function NewsPublisher({
? Number(primaryGroupId) ? Number(primaryGroupId)
: null; : null;
const canPublish = async () => { const canPublish = async (groupId?: number | null) => {
if (!userName) authenticateUser(); if (!userName) authenticateUser();
if (isIssuer) return true; if (isIssuer) return true;
if (!primaryGroupId) return false; if (!groupId) return false;
return isNameAdminOfGroupId(userName as string, primaryGroupId); return isNameAdminOfGroupId(userName as string, groupId);
}; };
const { alert } = useAlert(); const { alert } = useAlert();
const {
publish: publishNewsResources,
progress: qdnProgress,
throttle: qdnThrottle,
} = useQdnProgressivePublisher();
const showQdnStatus =
(qdnProgress && qdnProgress.status !== 'completed' && qdnProgress.status !== 'cancelled') ||
!!qdnThrottle;
const handlePublish = async () => { const handlePublish = async () => {
if (!userName) { if (!userName) {
await alert('You need a Qortal name to publish.'); await alert('You need a Qortal name to publish.');
return; return;
} }
if (!(await canPublish())) { const privacy = assetId
await alert('Only issuer or primary group admins can publish News.'); ? await resolveAssetPublicationById(assetId).catch(() => ({ publication: null }))
: { publication: null };
const isPrivate = Boolean(privacy.publication?.privateAsset);
const privateGroupIdRaw =
privacy.publication?.privateGroupId ?? privacy.publication?.primaryGroup?.id;
const privateGroupId =
privateGroupIdRaw != null && Number.isFinite(Number(privateGroupIdRaw))
? Number(privateGroupIdRaw)
: null;
const effectiveGroupId = privateGroupId ?? normalizedGroupId;
if (!(await canPublish(effectiveGroupId))) {
await alert('Only issuer or authorized group admins can publish News.');
return;
}
if (isPrivate && !effectiveGroupId) {
await alert('Private assets require a private group to publish news.');
return; return;
} }
@@ -87,21 +96,42 @@ export default function NewsPublisher({
title: newsTitle, title: newsTitle,
createdAt: Date.now(), createdAt: Date.now(),
}; };
const b64 = await objectToBase64(payloadObj); const raw64 = await objectToBase64(payloadObj);
// Encrypt for private assets
const service: 'DOCUMENT' | 'DOCUMENT_PRIVATE' = isPrivate ? 'DOCUMENT_PRIVATE' : 'DOCUMENT';
let data64 = raw64;
if (isPrivate) {
try {
const encrypted = await qortalRequest({
action: 'ENCRYPT_QORTAL_GROUP_DATA',
base64: raw64,
groupId: effectiveGroupId!,
isAdmins: false,
});
data64 = addPrivateMagic(encrypted);
} catch (e: any) {
const msg = typeof e?.message === 'string' ? e.message : 'Failed to encrypt for group.';
await alert(msg, 'Publish failed', { severity: 'error' });
return;
}
}
setPublishing(true); setPublishing(true);
try { try {
await publishNewsResources({ const queued = enqueueQdnPublishJob({
label: 'Asset news publish', label: 'Asset news publish',
resources: [ resources: [
{ {
name: userName as string, name: userName as string,
service: 'DOCUMENT', service,
identifier: newsItemId, identifier: newsItemId,
data64: b64, data64,
}, },
], ],
}); });
if (!queued) throw new Error('Unable to queue news publish');
await queued.completion;
const assetLink = `qortal://APP/Q-Assets/assets/${assetId}`; const assetLink = `qortal://APP/Q-Assets/assets/${assetId}`;
const links = [ const links = [
@@ -116,7 +146,8 @@ export default function NewsPublisher({
]; ];
if (address) { if (address) {
if (notifyAppSubs) { // For private assets, avoid global notifications; only group scope.
if (!isPrivate && notifyAppSubs) {
await publishScopedNotification({ await publishScopedNotification({
scope: { kind: 'global' }, scope: { kind: 'global' },
title: newsTitle, title: newsTitle,
@@ -127,9 +158,9 @@ export default function NewsPublisher({
links, links,
}); });
} }
if (notifyGroupSubs && normalizedGroupId) { if (notifyGroupSubs && effectiveGroupId) {
await publishScopedNotification({ await publishScopedNotification({
scope: { kind: 'group', groupId: normalizedGroupId }, scope: { kind: 'group', groupId: effectiveGroupId },
title: assetName ? `${assetName} group notice` : `Asset #${assetId} group notice`, title: assetName ? `${assetName} group notice` : `Asset #${assetId} group notice`,
html: payload, html: payload,
publisher: { name: userName, address, role: 'admin' }, publisher: { name: userName, address, role: 'admin' },
@@ -207,15 +238,7 @@ export default function NewsPublisher({
} }
/> />
</Box> </Box>
{showQdnStatus && ( <PublishQueueStatus fallbackLabel="Publishing news article" />
<Box sx={{ mt: 2 }}>
<QdnPublishStatus
progress={qdnProgress}
throttle={qdnThrottle}
contextLabel="Publishing news article"
/>
</Box>
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setOpen(false)} disabled={publishing}> <Button onClick={() => setOpen(false)} disabled={publishing}>
+56 -19
View File
@@ -1,10 +1,17 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Box, Card, CardContent, Typography, Divider, Skeleton, Chip, Button } from '@mui/material'; import { Box, Card, CardContent, Typography, Divider, Skeleton, Chip, Button } from '@mui/material';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { fetchAnnouncements, fetchLatestAssetNews, fetchActivePromotions } from '../../utils/news'; import {
fetchAnnouncements,
fetchLatestAssetNews,
fetchActivePromotions,
NEWS_REFRESH_EVENT,
invalidateAnnouncementCache,
} from '../../utils/news';
import { useTheme, alpha } from '@mui/material/styles'; import { useTheme, alpha } from '@mui/material/styles';
import NewsActionBar from '../../components/news/NewsActionBar'; import NewsActionBar from '../../components/news/NewsActionBar';
import { useMemberGroupIds } from '../../hooks/useMemberGroupIds';
import type { NewsSummary, NewsType } from '../../types/newsAndPromos'; import type { NewsSummary, NewsType } from '../../types/newsAndPromos';
@@ -202,45 +209,54 @@ export default function QAssetsNewsSection() {
// const [detailError, setDetailError] = useState<string | null>(null); // const [detailError, setDetailError] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { memberGroupIds, loading: groupsLoading } = useMemberGroupIds();
const theme = useTheme(); const theme = useTheme();
// Initial load of lists // Initial load of lists
useEffect(() => { const loadNews = useCallback(
let cancelled = false; async (forceFresh = false) => {
const load = async () => { if (forceFresh) {
invalidateAnnouncementCache();
}
try { try {
setLoading(true); setLoading(true);
const announcementLimit = showMoreAnnouncements ? 50 : 5; const announcementLimit = showMoreAnnouncements ? 50 : 5;
const assetNewsLimit = showMoreNews ? 50 : 8; const assetNewsLimit = showMoreNews ? 50 : 8;
const [a, n, p] = await Promise.all([ const [a, n, p] = await Promise.all([
fetchAnnouncements(announcementLimit, { includeExpired: showArchivedAnnouncements }), fetchAnnouncements(announcementLimit, {
fetchLatestAssetNews(assetNewsLimit, { includeExpired: showArchivedNews }), includeExpired: showArchivedAnnouncements,
forceFresh,
}),
fetchLatestAssetNews(assetNewsLimit, {
includeExpired: showArchivedNews,
allowedGroupIds: memberGroupIds,
}),
fetchActivePromotions(), fetchActivePromotions(),
]); ]);
if (cancelled) return;
setAnnouncements(a); setAnnouncements(a);
setAssetNews(n); setAssetNews(n);
setPromotions(p); setPromotions(p);
} catch (e) { } catch (e) {
console.error('Failed to load Q-Assets news', e); console.error('Failed to load Q-Assets news', e);
if (!cancelled) {
setAnnouncements([]); setAnnouncements([]);
setAssetNews([]); setAssetNews([]);
setPromotions([]); setPromotions([]);
}
} finally { } finally {
if (!cancelled) setLoading(false); setLoading(false);
} }
}; },
load(); [
return () => { showArchivedAnnouncements,
cancelled = true; showArchivedNews,
}; showMoreAnnouncements,
}, [showArchivedAnnouncements, showArchivedNews, showMoreAnnouncements, showMoreNews]); showMoreNews,
memberGroupIds,
]
);
const loadingLists = const loadingLists =
loading || announcements === null || assetNews === null || promotions === null; loading || groupsLoading || announcements === null || assetNews === null || promotions === null;
const handleClickItem = (item: NewsSummary) => { const handleClickItem = (item: NewsSummary) => {
setSelected(item); setSelected(item);
@@ -252,11 +268,32 @@ export default function QAssetsNewsSection() {
// setDetailLoading(false); // setDetailLoading(false);
}; };
useEffect(() => {
let cancelled = false;
(async () => {
if (cancelled) return;
await loadNews(true);
})();
return () => {
cancelled = true;
};
}, [loadNews]);
useEffect(() => {
const handler = () => {
loadNews(true);
};
window.addEventListener(NEWS_REFRESH_EVENT, handler);
return () => {
window.removeEventListener(NEWS_REFRESH_EVENT, handler);
};
}, [loadNews]);
const announcementList = announcements || []; const announcementList = announcements || [];
const assetNewsList = assetNews || [];
const announcementActive = announcementList.filter((item) => !item.isExpired); const announcementActive = announcementList.filter((item) => !item.isExpired);
const announcementArchived = announcementList.filter((item) => item.isExpired); const announcementArchived = announcementList.filter((item) => item.isExpired);
const assetNewsList = assetNews || [];
const newsActive = assetNewsList.filter((item) => !item.isExpired); const newsActive = assetNewsList.filter((item) => !item.isExpired);
const newsArchived = assetNewsList.filter((item) => item.isExpired); const newsArchived = assetNewsList.filter((item) => item.isExpired);
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { useAuth } from 'qapp-core';
import { getAccountGroups } from '../utils/qortalApi';
export function useMemberGroupIds() {
const { address } = useAuth();
const [memberGroupIds, setMemberGroupIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!address) {
setMemberGroupIds([]);
return;
}
let cancelled = false;
(async () => {
try {
setLoading(true);
const cacheKey = `memberGroups:${address}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed) && !cancelled) {
setMemberGroupIds(parsed as number[]);
}
} catch {
/* ignore cache parse */
}
}
const groups = await getAccountGroups(address);
if (!cancelled) {
const next = groups
.map((g) => Number(g.groupId))
.filter((n) => Number.isFinite(n)) as number[];
setMemberGroupIds(next);
localStorage.setItem(cacheKey, JSON.stringify(next));
}
} catch {
if (!cancelled) setMemberGroupIds([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [address]);
return { memberGroupIds, loading };
}
-85
View File
@@ -1,85 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
publishResourcesWithProgress,
type PublishJobDefinition,
type PublishJobOptions,
type PublishJobProgress,
type PublishThrottleContext,
} from '../utils/qdnProgressivePublisher';
export type PublishThrottleState = {
context: PublishThrottleContext;
secondsLeft: number;
resume: () => void;
cancel: () => void;
};
export function useQdnProgressivePublisher() {
const [progress, setProgress] = useState<PublishJobProgress | null>(null);
const [throttle, setThrottle] = useState<PublishThrottleState | null>(null);
useEffect(() => {
if (!throttle) return;
if (throttle.secondsLeft <= 0) {
throttle.resume();
return;
}
const id = setInterval(() => {
setThrottle((prev) => {
if (!prev) return prev;
if (prev.secondsLeft <= 1) {
prev.resume();
return null;
}
return { ...prev, secondsLeft: prev.secondsLeft - 1 };
});
}, 1000);
return () => clearInterval(id);
}, [throttle]);
const publish = useCallback(async (job: PublishJobDefinition, options?: PublishJobOptions) => {
const { onProgress, onThrottle, ...rest } = options || {};
setProgress(null);
const handleProgress = (ctx: PublishJobProgress) => {
setProgress(ctx);
onProgress?.(ctx);
};
const handleThrottle = async (ctx: PublishThrottleContext) => {
if (onThrottle) {
const decision = await onThrottle(ctx);
if (decision === false) return false;
}
return new Promise<boolean>((resolve) => {
const resolveAndClear = (value: boolean) => {
resolve(value);
setThrottle(null);
};
setThrottle({
context: ctx,
secondsLeft: Math.ceil(ctx.delayMs / 1000),
resume: () => resolveAndClear(true),
cancel: () => resolveAndClear(false),
});
});
};
try {
await publishResourcesWithProgress(job, {
...rest,
onProgress: handleProgress,
onThrottle: handleThrottle,
});
} finally {
setProgress(null);
setThrottle(null);
}
}, []);
return { publish, progress, throttle };
}
+9 -3
View File
@@ -24,7 +24,13 @@ const MAX_AUTO_PAGES = 40;
/** /**
* Generic hook for listing QDN resources for a given name, with paging support. * Generic hook for listing QDN resources for a given name, with paging support.
*/ */
export function useQdnResources(name: string | null) { export function useQdnResources(
name: string | null,
options?: {
autoFetch?: boolean;
}
) {
const autoFetch = options?.autoFetch !== false;
const [rows, setRows] = useState<QdnResource[]>([]); const [rows, setRows] = useState<QdnResource[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
@@ -110,8 +116,8 @@ export function useQdnResources(name: string | null) {
useEffect(() => { useEffect(() => {
resetState(); resetState();
if (name) void fetchPage(0); if (name && autoFetch) void fetchPage(0);
}, [name, fetchPage, resetState]); }, [name, fetchPage, resetState, autoFetch]);
return { rows, loading, hasMore, loadMore, loadAll, error, reset: resetState, reload }; return { rows, loading, hasMore, loadMore, loadAll, error, reset: resetState, reload };
} }
-2
View File
@@ -154,8 +154,6 @@ export async function sendNotification(
message, message,
batchSize: request.qmailOptions?.batchSize, batchSize: request.qmailOptions?.batchSize,
resumeFrom: request.qmailOptions?.resumeFrom, resumeFrom: request.qmailOptions?.resumeFrom,
onThrottle: request.qmailOptions?.onThrottle,
onProgress: request.qmailOptions?.onProgress,
}); });
results.qmail = { recipients: recipients.length }; results.qmail = { recipients: recipients.length };
} }
+61 -3
View File
@@ -63,6 +63,8 @@ import PublishedHtmlRenderer from '../components/PublishedHtmlRenderer';
import { useAlert } from '../components/alerts'; import { useAlert } from '../components/alerts';
import { updateAsset, getAccountGroups, type GroupSummary, getGroupById } from '../utils/qortalApi'; import { updateAsset, getAccountGroups, type GroupSummary, getGroupById } from '../utils/qortalApi';
// import { getAssetInfo } from '../utils/qortalAssetRequests'; // import { getAssetInfo } from '../utils/qortalAssetRequests';
import { useMemberGroupIds } from '../hooks/useMemberGroupIds';
import { canViewAsset, getAssetPrivacy, type AssetPrivacy } from '../utils/assetPrivacy';
type Enriched = { type Enriched = {
assetId: number; assetId: number;
@@ -174,9 +176,27 @@ export default function AssetDetail() {
// const navigate = useNavigate(); // const navigate = useNavigate();
const isIssuer = !!asset && !!userAddress && asset.owner === userAddress; const isIssuer = !!asset && !!userAddress && asset.owner === userAddress;
const { alert } = useAlert(); const { alert } = useAlert();
const { memberGroupIds, loading: memberGroupsLoading } = useMemberGroupIds();
const [assetPrivacy, setAssetPrivacy] = useState<AssetPrivacy | null>(null);
const [privacyChecked, setPrivacyChecked] = useState(false);
const canPublish = isIssuer && issuerName && issuerName === (userName as string | undefined); const canPublish = isIssuer && issuerName && issuerName === (userName as string | undefined);
const id = useMemo(() => Number(assetId), [assetId]); const id = useMemo(() => Number(assetId), [assetId]);
useEffect(() => {
let cancelled = false;
setPrivacyChecked(false);
(async () => {
try {
const priv = await getAssetPrivacy(id);
if (!cancelled) setAssetPrivacy(priv);
} finally {
if (!cancelled) setPrivacyChecked(true);
}
})();
return () => {
cancelled = true;
};
}, [id]);
const publishAllChanges = useCallback(async () => { const publishAllChanges = useCallback(async () => {
if (!canPublish || !asset) return; if (!canPublish || !asset) return;
setPublishing(true); setPublishing(true);
@@ -205,7 +225,15 @@ export default function AssetDetail() {
: undefined, : undefined,
customFields: currentPub.customFields ?? {}, customFields: currentPub.customFields ?? {},
}; };
await publishAssetPublication(userName as string, asset.name, pub); const privateGroupId =
pub.privateAsset && Number.isFinite(pub.privateGroupId)
? Number(pub.privateGroupId)
: pub.privateAsset && normalizedPrimary?.id
? Number(normalizedPrimary.id)
: undefined;
await publishAssetPublication(userName as string, asset.name, pub, {
privateGroupId,
});
alert('Publication updated!'); alert('Publication updated!');
setAssetPub(pub); setAssetPub(pub);
setHasPendingChanges(false); setHasPendingChanges(false);
@@ -260,6 +288,8 @@ export default function AssetDetail() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
if (!Number.isFinite(id)) return; if (!Number.isFinite(id)) return;
if (!privacyChecked) return;
if (assetPrivacy && !canViewAsset(assetPrivacy, memberGroupIds)) return;
try { try {
// 1) Try sync cache // 1) Try sync cache
@@ -350,7 +380,9 @@ export default function AssetDetail() {
// Publication // Publication
try { try {
if (iname) { if (iname) {
const pub = await fetchAssetPublication(iname, mini.name); const pub = await fetchAssetPublication(iname, mini.name, mini.assetId, {
preferPrivate: assetPrivacy?.isPrivate,
});
if (!cancelled) setAssetPub(pub); if (!cancelled) setAssetPub(pub);
} }
} catch { } catch {
@@ -364,7 +396,7 @@ export default function AssetDetail() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [id]); }, [id, privacyChecked, assetPrivacy, memberGroupIds]);
useEffect(() => { useEffect(() => {
setPrimaryForm( setPrimaryForm(
@@ -430,6 +462,30 @@ export default function AssetDetail() {
if (!asset) return <Typography>Loading asset...</Typography>; if (!asset) return <Typography>Loading asset...</Typography>;
const loadingGroupsCombined = groupsLoading || memberGroupsLoading;
if (loadingGroupsCombined || !privacyChecked) {
return (
<Box display="flex" justifyContent="center" py={6}>
<CircularProgress />
</Box>
);
}
if (assetPrivacy && !canViewAsset(assetPrivacy, memberGroupIds)) {
return (
<Box sx={{ p: { xs: 2, md: 3 } }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6">Private asset</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
You must be a member of group
{assetPrivacy.groupId ? ` #${assetPrivacy.groupId}` : ''} to view this assets details.
</Typography>
</Paper>
</Box>
);
}
return ( return (
<PageContainer> <PageContainer>
{/* Header row: title + (left info, right actions) */} {/* Header row: title + (left info, right actions) */}
@@ -804,6 +860,8 @@ export default function AssetDetail() {
assetId={asset.assetId} assetId={asset.assetId}
primaryGroupId={parseInt(String(assetPub?.primaryGroup?.id || ''), 10)} primaryGroupId={parseInt(String(assetPub?.primaryGroup?.id || ''), 10)}
issuerName={issuerName} issuerName={issuerName}
isPrivate={assetPrivacy?.isPrivate}
privateGroupId={assetPrivacy?.groupId}
isIssuer={!!canPublish} isIssuer={!!canPublish}
/> />
</Grid> </Grid>
+194 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo, useCallback } from 'react';
import { getAssetBalances } from '../utils/qortalAssetRequests'; import { getAssetBalances } from '../utils/qortalAssetRequests';
import { import {
Typography, Typography,
@@ -23,6 +23,8 @@ import { useExplorerStats } from '../explorerStats/useExplorerStats';
import { TRADE_FETCH_N } from '../explorerStats/types'; import { TRADE_FETCH_N } from '../explorerStats/types';
import { usePrimaryGroupId } from '../utils/usePrimaryGroupId'; import { usePrimaryGroupId } from '../utils/usePrimaryGroupId';
import { loadStats } from '../explorerStats/storage'; import { loadStats } from '../explorerStats/storage';
import { useMemberGroupIds } from '../hooks/useMemberGroupIds';
import { canViewAsset, getAssetPrivacy, type AssetPrivacy } from '../utils/assetPrivacy';
export interface Asset { export interface Asset {
assetId: number; assetId: number;
@@ -195,8 +197,10 @@ const AssetExplorer = () => {
const [sortKey, setSortKey] = useState<SortKey>('volume'); const [sortKey, setSortKey] = useState<SortKey>('volume');
const [sortDir, setSortDir] = useState<SortDir>('desc'); const [sortDir, setSortDir] = useState<SortDir>('desc');
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [privacyMap, setPrivacyMap] = useState<Record<number, AssetPrivacy>>({});
const theme = useTheme(); const theme = useTheme();
const { memberGroupIds, loading: groupsLoading } = useMemberGroupIds();
const { address: userAddress } = useAuth(); const { address: userAddress } = useAuth();
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
@@ -205,16 +209,33 @@ const AssetExplorer = () => {
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
const sortedAssets = useMemo(() => { const viewableAssets = useMemo(() => {
const copy = [...assets]; return assets.filter((a) => {
if (a.assetId <= 2) return true;
const privacy = privacyMap[a.assetId];
if (!privacy) return false; // hide until privacy resolved
return canViewAsset(privacy, memberGroupIds);
});
}, [assets, privacyMap, memberGroupIds]);
const publicAssets = useMemo(
() => viewableAssets.filter((a) => !privacyMap[a.assetId]?.isPrivate),
[viewableAssets, privacyMap]
);
const privateAssets = useMemo(
() => viewableAssets.filter((a) => privacyMap[a.assetId]?.isPrivate),
[viewableAssets, privacyMap]
);
const sortAssets = useCallback(
(list: EnrichedAsset[]) => {
const copy = [...list];
const getVolume = (assetId: number) => { const getVolume = (assetId: number) => {
// Read from local cache written by useExplorerStats
const s = loadStats(assetId); const s = loadStats(assetId);
// push unknown volumes to the end: use -1 sentinel
return s?.qortVolLastN ?? -1; return s?.qortVolLastN ?? -1;
}; };
const getCirculating = (a: EnrichedAsset) => const getCirculating = (a: EnrichedAsset) =>
typeof a.circulating === 'number' ? a.circulating : Number.NEGATIVE_INFINITY; typeof a.circulating === 'number' ? a.circulating : Number.NEGATIVE_INFINITY;
@@ -253,17 +274,49 @@ const AssetExplorer = () => {
}); });
return copy; return copy;
// include tick so list can update as stats land },
}, [assets, sortKey, sortDir, tick]); [sortDir, sortKey, tick]
);
const sortedPublicAssets = useMemo(() => sortAssets(publicAssets), [publicAssets, sortAssets]);
const sortedPrivateAssets = useMemo(() => sortAssets(privateAssets), [privateAssets, sortAssets]);
const displayAssets = useMemo( const displayAssets = useMemo(
() => sortedAssets.slice(0, Math.min(visibleCount, sortedAssets.length)), () => sortedPublicAssets.slice(0, Math.min(visibleCount, sortedPublicAssets.length)),
[sortedAssets, visibleCount] [sortedPublicAssets, visibleCount]
); );
useEffect(() => { useEffect(() => {
setVisibleCount(PAGE_SIZE); setVisibleCount(PAGE_SIZE);
}, [assets.length, sortKey, sortDir]); }, [sortedPublicAssets.length, sortKey, sortDir]);
useEffect(() => {
const missing = assets.filter((a) => a.assetId > 2 && !privacyMap[a.assetId]);
if (!missing.length) return;
let cancelled = false;
const limit = pLimit(6);
(async () => {
const results = await Promise.all(
missing.map((a) =>
limit(async () => {
const priv = await getAssetPrivacy(a.assetId);
return [a.assetId, priv] as const;
})
)
);
if (cancelled) return;
setPrivacyMap((prev) => {
const next = { ...prev };
for (const [id, priv] of results) {
next[id] = priv;
}
return next;
});
})();
return () => {
cancelled = true;
};
}, [assets, privacyMap]);
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@@ -271,13 +324,15 @@ const AssetExplorer = () => {
window.innerHeight + window.scrollY >= document.body.offsetHeight - SCROLL_THRESHOLD_PX; window.innerHeight + window.scrollY >= document.body.offsetHeight - SCROLL_THRESHOLD_PX;
if (nearBottom) { if (nearBottom) {
setVisibleCount((prev) => setVisibleCount((prev) =>
prev >= sortedAssets.length ? prev : Math.min(prev + PAGE_SIZE, sortedAssets.length) prev >= sortedPublicAssets.length
? prev
: Math.min(prev + PAGE_SIZE, sortedPublicAssets.length)
); );
} }
}; };
window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, [sortedAssets.length]); }, [sortedPublicAssets.length]);
useEffect(() => { useEffect(() => {
async function loadAssets() { async function loadAssets() {
@@ -410,9 +465,10 @@ const AssetExplorer = () => {
}, [displayAssets]); }, [displayAssets]);
// inside your component render // inside your component render
const overallLoading = loading || groupsLoading;
return ( return (
<Box sx={{ p: { xs: 1.25, sm: 2 } }}> <Box sx={{ p: { xs: 1.25, sm: 2 } }}>
{loading ? ( {overallLoading ? (
<Box display="flex" justifyContent="center" py={8}> <Box display="flex" justifyContent="center" py={8}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
@@ -667,11 +723,133 @@ const AssetExplorer = () => {
); );
})} })}
</Box> </Box>
{visibleCount < sortedAssets.length && ( {sortedPrivateAssets.length > 0 && (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Private Assets (accessible via your groups)
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(auto-fit, minmax(30rem, 1fr))',
},
gap: 2,
}}
>
{sortedPrivateAssets.map((asset) => {
const balance = balances[asset.assetId] || 0;
const isOwned = !!userAddress && asset.owner === userAddress;
const bgColor = isOwned
? theme.palette.secondary.dark
: balance > 0
? theme.palette.primary.dark
: theme.palette.grey[900];
const textColor = theme.palette.getContrastText(bgColor);
const borderColor = isOwned ? 'limegreen' : balance > 0 ? '#1e90ff' : 'orange';
return (
<Link
key={asset.assetId}
to={`/assets/${asset.assetId}`}
style={{ textDecoration: 'none' }}
>
<Paper
elevation={5}
sx={{
overflow: 'hidden',
p: { xs: 1.25, sm: 1.5 },
height: '100%',
backgroundColor: bgColor,
color: textColor,
borderLeft: `4px solid ${borderColor}`,
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: 'stretch',
gap: { xs: 1, sm: 1.25 },
flexWrap: 'noWrap',
}}
>
<Box sx={{ flex: '1 1 0', minWidth: 0 }}>
<Typography variant="h4" fontWeight={800} color="warning.light">
{asset.name}
</Typography>
<Typography
variant="body2"
sx={{
mb: 1.25,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: { xs: 3, sm: 3, md: 3 },
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
}}
>
{asset.description || 'No description'}
</Typography>
<Typography variant="caption" color="warning.main">
Private asset visible because you belong to its group
</Typography>
{balance > 0 && (
<Box sx={{ mt: 0.5 }}>
<Typography
variant="subtitle1"
color="secondary.light"
component="span"
fontWeight={700}
>
You Hold:{' '}
</Typography>
<Typography component="span" color="success.contrastText">
{formatAssetAmount(balance, asset.isDivisible)}
</Typography>
</Box>
)}
</Box>
<Box
sx={{
flex: { xs: '0 0 auto', sm: '0 0 auto' },
alignSelf: 'center',
width: { xs: 'min(70%, 220px)', sm: 'clamp(140px, 18vw, 180px)' },
aspectRatio: '1 / 1',
borderRadius: '999px',
overflow: 'hidden',
display: 'grid',
placeItems: 'center',
mx: { sm: 1 },
}}
>
{avatarMap[asset.assetId] ? (
<img
loading="lazy"
src={avatarMap[asset.assetId]!}
alt={`${asset.name} Avatar`}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
) : (
<div style={{ width: '100%', height: '100%', opacity: 0.5 }} />
)}
</Box>
</Paper>
</Link>
);
})}
</Box>
</Box>
)}
{visibleCount < sortedPublicAssets.length && (
<Box display="flex" justifyContent="center" mt={2}> <Box display="flex" justifyContent="center" mt={2}>
<button <button
onClick={() => onClick={() =>
setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, sortedAssets.length)) setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, sortedPublicAssets.length))
} }
style={{ style={{
padding: '10px 16px', padding: '10px 16px',
+55
View File
@@ -12,6 +12,7 @@ import {
Select, Select,
MenuItem, MenuItem,
CircularProgress, CircularProgress,
FormHelperText,
Chip, Chip,
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
@@ -51,6 +52,7 @@ export default function IssueAsset() {
const [groupIsPrivate, setGroupIsPrivate] = useState(false); const [groupIsPrivate, setGroupIsPrivate] = useState(false);
const [avatarBase64, setAvatarBase64] = useState<string>(''); const [avatarBase64, setAvatarBase64] = useState<string>('');
// const [newAssetID, setNewAssetID] = useState<number>(0); // const [newAssetID, setNewAssetID] = useState<number>(0);
const [privateAsset, setPrivateAsset] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -106,6 +108,7 @@ export default function IssueAsset() {
setGroupId(''); setGroupId('');
setGroupLink(''); setGroupLink('');
setGroupIsPrivate(false); setGroupIsPrivate(false);
setPrivateAsset(false);
setAttemptedSubmit(false); setAttemptedSubmit(false);
setAvatarBase64(''); setAvatarBase64('');
setHtml(''); setHtml('');
@@ -139,6 +142,19 @@ export default function IssueAsset() {
setSuccess(null); setSuccess(null);
if (!assetData) setAssetData('None'); if (!assetData) setAssetData('None');
if (privateAsset) {
if (!groupId) {
setError('Private assets require selecting a private group.');
setLoading(false);
return;
}
if (!groupIsPrivate) {
setError('Selected group must be private for a private asset.');
setLoading(false);
return;
}
}
// Snapshot state so later resetForm() won't overwrite values during async work // Snapshot state so later resetForm() won't overwrite values during async work
const currentAssetName = assetName; const currentAssetName = assetName;
const currentDescription = description; const currentDescription = description;
@@ -147,6 +163,10 @@ export default function IssueAsset() {
const currentUnspendable = isUnspendable; const currentUnspendable = isUnspendable;
const currentAssetData = assetData; const currentAssetData = assetData;
const currentAvatarBase64 = avatarBase64; const currentAvatarBase64 = avatarBase64;
const currentPrivateAsset = privateAsset;
const parsedGroupId = groupId ? Number(groupId) : NaN;
const normalizedPrivateGroupId =
currentPrivateAsset && Number.isFinite(parsedGroupId) ? parsedGroupId : undefined;
try { try {
const predictedAssetID = await predictAssetID(); const predictedAssetID = await predictAssetID();
@@ -156,6 +176,8 @@ export default function IssueAsset() {
const publication: AssetPublication = { const publication: AssetPublication = {
description: currentDescription, description: currentDescription,
html, html,
privateAsset: currentPrivateAsset,
privateGroupId: normalizedPrivateGroupId,
primaryGroup: { primaryGroup: {
name: groupName, name: groupName,
id: groupId, id: groupId,
@@ -346,6 +368,29 @@ export default function IssueAsset() {
<Divider sx={{ my: 3 }} /> <Divider sx={{ my: 3 }} />
{/* Private asset toggle */}
<Typography variant="h5" fontWeight={600} color="primary.contrastText">
Visibility
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Private assets require a private group and will be hidden from public listings.
</Typography>
<FormControlLabel
control={
<Checkbox
checked={privateAsset}
onChange={(e) => setPrivateAsset(e.target.checked)}
sx={{
color: theme.palette.primary.dark,
'&.Mui-checked': { color: theme.palette.info.main },
}}
/>
}
label="Make this a private asset"
/>
<Divider sx={{ my: 3 }} />
{/* Group Info */} {/* Group Info */}
<Typography variant="h5" fontWeight={600} color="primary.contrastText"> <Typography variant="h5" fontWeight={600} color="primary.contrastText">
Asset-Related Group Data Asset-Related Group Data
@@ -381,6 +426,7 @@ export default function IssueAsset() {
if (!match) return value; if (!match) return value;
return `${match.groupName} (#${match.groupId}) — ${match.isOpen ? 'Public' : 'Private'}`; return `${match.groupName} (#${match.groupId}) — ${match.isOpen ? 'Public' : 'Private'}`;
}} }}
error={privateAsset && attemptedSubmit && (!groupId || !groupIsPrivate)}
> >
{groupOptions.map((group) => ( {groupOptions.map((group) => (
<MenuItem key={group.groupId} value={group.groupId}> <MenuItem key={group.groupId} value={group.groupId}>
@@ -403,6 +449,15 @@ export default function IssueAsset() {
<CircularProgress size={20} /> <CircularProgress size={20} />
</Box> </Box>
)} )}
<FormHelperText error={privateAsset && attemptedSubmit && (!groupId || !groupIsPrivate)}>
{privateAsset
? !groupId
? 'Private assets must choose a private group.'
: groupIsPrivate
? 'Private group selected.'
: 'Selected group is public; choose a private group.'
: 'Primary group for this asset (optional but recommended).'}
</FormHelperText>
</FormControl> </FormControl>
<Divider sx={{ my: 3 }} /> <Divider sx={{ my: 3 }} />
+46 -5
View File
@@ -11,6 +11,8 @@ import { makeAssetFallbackAvatar } from '../utils/assetAvatarFallback';
import { fetchQortVolumeLastN } from '../explorerStats/fetchers'; import { fetchQortVolumeLastN } from '../explorerStats/fetchers';
import { TRADE_FETCH_N } from '../explorerStats/types'; import { TRADE_FETCH_N } from '../explorerStats/types';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
import { useMemberGroupIds } from '../hooks/useMemberGroupIds';
import { canViewAsset, getAssetPrivacy, type AssetPrivacy } from '../utils/assetPrivacy';
type Row = { type Row = {
assetId: number; assetId: number;
@@ -30,6 +32,8 @@ export default function TradeMarkets() {
type VolInfo = { sum: number; count: number; ts: number }; type VolInfo = { sum: number; count: number; ts: number };
const VOL_TTL_MS = 10 * 60 * 1000; const VOL_TTL_MS = 10 * 60 * 1000;
const theme = useTheme(); const theme = useTheme();
const { memberGroupIds, loading: groupsLoading } = useMemberGroupIds();
const [privacyMap, setPrivacyMap] = useState<Record<number, AssetPrivacy>>({});
const [volumes, setVolumes] = useState<Record<number, VolInfo>>({}); const [volumes, setVolumes] = useState<Record<number, VolInfo>>({});
const [sortKey, setSortKey] = useState<'volume' | 'name' | 'assetId'>('volume'); const [sortKey, setSortKey] = useState<'volume' | 'name' | 'assetId'>('volume');
@@ -45,7 +49,9 @@ export default function TradeMarkets() {
const writeVolCache = (m: Record<number, VolInfo>) => { const writeVolCache = (m: Record<number, VolInfo>) => {
try { try {
localStorage.setItem('marketVolumes', JSON.stringify(m)); localStorage.setItem('marketVolumes', JSON.stringify(m));
} catch {} } catch {
/* empty */
}
}; };
useEffect(() => { useEffect(() => {
@@ -110,6 +116,32 @@ export default function TradeMarkets() {
}; };
}, []); }, []);
useEffect(() => {
const missing = rows.filter((r) => r.assetId > 2 && !privacyMap[r.assetId]);
if (!missing.length) return;
let cancelled = false;
const limit = pLimit(6);
(async () => {
const results = await Promise.all(
missing.map((r) =>
limit(async () => {
const priv = await getAssetPrivacy(r.assetId);
return [r.assetId, priv] as const;
})
)
);
if (cancelled) return;
setPrivacyMap((prev) => {
const next = { ...prev };
for (const [id, priv] of results) next[id] = priv;
return next;
});
})();
return () => {
cancelled = true;
};
}, [rows, privacyMap]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (rows.length === 0) return; if (rows.length === 0) return;
@@ -155,16 +187,25 @@ export default function TradeMarkets() {
}; };
}, [rows]); }, [rows]);
const viewableRows = useMemo(() => {
return rows.filter((r) => {
if (r.assetId <= 2) return false; // should not appear anyway
const privacy = privacyMap[r.assetId];
if (!privacy) return false;
return canViewAsset(privacy, memberGroupIds);
});
}, [rows, privacyMap, memberGroupIds]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const s = q.trim().toLowerCase(); const s = q.trim().toLowerCase();
if (!s) return rows; if (!s) return viewableRows;
return rows.filter( return viewableRows.filter(
(r) => (r) =>
r.name.toLowerCase().includes(s) || r.name.toLowerCase().includes(s) ||
r.description?.toLowerCase().includes(s) || r.description?.toLowerCase().includes(s) ||
String(r.assetId).includes(s) String(r.assetId).includes(s)
); );
}, [rows, q]); }, [viewableRows, q]);
const sorted = useMemo(() => { const sorted = useMemo(() => {
const arr = [...filtered]; const arr = [...filtered];
@@ -236,7 +277,7 @@ export default function TradeMarkets() {
</Box> </Box>
</Box> </Box>
{loading && rows.length === 0 ? ( {(loading || groupsLoading) && viewableRows.length === 0 ? (
<Box display="flex" justifyContent="center" py={6}> <Box display="flex" justifyContent="center" py={6}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
+73 -4
View File
@@ -52,6 +52,8 @@ import SmartPriceChart from '../components/trade/SmartPriceChart';
import ActionsToolbar from '../components/asset/ActionsToolbar'; import ActionsToolbar from '../components/asset/ActionsToolbar';
import { useAlert } from '../components/alerts'; import { useAlert } from '../components/alerts';
import { useTheme, useMediaQuery } from '@mui/material'; import { useTheme, useMediaQuery } from '@mui/material';
import { useMemberGroupIds } from '../hooks/useMemberGroupIds';
import { getAssetPrivacy, canViewAsset, type AssetPrivacy } from '../utils/assetPrivacy';
export default function TradePair() { export default function TradePair() {
const { assetId } = useParams<{ assetId: string }>(); const { assetId } = useParams<{ assetId: string }>();
@@ -73,6 +75,8 @@ export default function TradePair() {
const [issuerAddr, setIssuerAddr] = useState<string | null>(null); const [issuerAddr, setIssuerAddr] = useState<string | null>(null);
const [balAsset, setBalAsset] = useState<number | null>(null); const [balAsset, setBalAsset] = useState<number | null>(null);
const [balQort, setBalQort] = useState<number | null>(null); const [balQort, setBalQort] = useState<number | null>(null);
const [assetPrivacy, setAssetPrivacy] = useState<AssetPrivacy | null>(null);
const [privacyChecked, setPrivacyChecked] = useState(false);
// ---- controls for chart window & bucket // ---- controls for chart window & bucket
const [rangeHours, setRangeHours] = useState<number>(720); // set default range hours const [rangeHours, setRangeHours] = useState<number>(720); // set default range hours
const [bucketMinutes, setBucketMinutes] = useState<number>(60); // 1, 5, 15, 60 etc. const [bucketMinutes, setBucketMinutes] = useState<number>(60); // 1, 5, 15, 60 etc.
@@ -87,6 +91,12 @@ export default function TradePair() {
const isXs = useMediaQuery(theme.breakpoints.down('sm')); const isXs = useMediaQuery(theme.breakpoints.down('sm'));
const { alert } = useAlert(); const { alert } = useAlert();
const { memberGroupIds, loading: groupsLoading } = useMemberGroupIds();
const privacyAllowed = useMemo(() => {
if (!privacyChecked) return false;
if (!assetPrivacy) return true;
return canViewAsset(assetPrivacy, memberGroupIds);
}, [assetPrivacy, memberGroupIds, privacyChecked]);
const candles = useMemo(() => { const candles = useMemo(() => {
const lookbackMs = rangeHours * 60 * 60 * 1000; const lookbackMs = rangeHours * 60 * 60 * 1000;
@@ -139,6 +149,11 @@ export default function TradePair() {
} }
async function refreshBalances() { async function refreshBalances() {
if (!privacyAllowed) {
setBalAsset(null);
setBalQort(null);
return;
}
if (!authAddress) { if (!authAddress) {
setBalAsset(null); setBalAsset(null);
setBalQort(null); setBalQort(null);
@@ -179,6 +194,26 @@ export default function TradePair() {
const DP = 8; const DP = 8;
const TEN_DP = 100000000n; const TEN_DP = 100000000n;
useEffect(() => {
let cancelled = false;
setPrivacyChecked(false);
(async () => {
try {
const priv = await getAssetPrivacy(id);
if (cancelled) return;
setAssetPrivacy(priv);
setPrivacyChecked(true);
} catch {
if (cancelled) return;
setAssetPrivacy({ isPrivate: false });
setPrivacyChecked(true);
}
})();
return () => {
cancelled = true;
};
}, [id]);
function decimalToAtomics(s: string, dp = DP): bigint { function decimalToAtomics(s: string, dp = DP): bigint {
const m = s.trim().match(/^(\d+)(?:\.(\d{0,18}))?$/); // allow up to 18 just in case const m = s.trim().match(/^(\d+)(?:\.(\d{0,18}))?$/); // allow up to 18 just in case
if (!m) throw new Error('bad decimal'); if (!m) throw new Error('bad decimal');
@@ -292,6 +327,10 @@ export default function TradePair() {
} }
const refreshMarket = useCallback(async () => { const refreshMarket = useCallback(async () => {
if (!privacyAllowed) {
setLoading(false);
return;
}
try { try {
setLoading(true); setLoading(true);
@@ -393,9 +432,13 @@ export default function TradePair() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [id, authAddress, divisible]); }, [id, authAddress, divisible, privacyAllowed]);
const refreshMyFills = useCallback(async () => { const refreshMyFills = useCallback(async () => {
if (!privacyAllowed) {
setMyFills([]);
return;
}
if (!authAddress) { if (!authAddress) {
setMyFills([]); setMyFills([]);
return; return;
@@ -419,7 +462,7 @@ export default function TradePair() {
console.debug('[fills] error', e); console.debug('[fills] error', e);
setMyFills([]); setMyFills([]);
} }
}, [authAddress, authPublicKey, id]); }, [authAddress, authPublicKey, id, privacyAllowed]);
useMarketConfirmRefresh({ useMarketConfirmRefresh({
assetId: id, assetId: id,
@@ -438,7 +481,11 @@ export default function TradePair() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
if (!cancelled) { if (!cancelled && privacyChecked) {
if (!privacyAllowed) {
setLoading(false);
return;
}
await refreshMarket(); await refreshMarket();
await refreshBalances(); await refreshBalances();
await refreshMyFills(); await refreshMyFills();
@@ -450,7 +497,7 @@ export default function TradePair() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [privacyAllowed, privacyChecked, refreshMarket, refreshBalances, refreshMyFills]);
// ----- Place order state // ----- Place order state
const [side, setSide] = useState<'buy' | 'sell'>('buy'); const [side, setSide] = useState<'buy' | 'sell'>('buy');
@@ -621,6 +668,28 @@ export default function TradePair() {
return balAsset != null && needAsset > (balAsset ?? 0); return balAsset != null && needAsset > (balAsset ?? 0);
}, [side, needQort, needAsset, balQort, balAsset, authAddress]); }, [side, needQort, needAsset, balQort, balAsset, authAddress]);
if (groupsLoading || !privacyChecked) {
return (
<Box display="flex" justifyContent="center" py={6}>
<CircularProgress />
</Box>
);
}
if (privacyChecked && assetPrivacy && !privacyAllowed) {
return (
<Box sx={{ p: { xs: 2, md: 3 } }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6">Private asset</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
You must be a member of group
{assetPrivacy.groupId ? ` #${assetPrivacy.groupId}` : ''} to view and trade this asset.
</Typography>
</Paper>
</Box>
);
}
return ( return (
<Box <Box
sx={{ sx={{
+92 -29
View File
@@ -58,7 +58,7 @@ import {
} from '../../../constants/qdeckIdentifiers'; } from '../../../constants/qdeckIdentifiers';
import { collectRecipientPublicKeys } from '../../../utils/qdeckAccess'; import { collectRecipientPublicKeys } from '../../../utils/qdeckAccess';
import { getAccountGroups, type GroupSummary } from '../../../utils/qortalApi'; import { getAccountGroups, type GroupSummary } from '../../../utils/qortalApi';
import { useAuth } from 'qapp-core'; import { useAuth, VideoPlayer } from 'qapp-core';
import type { Service } from 'qapp-core'; import type { Service } from 'qapp-core';
import { import {
MANIFEST_IDENTIFIER, MANIFEST_IDENTIFIER,
@@ -114,7 +114,7 @@ type PreviewDialogState = {
title?: string; title?: string;
content?: string; content?: string;
dataUrl?: string; dataUrl?: string;
type?: 'text' | 'binary' | 'image'; type?: 'text' | 'binary' | 'image' | 'video';
error?: string; error?: string;
loading?: boolean; loading?: boolean;
steps: PreviewStep[]; steps: PreviewStep[];
@@ -140,6 +140,7 @@ const createPreviewDialogState = (): PreviewDialogState => ({
}); });
const PUBLISH_MODE_STORAGE_KEY = 'qassets_publish_mode_preference_v1'; const PUBLISH_MODE_STORAGE_KEY = 'qassets_publish_mode_preference_v1';
type ManifestLoadState = 'idle' | 'loading' | 'success' | 'missing';
const SERVICE_OPTIONS = ALL_QDN_SERVICES; const SERVICE_OPTIONS = ALL_QDN_SERVICES;
const PENDING_FOLDERS_KEY = 'qassets_data_pending_folders_v1'; const PENDING_FOLDERS_KEY = 'qassets_data_pending_folders_v1';
@@ -346,14 +347,18 @@ const detectMimeFromBase64 = (base64: string, fallback: string) => {
const hasPrivateMagicPrefix = (base64: string) => base64.startsWith(PRIVATE_MAGIC_B64); const hasPrivateMagicPrefix = (base64: string) => base64.startsWith(PRIVATE_MAGIC_B64);
const applyPrivateMagicIfNeeded = (base64: string, service?: string) => { type EncryptionMode = 'group' | 'direct' | null | undefined;
if (isPrivateService(service)) console.log('private service:', service);
const applyPrivateMagicIfNeeded = (base64: string, service?: string, mode?: EncryptionMode) => {
if (!isPrivateService(service)) return base64;
if (mode !== 'group') return base64;
return hasPrivateMagicPrefix(base64) ? base64 : addPrivateMagic(base64); return hasPrivateMagicPrefix(base64) ? base64 : addPrivateMagic(base64);
}; };
const stripPrivateMagicIfNeeded = (base64: string, _service?: string) => { const stripPrivateMagicIfNeeded = (base64: string, service?: string, mode?: EncryptionMode) => {
console.log(_service); if (!isPrivateService(service)) return base64;
return stripPrivateMagic(base64); if (mode !== 'group') return base64;
return hasPrivateMagicPrefix(base64) ? stripPrivateMagic(base64) : base64;
}; };
const resolveMimeForResource = ( const resolveMimeForResource = (
@@ -511,7 +516,7 @@ async function decryptPrivateBase64(
mode = 'direct'; mode = 'direct';
} }
const encryptedPayload = stripPrivateMagicIfNeeded(encryptedWithMagic, resource.service); const encryptedPayload = stripPrivateMagicIfNeeded(encryptedWithMagic, resource.service, mode);
// Always try direct decrypt first (covers NODE-inserted metadata-less items) // Always try direct decrypt first (covers NODE-inserted metadata-less items)
try { try {
@@ -889,6 +894,7 @@ export default function DataExplorer() {
const [previewDialog, setPreviewDialog] = useState<PreviewDialogState>( const [previewDialog, setPreviewDialog] = useState<PreviewDialogState>(
createPreviewDialogState() createPreviewDialogState()
); );
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const [manifestDialog, setManifestDialog] = useState<{ const [manifestDialog, setManifestDialog] = useState<{
open: boolean; open: boolean;
entry: StructuredEntry | null; entry: StructuredEntry | null;
@@ -992,6 +998,7 @@ export default function DataExplorer() {
const [manifestPublishing, setManifestPublishing] = useState(false); const [manifestPublishing, setManifestPublishing] = useState(false);
const [manifestError, setManifestError] = useState<string | null>(null); const [manifestError, setManifestError] = useState<string | null>(null);
const [manifestRefreshBlockedUntil, setManifestRefreshBlockedUntil] = useState(0); const [manifestRefreshBlockedUntil, setManifestRefreshBlockedUntil] = useState(0);
const [manifestLoadState, setManifestLoadState] = useState<ManifestLoadState>('idle');
const [ignoreManifestCache, setIgnoreManifestCache] = useState(false); const [ignoreManifestCache, setIgnoreManifestCache] = useState(false);
const [loadingAllPages, setLoadingAllPages] = useState(false); const [loadingAllPages, setLoadingAllPages] = useState(false);
const [detectedTypes, setDetectedTypes] = useState<Record<string, string>>({}); const [detectedTypes, setDetectedTypes] = useState<Record<string, string>>({});
@@ -1244,11 +1251,15 @@ export default function DataExplorer() {
} }
}, [pendingFolders, activeName]); }, [pendingFolders, activeName]);
const applyManifestState = useCallback((doc: ManifestDoc | null, dirty: boolean) => { const applyManifestState = useCallback(
(doc: ManifestDoc | null, dirty: boolean, loadState?: ManifestLoadState) => {
setManifestDoc(doc); setManifestDoc(doc);
setDetectedTypes(doc?.resourceTypes || {}); setDetectedTypes(doc?.resourceTypes || {});
setManifestDirty(dirty); setManifestDirty(dirty);
}, []); if (loadState) setManifestLoadState(loadState);
},
[]
);
const fetchManifestDoc = useCallback(async (): Promise<ManifestDoc | null> => { const fetchManifestDoc = useCallback(async (): Promise<ManifestDoc | null> => {
if (!activeName) return null; if (!activeName) return null;
@@ -1263,7 +1274,7 @@ export default function DataExplorer() {
const data64 = normalizeData64(res); const data64 = normalizeData64(res);
if (!data64) return null; if (!data64) return null;
if (!decrypt) return JSON.parse(base64ToUtf8(data64)); if (!decrypt) return JSON.parse(base64ToUtf8(data64));
const payload = stripPrivateMagicIfNeeded(data64, service); const payload = stripPrivateMagicIfNeeded(data64, service, 'direct');
const clear = await qortalRequest({ const clear = await qortalRequest({
action: 'DECRYPT_DATA', action: 'DECRYPT_DATA',
encryptedData: payload, encryptedData: payload,
@@ -1284,10 +1295,11 @@ export default function DataExplorer() {
const refreshManifestDoc = useCallback(async () => { const refreshManifestDoc = useCallback(async () => {
if (!activeName) { if (!activeName) {
applyManifestState(null, false); applyManifestState(null, false, 'idle');
return; return;
} }
if (Date.now() < manifestRefreshBlockedUntil) return; if (Date.now() < manifestRefreshBlockedUntil) return;
setManifestLoadState('loading');
try { try {
const doc = await fetchManifestDoc(); const doc = await fetchManifestDoc();
if ( if (
@@ -1296,26 +1308,28 @@ export default function DataExplorer() {
doc.generatedAt && doc.generatedAt &&
doc.generatedAt <= manifestDoc.generatedAt doc.generatedAt <= manifestDoc.generatedAt
) { ) {
setManifestLoadState('success');
return; return;
} }
applyManifestState(doc, false); applyManifestState(doc, false, doc ? 'success' : 'missing');
} catch { } catch {
applyManifestState(null, true); applyManifestState(null, true, 'missing');
} }
}, [activeName, fetchManifestDoc, applyManifestState, manifestDoc, manifestRefreshBlockedUntil]); }, [activeName, fetchManifestDoc, applyManifestState, manifestDoc, manifestRefreshBlockedUntil]);
useEffect(() => { useEffect(() => {
if (!activeName) { if (!activeName) {
applyManifestState(null, false); applyManifestState(null, false, 'idle');
return; return;
} }
let cancelled = false; let cancelled = false;
setManifestLoadState('loading');
(async () => { (async () => {
try { try {
const doc = await fetchManifestDoc(); const doc = await fetchManifestDoc();
if (!cancelled) applyManifestState(doc, false); if (!cancelled) applyManifestState(doc, false, doc ? 'success' : 'missing');
} catch { } catch {
if (!cancelled) applyManifestState(null, true); if (!cancelled) applyManifestState(null, true, 'missing');
} }
})(); })();
return () => { return () => {
@@ -2235,22 +2249,25 @@ export default function DataExplorer() {
setSharePage(1); setSharePage(1);
}; };
const handleReload = async () => { const handleLoadFromNetwork = async () => {
await refreshResources(); await refreshResources();
}; };
const handleLoadRemaining = useCallback(async () => { const handleLoadRemaining = useCallback(async () => {
if (!hasMore || resourcesLoading || loadingAllPages) return; if (resourcesLoading || loadingAllPages) return;
setIgnoreManifestCache(true); setIgnoreManifestCache(true);
setLoadingAllPages(true); setLoadingAllPages(true);
try { try {
if (!rows.length) {
await reload();
}
await loadAll(); await loadAll();
} catch { } catch {
// errors surfaced via useQdnResources error state // errors surfaced via useQdnResources error state
} finally { } finally {
setLoadingAllPages(false); setLoadingAllPages(false);
} }
}, [hasMore, resourcesLoading, loadingAllPages, loadAll]); }, [resourcesLoading, loadingAllPages, loadAll, reload, rows.length]);
const handlePublishOpen = (variant: 'single' | 'multiple') => { const handlePublishOpen = (variant: 'single' | 'multiple') => {
if (!activeName) { if (!activeName) {
@@ -2345,7 +2362,7 @@ export default function DataExplorer() {
isAdmins: groupAdminsOnly, isAdmins: groupAdminsOnly,
}); });
const finalService = ensurePrivateService(form.service); const finalService = ensurePrivateService(form.service);
const privData64 = applyPrivateMagicIfNeeded(enc, finalService); const privData64 = applyPrivateMagicIfNeeded(enc, finalService, 'group');
return { return {
data64: privData64, data64: privData64,
service: finalService, service: finalService,
@@ -2375,7 +2392,7 @@ export default function DataExplorer() {
}); });
const finalService = ensurePrivateService(form.service); const finalService = ensurePrivateService(form.service);
return { return {
data64: applyPrivateMagicIfNeeded(enc, finalService), data64: applyPrivateMagicIfNeeded(enc, finalService, 'direct'),
service: finalService, service: finalService,
metadataExtra: { encrypted: { mode: 'direct', recipients } }, metadataExtra: { encrypted: { mode: 'direct', recipients } },
tagExtra: ['private', 'encrypted:direct'], tagExtra: ['private', 'encrypted:direct'],
@@ -2579,6 +2596,19 @@ export default function DataExplorer() {
return; return;
} }
if (loaded.mime.startsWith('video/')) {
setPreviewDialog((prev) => ({
...prev,
open: true,
loading: false,
title: getResourceLabel(target),
type: 'video',
resource: target,
zoomed: false,
}));
return;
}
try { try {
const text = base64ToUtf8(loaded.base64); const text = base64ToUtf8(loaded.base64);
if (isProbablyText(text)) { if (isProbablyText(text)) {
@@ -2811,7 +2841,7 @@ export default function DataExplorer() {
base64: data64, base64: data64,
publicKeys, publicKeys,
}); });
const privateData64 = applyPrivateMagicIfNeeded(encrypted, MANIFEST_SERVICE); const privateData64 = applyPrivateMagicIfNeeded(encrypted, MANIFEST_SERVICE, 'direct');
const metadata = { const metadata = {
qassetsManifest: { version: 1, visibility: 'private' }, qassetsManifest: { version: 1, visibility: 'private' },
encrypted: { mode: 'direct', recipients: [publisherAddress] }, encrypted: { mode: 'direct', recipients: [publisherAddress] },
@@ -2829,8 +2859,7 @@ export default function DataExplorer() {
}, },
]); ]);
setManifestRefreshBlockedUntil(Date.now() + MANIFEST_REFRESH_COOLDOWN); setManifestRefreshBlockedUntil(Date.now() + MANIFEST_REFRESH_COOLDOWN);
setManifestDoc(manifestPayload); applyManifestState(manifestPayload, false, 'success');
setManifestDirty(false);
} catch (e: any) { } catch (e: any) {
setManifestError(e?.message || 'Manifest publish failed'); setManifestError(e?.message || 'Manifest publish failed');
} finally { } finally {
@@ -2844,6 +2873,7 @@ export default function DataExplorer() {
publishResources, publishResources,
resolvePublisherAddress, resolvePublisherAddress,
flushPendingPublishRequests, flushPendingPublishRequests,
applyManifestState,
] ]
); );
@@ -2926,7 +2956,7 @@ export default function DataExplorer() {
isAdmins: false, isAdmins: false,
}); });
const service = ensurePrivateService(resource.service); const service = ensurePrivateService(resource.service);
const privData = applyPrivateMagicIfNeeded(enc, service); const privData = applyPrivateMagicIfNeeded(enc, service, 'group');
shareRequests.push({ shareRequests.push({
name: publisherName, name: publisherName,
service, service,
@@ -2967,7 +2997,7 @@ export default function DataExplorer() {
publicKeys, publicKeys,
}); });
const service = ensurePrivateService(resource.service); const service = ensurePrivateService(resource.service);
const privData = applyPrivateMagicIfNeeded(enc, service); const privData = applyPrivateMagicIfNeeded(enc, service, 'direct');
shareRequests.push({ shareRequests.push({
name: publisherName, name: publisherName,
service, service,
@@ -3706,7 +3736,10 @@ export default function DataExplorer() {
</Button> </Button>
<Tooltip title="Refresh current folder"> <Tooltip title="Refresh current folder">
<span> <span>
<IconButton onClick={handleReload} disabled={!activeName || resourcesLoading}> <IconButton
onClick={handleLoadFromNetwork}
disabled={!activeName || resourcesLoading}
>
<RefreshRoundedIcon /> <RefreshRoundedIcon />
</IconButton> </IconButton>
</span> </span>
@@ -3760,6 +3793,16 @@ export default function DataExplorer() {
{loadingAllPages ? 'Loading…' : 'Load remaining'} {loadingAllPages ? 'Loading…' : 'Load remaining'}
</Button> </Button>
)} )}
{activeName && manifestLoadState === 'success' && (
<Button
variant="contained"
color="primary"
onClick={handleLoadFromNetwork}
disabled={resourcesLoading || loadingAllPages}
>
{loadingAllPages ? 'Loading…' : 'Load from network'}
</Button>
)}
</Stack> </Stack>
{manifestBoundaryReached && hasMore && !ignoreManifestCache && ( {manifestBoundaryReached && hasMore && !ignoreManifestCache && (
@@ -3769,7 +3812,9 @@ export default function DataExplorer() {
action={ action={
<Button <Button
size="small" size="small"
onClick={handleLoadRemaining} variant="contained"
color="primary"
onClick={handleLoadFromNetwork}
disabled={resourcesLoading || loadingAllPages} disabled={resourcesLoading || loadingAllPages}
> >
{loadingAllPages ? 'Loading…' : 'Load from network'} {loadingAllPages ? 'Loading…' : 'Load from network'}
@@ -4977,6 +5022,24 @@ export default function DataExplorer() {
}} }}
/> />
)} )}
{previewDialog.type === 'video' && previewDialog.resource && (
<Box
sx={{
width: '100%',
maxWidth: '100%',
height: previewDialog.expanded ? '70vh' : 420,
}}
>
<VideoPlayer
videoRef={previewVideoRef}
qortalVideoResource={{
service: previewDialog.resource.service as any,
name: previewDialog.resource.name,
identifier: previewDialog.resource.identifier,
}}
/>
</Box>
)}
{previewDialog.type === 'binary' && ( {previewDialog.type === 'binary' && (
<Typography variant="body2">{previewDialog.content}</Typography> <Typography variant="body2">{previewDialog.content}</Typography>
)} )}
@@ -12,14 +12,26 @@ declare function qortalRequest<T = any>(req: any): Promise<T>;
export const hasPrivateMagicPrefix = (base64: string) => base64.startsWith(PRIVATE_MAGIC_B64); export const hasPrivateMagicPrefix = (base64: string) => base64.startsWith(PRIVATE_MAGIC_B64);
export const applyPrivateMagicIfNeeded = (base64: string, service?: string) => { type EncryptionMode = 'group' | 'direct' | null | undefined;
if (isPrivateService(service)) return base64;
export const applyPrivateMagicIfNeeded = (
base64: string,
service?: string,
mode?: EncryptionMode
) => {
if (!isPrivateService(service)) return base64;
if (mode !== 'group') return base64;
return hasPrivateMagicPrefix(base64) ? base64 : addPrivateMagic(base64); return hasPrivateMagicPrefix(base64) ? base64 : addPrivateMagic(base64);
}; };
export const stripPrivateMagicIfNeeded = (base64: string, _service?: string) => { export const stripPrivateMagicIfNeeded = (
console.log('removing private magic', base64, 'from service (if passed)', _service); base64: string,
return stripPrivateMagic(base64); service?: string,
mode?: EncryptionMode
) => {
if (!isPrivateService(service)) return base64;
if (mode !== 'group') return base64;
return hasPrivateMagicPrefix(base64) ? stripPrivateMagic(base64) : base64;
}; };
type GroupDecryptAttempt = { type GroupDecryptAttempt = {
@@ -135,7 +147,7 @@ async function decryptPrivateBase64(
mode = 'direct'; mode = 'direct';
} }
const encryptedPayload = stripPrivateMagicIfNeeded(encryptedWithMagic, resource.service); const encryptedPayload = stripPrivateMagicIfNeeded(encryptedWithMagic, resource.service, mode);
try { try {
const direct = await qortalRequest({ const direct = await qortalRequest({
+395
View File
@@ -0,0 +1,395 @@
import { useSyncExternalStore } from 'react';
import { BatchPublishResource } from '../utils/useQdnBatchPublisher';
import {
PublishJobProgress,
PublishThrottleContext,
publishResourcesWithProgress,
PublishJobError,
} from '../utils/qdnProgressivePublisher';
type PublishQueueJobBase = {
id: string;
label?: string;
createdAt: number;
status: 'pending' | 'running' | 'completed' | 'error';
progress: PublishJobProgress | null;
error?: string;
};
export type PublishThrottleState = {
context: PublishThrottleContext;
secondsLeft: number;
resume: () => void;
cancel: () => void;
};
type QdnJobPayload = {
kind: 'qdn';
resources: BatchPublishResource[];
chunkSize?: number;
throttleDelayMs?: number;
};
type QmailJobPayload = {
kind: 'qmail';
resources: BatchPublishResource[];
chunkSize?: number;
throttleDelayMs?: number;
fallbackPublicKeys: string[];
identifierKeyMap: Record<string, string>;
chunkTimeoutPerResourceMs?: number;
};
type PublishQueueJob = PublishQueueJobBase & {
payload: QdnJobPayload | QmailJobPayload;
throttle: PublishThrottleState | null;
completionPromise: Promise<void>;
resolveCompletion: () => void;
rejectCompletion: (error: Error) => void;
};
type PersistedJob = {
id: string;
label?: string;
createdAt: number;
payload: QdnJobPayload | QmailJobPayload;
};
type PublishQueueSnapshot = {
jobs: PublishQueueJob[];
activeJobId?: string;
};
const STORAGE_KEY = 'qassets_publish_queue_v1';
const listeners = new Set<() => void>();
const hasWindow = typeof window !== 'undefined';
const isQmailPayload = (payload: QdnJobPayload | QmailJobPayload): payload is QmailJobPayload =>
payload.kind === 'qmail';
const createQueueJob = (
payload: QdnJobPayload | QmailJobPayload,
meta?: {
id?: string;
label?: string;
createdAt?: number;
status?: PublishQueueJob['status'];
}
): PublishQueueJob => {
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
const completionPromise = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
return {
id: meta?.id ?? generateId(),
label: meta?.label,
createdAt: meta?.createdAt ?? Date.now(),
status: meta?.status ?? 'pending',
progress: null,
throttle: null,
payload,
completionPromise,
resolveCompletion,
rejectCompletion,
};
};
const createSnapshot = (): PublishQueueSnapshot => ({
jobs: [],
activeJobId: undefined,
});
const snapshot: PublishQueueSnapshot = createSnapshot();
let processing = false;
const notify = () => {
listeners.forEach((listener) => listener());
};
const persist = () => {
if (!hasWindow) return;
try {
const serializable = snapshot.jobs
.filter((job) => job.status === 'pending')
.map((job) => ({
id: job.id,
label: job.label,
createdAt: job.createdAt,
payload: job.payload,
}));
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable));
} catch {
/* ignore */
}
};
const loadPersistedJobs = () => {
if (!hasWindow) return;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return;
parsed.forEach((item: PersistedJob) => {
if (!item || typeof item !== 'object' || !Array.isArray(item.payload?.resources)) return;
const job = createQueueJob(item.payload, {
id: item.id,
label: item.label,
createdAt: item.createdAt || Date.now(),
});
snapshot.jobs.push(job);
});
} catch {
/* ignore */
}
};
const generateId = () => `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const updateJob = (jobId: string, updater: (job: PublishQueueJob) => void) => {
const job = snapshot.jobs.find((j) => j.id === jobId);
if (!job) return;
updater(job);
notify();
};
const cleanupJob = (jobId: string) => {
const idx = snapshot.jobs.findIndex((j) => j.id === jobId);
if (idx >= 0) {
snapshot.jobs.splice(idx, 1);
}
if (snapshot.activeJobId === jobId) {
snapshot.activeJobId = undefined;
}
persist();
notify();
};
const startNextJob = async () => {
if (processing) return;
processing = true;
try {
while (true) {
const nextJob = snapshot.jobs.find((j) => j.status === 'pending');
if (!nextJob) break;
await runJob(nextJob);
}
} finally {
processing = false;
}
};
loadPersistedJobs();
if (snapshot.jobs.length) {
startNextJob();
}
if (snapshot.jobs.length) {
startNextJob();
}
const startThrottleCountdown = (
job: PublishQueueJob,
ctx: PublishThrottleContext,
resolve: (value: boolean) => void
) => {
if (!hasWindow) {
resolve(true);
return;
}
let resolved = false;
const interval = window.setInterval(() => {
updateJob(job.id, (target) => {
if (!target.throttle) return;
const next = target.throttle.secondsLeft - 1;
target.throttle = { ...target.throttle, secondsLeft: Math.max(0, next) };
if (next <= 0 && !resolved) {
resolved = true;
window.clearInterval(interval);
target.throttle = null;
resolve(true);
}
});
}, 1000);
job.throttle = {
context: ctx,
secondsLeft: Math.ceil(ctx.delayMs / 1000),
resume: () => {
if (resolved) return;
resolved = true;
if (hasWindow) window.clearInterval(interval);
updateJob(job.id, (target) => {
target.throttle = null;
});
resolve(true);
},
cancel: () => {
if (resolved) return;
resolved = true;
if (hasWindow) window.clearInterval(interval);
updateJob(job.id, (target) => {
target.throttle = null;
});
resolve(false);
},
};
notify();
};
const runJob = async (job: PublishQueueJob) => {
snapshot.activeJobId = job.id;
job.status = 'running';
job.progress = null;
job.throttle = null;
notify();
const abortController = new AbortController();
const handleProgress = (progress: PublishJobProgress) => {
updateJob(job.id, (target) => {
target.progress = progress;
});
};
const handleThrottle = (ctx: PublishThrottleContext) =>
new Promise<boolean>((resolve) => {
updateJob(job.id, (target) => {
startThrottleCountdown(target, ctx, resolve);
});
});
const execOptions: Parameters<typeof publishResourcesWithProgress>[1] = {
chunkSize: job.payload.chunkSize,
throttleDelayMs: job.payload.throttleDelayMs,
signal: abortController.signal,
onProgress: handleProgress,
onThrottle: handleThrottle,
};
if (isQmailPayload(job.payload)) {
const qmailPayload = job.payload;
execOptions.executeChunk = async (chunk) => {
const identifierMap = qmailPayload.identifierKeyMap;
const chunkKeys = new Set<string>();
chunk.forEach((resource) => {
const key = identifierMap[resource.identifier];
if (key) chunkKeys.add(key);
});
const keys = chunkKeys.size ? Array.from(chunkKeys) : qmailPayload.fallbackPublicKeys;
const perResource = qmailPayload.chunkTimeoutPerResourceMs ?? 12e5;
const timeoutMs = Math.max(perResource, chunk.length * perResource);
await qortalRequestWithTimeout(
{
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
resources: chunk,
encrypt: true,
publicKeys: keys,
},
timeoutMs
);
};
}
try {
await publishResourcesWithProgress(
{
label: job.label,
resources: job.payload.resources,
},
execOptions
);
job.status = 'completed';
job.progress = null;
job.resolveCompletion();
} catch (error: any) {
job.status = 'error';
job.error = error instanceof PublishJobError ? error.message : error?.message || String(error);
job.rejectCompletion(
error instanceof Error ? error : new Error(error?.message || String(error))
);
} finally {
snapshot.activeJobId = undefined;
job.progress = null;
job.throttle = null;
cleanupJob(job.id);
}
};
export const enqueueQdnPublishJob = (params: {
label?: string;
resources: BatchPublishResource[];
chunkSize?: number;
throttleDelayMs?: number;
}) => {
if (!params.resources.length) return null;
const job = createQueueJob(
{
kind: 'qdn',
resources: params.resources,
chunkSize: params.chunkSize,
throttleDelayMs: params.throttleDelayMs,
},
{ label: params.label }
);
snapshot.jobs.push(job);
persist();
notify();
startNextJob();
return { id: job.id, completion: job.completionPromise };
};
export const enqueueQmailPublishJob = (params: {
label?: string;
resources: BatchPublishResource[];
fallbackPublicKeys: string[];
identifierKeyMap: Record<string, string>;
chunkSize?: number;
throttleDelayMs?: number;
chunkTimeoutPerResourceMs?: number;
}) => {
if (!params.resources.length) return null;
const job = createQueueJob(
{
kind: 'qmail',
resources: params.resources,
chunkSize: params.chunkSize,
throttleDelayMs: params.throttleDelayMs,
fallbackPublicKeys: params.fallbackPublicKeys,
identifierKeyMap: params.identifierKeyMap,
chunkTimeoutPerResourceMs: params.chunkTimeoutPerResourceMs,
},
{ label: params.label }
);
snapshot.jobs.push(job);
persist();
notify();
startNextJob();
return { id: job.id, completion: job.completionPromise };
};
export const usePublishQueue = () =>
useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => snapshot,
() => snapshot
);
export const resumeThrottledJob = (jobId: string) => {
const job = snapshot.jobs.find((j) => j.id === jobId);
job?.throttle?.resume();
};
export const cancelThrottledJob = (jobId: string) => {
const job = snapshot.jobs.find((j) => j.id === jobId);
job?.throttle?.cancel();
};
+3
View File
@@ -20,6 +20,8 @@ export type DividendPeriod = '1W' | '2W' | '1M' | '3M' | '6M' | '1Y';
export interface AssetPublication { export interface AssetPublication {
description?: string; description?: string;
html?: string; // rendered in a rich display section html?: string; // rendered in a rich display section
privateAsset?: boolean; // opt-in private asset flag
privateGroupId?: number; // group that gates private content/visibility
genesisPostId?: string; // ID or identifier to a BLOG_POST genesisPostId?: string; // ID or identifier to a BLOG_POST
primaryGroup?: { primaryGroup?: {
name: string; name: string;
@@ -47,6 +49,7 @@ export function normalizePublication(pub?: AssetPublication): AssetPublication {
const p: AssetPublication = { ...(pub ?? {}) }; const p: AssetPublication = { ...(pub ?? {}) };
if (p.dividends == null) p.dividends = false; if (p.dividends == null) p.dividends = false;
if (p.dividends && !isValidDividendPeriod(p.dividendPeriod)) p.dividendPeriod = '1M'; if (p.dividends && !isValidDividendPeriod(p.dividendPeriod)) p.dividendPeriod = '1M';
if (p.privateAsset == null) p.privateAsset = false;
return p; return p;
} }
export function isValidDividendPeriod(x: any): x is DividendPeriod { export function isValidDividendPeriod(x: any): x is DividendPeriod {
+29
View File
@@ -0,0 +1,29 @@
import { resolveAssetPublicationById } from './resolveAssetPublication';
export type AssetPrivacy = { isPrivate: boolean; groupId?: number };
const cache = new Map<number, AssetPrivacy>();
export async function getAssetPrivacy(assetId: number): Promise<AssetPrivacy> {
if (assetId <= 2) return { isPrivate: false };
if (cache.has(assetId)) return cache.get(assetId)!;
try {
const { publication } = await resolveAssetPublicationById(assetId);
const rawGroup = publication?.privateGroupId ?? publication?.primaryGroup?.id;
const groupId =
rawGroup != null && Number.isFinite(Number(rawGroup)) ? Number(rawGroup) : undefined;
const info: AssetPrivacy = { isPrivate: Boolean(publication?.privateAsset), groupId };
cache.set(assetId, info);
return info;
} catch {
const info: AssetPrivacy = { isPrivate: false };
cache.set(assetId, info);
return info;
}
}
export function canViewAsset(privacy: AssetPrivacy, memberGroupIds: number[]) {
if (!privacy.isPrivate) return true;
return privacy.groupId != null && memberGroupIds.includes(privacy.groupId);
}
+29 -8
View File
@@ -1,27 +1,48 @@
import { getAssetIdentifiers } from '../constants/qdnConstants'; import { getAssetIdentifiers } from '../constants/qdnConstants';
import { base64ToObject } from './data'; import { base64ToObject } from './data';
import type { AssetPublication } from '../types/AssetPublicationMetadata'; import type { AssetPublication } from '../types/AssetPublicationMetadata';
import { stripPrivateMagic } from '../constants/qdeckIdentifiers';
export const fetchAssetPublication = async ( export const fetchAssetPublication = async (
name: string, name: string,
assetName: string, assetName: string,
assetId?: number assetId?: number,
opts?: { preferPrivate?: boolean }
): Promise<AssetPublication | null> => { ): Promise<AssetPublication | null> => {
const publishInfo = await getAssetIdentifiers(assetName, assetId); const publishInfo = await getAssetIdentifiers(assetName, assetId);
// Try correct, ID-based identifier first const tryFetch = async (service: any, identifier: string, isPrivate: boolean) => {
try { const res = await qortalRequest({
const pub = await qortalRequest({
action: 'FETCH_QDN_RESOURCE', action: 'FETCH_QDN_RESOURCE',
name, name,
service: publishInfo.services.genesisPost, service,
identifier: publishInfo.identifiers.genesisPost, identifier,
encoding: 'base64',
}); });
const raw = res?.data64 ?? res;
const cleaned = isPrivate && typeof raw === 'string' ? stripPrivateMagic(raw) : raw;
return base64ToObject(cleaned);
};
return await base64ToObject(pub); const tryOrder: Array<{ svc: any; priv: boolean }> = opts?.preferPrivate
? [
{ svc: 'DOCUMENT_PRIVATE', priv: true },
{ svc: publishInfo.services.genesisPost, priv: false },
]
: [
{ svc: publishInfo.services.genesisPost, priv: false },
{ svc: 'DOCUMENT_PRIVATE', priv: true },
];
for (const attempt of tryOrder) {
try {
const pub = await tryFetch(attempt.svc, publishInfo.identifiers.genesisPost, attempt.priv);
if (pub) return pub;
} catch { } catch {
console.warn(`No publication for correct ID ${assetId}. Trying fallback search...`); /* try next */
} }
}
console.warn(`No publication for correct ID ${assetId}. Trying fallback search...`);
// Fallback: search for anything resembling the asset name // Fallback: search for anything resembling the asset name
const results = await qortalRequest({ const results = await qortalRequest({
+64 -12
View File
@@ -7,7 +7,8 @@ import { stripHtml, extractTitleFromHtml, isManagementAdminPublisher } from './n
import { loadAnnouncementApprovalDoc } from './announcementApprovals'; import { loadAnnouncementApprovalDoc } from './announcementApprovals';
import { getNewsPromoExpiryDays, publisherHasPermission } from './managementManifest'; import { getNewsPromoExpiryDays, publisherHasPermission } from './managementManifest';
import { base64ToObject, base64ToUtf8 } from './data'; import { base64ToObject, base64ToUtf8 } from './data';
import { getCached, setCached } from './cache'; import { getCached, setCached, invalidateByPrefix } from './cache';
import { resolveAssetPublicationById } from './resolveAssetPublication';
async function canPublishAnnouncement(publisher: string): Promise<boolean> { async function canPublishAnnouncement(publisher: string): Promise<boolean> {
try { try {
@@ -54,10 +55,15 @@ const decodeAnnouncementResource = async (data64?: string | null) => {
return null; return null;
}; };
type FetchNewsOptions = { includeExpired?: boolean }; type FetchNewsOptions = {
includeExpired?: boolean;
forceFresh?: boolean;
allowedGroupIds?: number[]; // membership list to gate private asset news
};
const LIST_CACHE_MS = 60_000; const LIST_CACHE_MS = 60_000;
const ITEM_CACHE_MS = 5 * 60_000; const ITEM_CACHE_MS = 5 * 60_000;
export const NEWS_REFRESH_EVENT = 'qassets:news-refresh';
export async function fetchAnnouncements( export async function fetchAnnouncements(
limit = 5, limit = 5,
@@ -66,8 +72,10 @@ export async function fetchAnnouncements(
try { try {
const includeExpired = options?.includeExpired ?? false; const includeExpired = options?.includeExpired ?? false;
const listKey = `ann:list:${includeExpired}:${limit}`; const listKey = `ann:list:${includeExpired}:${limit}`;
if (!options?.forceFresh) {
const cachedList = getCached<NewsSummary[]>(listKey); const cachedList = getCached<NewsSummary[]>(listKey);
if (cachedList) return cachedList; if (cachedList) return cachedList;
}
const expiryDays = Number(await getNewsPromoExpiryDays()); const expiryDays = Number(await getNewsPromoExpiryDays());
const expiryCutoff = const expiryCutoff =
@@ -134,7 +142,8 @@ export async function fetchAnnouncements(
if (approvedEntries.length) { if (approvedEntries.length) {
const ordered = approvedEntries const ordered = approvedEntries
.slice() .slice()
.sort((a, b) => (b.approvedAt || b.createdAt || 0) - (a.approvedAt || a.createdAt || 0)); .sort((a, b) => (b.approvedAt || b.createdAt || 0) - (a.approvedAt || a.createdAt || 0))
.slice(0, limit * 2);
for (const entry of ordered) { for (const entry of ordered) {
const dedupeKey = keyFor(entry.publisher, entry.identifier); const dedupeKey = keyFor(entry.publisher, entry.identifier);
@@ -154,18 +163,16 @@ export async function fetchAnnouncements(
} }
// Also surface admin-published announcements even if not explicitly approved // Also surface admin-published announcements even if not explicitly approved
if (items.length < limit) {
let docHits: Awaited<ReturnType<typeof searchSimpleByIdentifierPrefix>> = []; let docHits: Awaited<ReturnType<typeof searchSimpleByIdentifierPrefix>> = [];
let jsonHits: Awaited<ReturnType<typeof searchSimpleByIdentifierPrefix>> = [];
try { try {
[docHits, jsonHits] = await Promise.all([ [docHits] = await Promise.all([
searchSimpleByIdentifierPrefix('DOCUMENT', qaAnnouncementPrefix, limit), searchSimpleByIdentifierPrefix('DOCUMENT', qaAnnouncementPrefix, limit * 2),
searchSimpleByIdentifierPrefix('JSON', qaAnnouncementPrefix, limit).catch(() => []),
]); ]);
} catch (e) { } catch (e) {
console.warn('Failed to fetch announcement list', e); console.warn('Failed to fetch announcement list', e);
} }
const allHits = [...docHits, ...jsonHits].sort( const allHits = [...docHits].sort(
(a, b) => (b.created || b.updated || 0) - (a.created || a.updated || 0) (a, b) => (b.created || b.updated || 0) - (a.created || a.updated || 0)
); );
@@ -179,8 +186,7 @@ export async function fetchAnnouncements(
if (added) { if (added) {
seen.add(dedupeKey); seen.add(dedupeKey);
} }
if (items.length >= limit) break; if (items.length >= limit * 2) break;
}
} }
const finalList = items.sort((a, b) => b.created - a.created).slice(0, limit); const finalList = items.sort((a, b) => b.created - a.created).slice(0, limit);
@@ -192,13 +198,30 @@ export async function fetchAnnouncements(
} }
} }
export function invalidateAnnouncementCache() {
invalidateByPrefix('ann:');
}
export function dispatchNewsRefreshEvent() {
if (typeof window === 'undefined') return;
try {
window.dispatchEvent(new Event(NEWS_REFRESH_EVENT));
} catch {
/* ignore */
}
}
export async function fetchLatestAssetNews( export async function fetchLatestAssetNews(
limit = 10, limit = 10,
options?: FetchNewsOptions options?: FetchNewsOptions
): Promise<NewsSummary[]> { ): Promise<NewsSummary[]> {
try { try {
const includeExpired = options?.includeExpired ?? false; const includeExpired = options?.includeExpired ?? false;
const listKey = `assetnews:list:${includeExpired}:${limit}`; const allowedGroupIds = options?.allowedGroupIds ?? [];
const listKey = `assetnews:list:${includeExpired}:${limit}:${allowedGroupIds
.slice()
.sort((a, b) => a - b)
.join(',')}`;
const cachedList = getCached<NewsSummary[]>(listKey); const cachedList = getCached<NewsSummary[]>(listKey);
if (cachedList) return cachedList; if (cachedList) return cachedList;
@@ -218,6 +241,26 @@ export async function fetchLatestAssetNews(
const items: NewsSummary[] = []; const items: NewsSummary[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const privacyCache = new Map<number, { isPrivate: boolean; groupId?: number }>();
const getPrivacy = async (assetId: number) => {
if (privacyCache.has(assetId)) return privacyCache.get(assetId)!;
try {
const { publication } = await resolveAssetPublicationById(assetId);
const groupIdRaw = publication?.privateGroupId ?? publication?.primaryGroup?.id;
const groupIdNum = groupIdRaw != null ? Number(groupIdRaw) : undefined;
const info = {
isPrivate: Boolean(publication?.privateAsset),
groupId: Number.isFinite(groupIdNum as number) ? Number(groupIdNum) : undefined,
};
privacyCache.set(assetId, info);
return info;
} catch {
const info = { isPrivate: false, groupId: undefined as number | undefined };
privacyCache.set(assetId, info);
return info;
}
};
for (const hit of hits) { for (const hit of hits) {
const dedupeKey = `${hit.name}::${hit.identifier}`; const dedupeKey = `${hit.name}::${hit.identifier}`;
@@ -286,6 +329,15 @@ export async function fetchLatestAssetNews(
const assetName = assetId != null ? `Asset #${assetId}` : undefined; const assetName = assetId != null ? `Asset #${assetId}` : undefined;
// Visibility: hide private asset news unless viewer is in the allowed group
if (assetId != null) {
const privacy = await getPrivacy(assetId);
if (privacy.isPrivate) {
if (!privacy.groupId) continue; // cannot authorize, skip
if (!allowedGroupIds.includes(privacy.groupId)) continue;
}
}
title = extractTitleFromHtml( title = extractTitleFromHtml(
html, html,
assetId != null ? `News for ${assetName}` : 'Asset news' assetId != null ? `News for ${assetName}` : 'Asset news'
+8 -2
View File
@@ -15,7 +15,8 @@ import {
import { base64ToObject, objectToBase64 } from './data'; import { base64ToObject, objectToBase64 } from './data';
import { sendChatMessage } from './qchat'; import { sendChatMessage } from './qchat';
import { getAccount, getTransactionInfoBySignature, transferAsset } from './qortalApi'; import { getAccount, getTransactionInfoBySignature, transferAsset } from './qortalApi';
import { BatchPublishResource, publishQdnResources } from './useQdnBatchPublisher'; import { BatchPublishResource } from './useQdnBatchPublisher';
import { enqueueQdnPublishJob } from '../state/publishQueue';
export type NotifPriority = 'low' | 'normal' | 'high'; export type NotifPriority = 'low' | 'normal' | 'high';
export type NotifScopeStr = export type NotifScopeStr =
@@ -302,7 +303,12 @@ export async function publishNotification(args: {
}); });
} }
await publishQdnResources(resources); const queued = enqueueQdnPublishJob({
label: `Notification publish (${scopeKey})`,
resources,
});
if (!queued) throw new Error('Unable to queue notification publish');
await queued.completion;
// 4) Optional: ping Q-Chat (feeless) // 4) Optional: ping Q-Chat (feeless)
if (scopeKey === 'global' && args.chatGroupForGlobal) { if (scopeKey === 'global' && args.chatGroupForGlobal) {
+19 -4
View File
@@ -1,28 +1,43 @@
import { objectToBase64 } from './data'; import { objectToBase64 } from './data';
import type { AssetPublication } from '../types/AssetPublicationMetadata'; import type { AssetPublication } from '../types/AssetPublicationMetadata';
import { getAssetIdentifiers } from '../constants/qdnConstants'; import { getAssetIdentifiers } from '../constants/qdnConstants';
// import { useAuth } from 'qapp-core'; import { addPrivateMagic } from '../constants/qdeckIdentifiers';
export const publishAssetPublication = async ( export const publishAssetPublication = async (
owner: string, owner: string,
assetName: string, assetName: string,
pub: AssetPublication pub: AssetPublication,
opts?: { privateGroupId?: number }
) => { ) => {
if (!owner) throw new Error('ownerName is required'); if (!owner) throw new Error('ownerName is required');
if (!assetName) throw new Error('assetName is required'); if (!assetName) throw new Error('assetName is required');
const publishInfo = await getAssetIdentifiers(assetName); const publishInfo = await getAssetIdentifiers(assetName);
const identifier = publishInfo.identifiers.genesisPost; const identifier = publishInfo.identifiers.genesisPost;
const service = publishInfo.services.genesisPost; const baseService = publishInfo.services.genesisPost;
const data64 = await objectToBase64(pub); const data64 = await objectToBase64(pub);
const isPrivate = Number.isFinite(opts?.privateGroupId);
const service = isPrivate ? ('DOCUMENT_PRIVATE' as const) : baseService;
let finalData = data64;
if (isPrivate && opts?.privateGroupId != null) {
const encrypted = await qortalRequest({
action: 'ENCRYPT_QORTAL_GROUP_DATA',
base64: data64,
groupId: opts.privateGroupId,
isAdmins: false,
});
finalData = addPrivateMagic(encrypted);
}
try { try {
await qortalRequest({ await qortalRequest({
action: 'PUBLISH_QDN_RESOURCE', action: 'PUBLISH_QDN_RESOURCE',
name: owner, name: owner,
service, service,
identifier, identifier,
data64, data64: finalData,
}); });
} catch (e: any) { } catch (e: any) {
const msg = e?.message || String(e); const msg = e?.message || String(e);
+17 -95
View File
@@ -2,7 +2,7 @@ import { objectToBase64 } from './data';
import { NotificationRecipient } from './notificationRecipients'; import { NotificationRecipient } from './notificationRecipients';
import { uniqueId6 } from './ids'; import { uniqueId6 } from './ids';
import type { Service } from 'qapp-core'; import type { Service } from 'qapp-core';
import { publishResourcesWithProgress } from './qdnProgressivePublisher'; import { enqueueQmailPublishJob } from '../state/publishQueue';
const MAIL_SERVICE_TYPE: Service = 'MAIL_PRIVATE'; const MAIL_SERVICE_TYPE: Service = 'MAIL_PRIVATE';
const QMAIL_IDENTIFIER_PREFIX = '_mail_qortal_qmail_'; const QMAIL_IDENTIFIER_PREFIX = '_mail_qortal_qmail_';
@@ -14,29 +14,7 @@ type SendQmailParams = {
message: string; message: string;
batchSize?: number; batchSize?: number;
resumeFrom?: number; resumeFrom?: number;
onThrottle?: (ctx: {
sent: number;
total: number;
nextIndex: number;
attempt: number;
delayMs: number;
error: any;
}) => Promise<boolean> | boolean;
onProgress?: (ctx: { sent: number; total: number }) => void;
}; };
export class QmailPartialError extends Error {
code = 'QMAIL_PARTIAL';
sent: number;
total: number;
nextIndex: number;
constructor(msg: string, sent: number, total: number, nextIndex: number) {
super(msg);
this.sent = sent;
this.total = total;
this.nextIndex = nextIndex;
}
}
function buildIdentifier(recipientName: string, address: string) { function buildIdentifier(recipientName: string, address: string) {
const safeName = (recipientName || '').slice(0, 20).replace(/\s+/g, ''); const safeName = (recipientName || '').slice(0, 20).replace(/\s+/g, '');
const suffix = (address || '').slice(-6) || '000000'; const suffix = (address || '').slice(-6) || '000000';
@@ -66,12 +44,8 @@ export async function sendQmailNotifications(params: SendQmailParams) {
const batchSize = params.batchSize && params.batchSize > 0 ? params.batchSize : DEFAULT_BATCH; const batchSize = params.batchSize && params.batchSize > 0 ? params.batchSize : DEFAULT_BATCH;
const total = validRecipients.length; const total = validRecipients.length;
const startIndex = Math.min(total, Math.max(0, params.resumeFrom ?? 0)); const startIndex = Math.min(total, Math.max(0, params.resumeFrom ?? 0));
if (startIndex > 0) {
params.onProgress?.({ sent: startIndex, total });
}
if (startIndex >= total) { if (startIndex >= total) {
params.onProgress?.({ sent: total, total }); return null;
return;
} }
const resourcesWithKeys = await Promise.all( const resourcesWithKeys = await Promise.all(
@@ -98,79 +72,27 @@ export async function sendQmailNotifications(params: SendQmailParams) {
}) })
); );
const identifierToKey = new Map( const identifierKeyMap: Record<string, string> = {};
resourcesWithKeys.map(({ resource, publicKey }) => [resource.identifier, publicKey]) resourcesWithKeys.forEach(({ resource, publicKey }) => {
); identifierKeyMap[resource.identifier] = publicKey;
});
const pendingResources = resourcesWithKeys.slice(startIndex).map((entry) => entry.resource); const pendingResources = resourcesWithKeys.slice(startIndex).map((entry) => entry.resource);
if (!pendingResources.length) { if (!pendingResources.length) {
params.onProgress?.({ sent: total, total }); return null;
return;
} }
let latestSent = startIndex; const queued = enqueueQmailPublishJob({
let userCancelled = false; label: subject,
const toGlobalCount = (completedResources: number) =>
Math.min(total, startIndex + completedResources);
try {
await publishResourcesWithProgress(
{
label: 'Q-Mail notifications',
resources: pendingResources, resources: pendingResources,
}, fallbackPublicKeys,
{ identifierKeyMap,
chunkSize: batchSize, chunkSize: batchSize,
throttleDelayMs: DEFAULT_THROTTLE_DELAY, throttleDelayMs: DEFAULT_THROTTLE_DELAY,
onProgress: (ctx) => { chunkTimeoutPerResourceMs: CHUNK_PUBLISH_TIMEOUT_PER_RESOURCE,
latestSent = toGlobalCount(ctx.completedResources); });
params.onProgress?.({ sent: latestSent, total });
}, if (!queued) return null;
onThrottle: async (ctx) => { await queued.completion;
const sent = toGlobalCount(ctx.completedResources); return queued.id;
latestSent = sent;
const proceed =
(await params.onThrottle?.({
sent,
total,
nextIndex: sent,
attempt: ctx.attempt,
delayMs: ctx.delayMs,
error: ctx.error,
})) ?? true;
if (!proceed) {
userCancelled = true;
}
return proceed;
},
executeChunk: async (chunk) => {
const chunkKeys = new Set<string>();
for (const res of chunk) {
const key = identifierToKey.get(res.identifier);
if (key) chunkKeys.add(key);
}
const keysToUse = chunkKeys.size ? Array.from(chunkKeys) : fallbackPublicKeys;
const timeoutMs = Math.max(
CHUNK_PUBLISH_TIMEOUT_PER_RESOURCE,
chunk.length * CHUNK_PUBLISH_TIMEOUT_PER_RESOURCE
);
await qortalRequestWithTimeout(
{
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
resources: chunk,
encrypt: true,
publicKeys: keysToUse,
},
timeoutMs
);
},
}
);
} catch (e: any) {
if (userCancelled) {
throw new QmailPartialError('Q-Mail sending cancelled', latestSent, total, latestSent);
}
throw e;
}
} }