diff --git a/src/components/comments/CommentsSection.tsx b/src/components/comments/CommentsSection.tsx index 022b622..e7e162a 100644 --- a/src/components/comments/CommentsSection.tsx +++ b/src/components/comments/CommentsSection.tsx @@ -21,9 +21,10 @@ import { import { useAlert } from '../alerts'; 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 { assetCommentsPrefix, assetCommentId } from '../../constants/qdnConstants'; +import { addPrivateMagic, stripPrivateMagic } from '../../constants/qdeckIdentifiers'; import { uniqueId6 } from '../../utils/ids'; import { base64ToObject } from '../../utils/data'; import { fetchAccountAvatarDataUrl } from '../../utils/qdnAvatar'; @@ -118,6 +119,8 @@ export interface CommentsSectionProps { primaryGroupId: number; isIssuer?: boolean; issuerName: string | null; + isPrivate?: boolean; + privateGroupId?: number; type?: string; pageSize?: number; // default 10 roots per page collapsible?: boolean; // default false @@ -254,19 +257,21 @@ async function fetchHtmlComment( name: string, identifier: string, prefix: string, + service: Service, createdFallback?: number, updatedFallback?: number ): Promise { try { const b64 = await qortalRequest({ action: 'FETCH_QDN_RESOURCE', - service: 'DOCUMENT', + service, name, identifier, encoding: 'base64', } 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) { const id = stripPrefixId(identifier, prefix); @@ -282,7 +287,7 @@ async function fetchHtmlComment( } as ThreadCommentWithFlags; } - const obj = await base64ToObject(b64); + const obj = await base64ToObject(cleaned); if (!obj || typeof obj !== 'object') return null; const html = String((obj as any).html ?? '').trim(); @@ -323,6 +328,8 @@ export default function CommentsSection({ primaryGroupId, // isIssuer, issuerName, + isPrivate, + privateGroupId, pageSize: pageSizeProp, collapsible = true, defaultCollapsed = false, @@ -389,6 +396,12 @@ export default function CommentsSection({ const end = Math.min(start + pageSize, totalRoots); const pageRoots = forestRootsDesc.slice(start, end); + const effectiveGroupId = + Number.isFinite(privateGroupId) && privateGroupId + ? privateGroupId + : Number.isFinite(primaryGroupId) && primaryGroupId + ? primaryGroupId + : undefined; const inputs = { primaryGroupId, MINTER_GROUP_ID, @@ -509,7 +522,8 @@ export default function CommentsSection({ setError(null); try { // 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; // Sort stable (you had this already) @@ -533,7 +547,7 @@ export default function CommentsSection({ hits.map((h) => limit(async () => { 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; // de-dupe by id if (docs.find((d) => d.id === doc.id)) return; @@ -565,7 +579,7 @@ export default function CommentsSection({ const candidateNames = namesByIdentifier.get(fullIdentifier) ?? []; let found: ThreadComment | null = null; for (const nm of candidateNames) { - const doc = await fetchHtmlComment(nm, fullIdentifier, prefix); + const doc = await fetchHtmlComment(nm, fullIdentifier, prefix, svc); if (doc) { found = doc; break; @@ -574,7 +588,7 @@ export default function CommentsSection({ if (!found && docs.length) { const uniqueAuthors = Array.from(new Set(docs.map((x) => x.author).filter(Boolean))); for (const nm of uniqueAuthors) { - const doc = await fetchHtmlComment(nm, fullIdentifier, prefix); + const doc = await fetchHtmlComment(nm, fullIdentifier, prefix, svc); if (doc) { found = doc; break; @@ -696,13 +710,25 @@ export default function CommentsSection({ const identifier = assetCommentId(assetId, id); 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({ action: 'PUBLISH_QDN_RESOURCE', name: userName, - service: 'DOCUMENT', + service, identifier, - data64, + data64: finalData, } as any); // 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 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({ action: 'PUBLISH_QDN_RESOURCE', name: userName, - service: 'DOCUMENT', + service, identifier, - data64, + data64: finalData, } as any); // Optimistic local update @@ -810,12 +848,25 @@ export default function CommentsSection({ // Publish tiny sentinel payload (1 raw byte) – base64("x") = "eA==" 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({ action: 'PUBLISH_QDN_RESOURCE', name: userName, - service: 'DOCUMENT', + service, identifier, - data64, + data64: finalData, } as any); // Optimistic local mark diff --git a/src/components/common/PublishQueueStatus.tsx b/src/components/common/PublishQueueStatus.tsx new file mode 100644 index 0000000..158eb38 --- /dev/null +++ b/src/components/common/PublishQueueStatus.tsx @@ -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 ( + + ); +} diff --git a/src/components/common/QdnPublishStatus.tsx b/src/components/common/QdnPublishStatus.tsx index e7b20b9..ba4436b 100644 --- a/src/components/common/QdnPublishStatus.tsx +++ b/src/components/common/QdnPublishStatus.tsx @@ -1,6 +1,6 @@ import { Box, Button, LinearProgress, Typography } from '@mui/material'; import type { PublishJobProgress, PublishJobStatus } from '../../utils/qdnProgressivePublisher'; -import type { PublishThrottleState } from '../../hooks/useQdnProgressivePublisher'; +import type { PublishThrottleState } from '../../state/publishQueue'; type Props = { progress: PublishJobProgress | null; diff --git a/src/components/news/AnnouncementDialog.tsx b/src/components/news/AnnouncementDialog.tsx index b2b107e..4ad5fbf 100644 --- a/src/components/news/AnnouncementDialog.tsx +++ b/src/components/news/AnnouncementDialog.tsx @@ -23,8 +23,9 @@ import { } from '@mui/material'; import { useAuth } from 'qapp-core'; import TiptapEditor from '../TipTapEditor'; -import QdnPublishStatus from '../common/QdnPublishStatus'; +import PublishQueueStatus from '../common/PublishQueueStatus'; import { prepareHtmlForPublish } from '../../utils/publicationPublisher'; +import { invalidateAnnouncementCache, dispatchNewsRefreshEvent } from '../../utils/news'; import { objectToBase64 } from '../../utils/data'; import { sendNotification } from '../../notifications/notificationService'; import { NOTIF_GROUP_ID } from '../../notifications/notifyIndex'; @@ -32,10 +33,9 @@ import { qaAnnouncementPrefix } from '../../constants/qdnConstants'; import { uniqueId6 } from '../../utils/ids'; import { getAccountGroups, type GroupSummary } from '../../utils/qortalApi'; import type { NotifScope } from '../../types/notifications'; -import { QmailPartialError } from '../../utils/qmailNotifications'; import type { NotificationRecipient } from '../../utils/notificationRecipients'; import { prepareQmailRecipients } from '../../utils/qmailRecipientCache'; -import { useQdnProgressivePublisher } from '../../hooks/useQdnProgressivePublisher'; +import { enqueueQdnPublishJob } from '../../state/publishQueue'; import { PublishJobError } from '../../utils/qdnProgressivePublisher'; type Props = { @@ -46,22 +46,6 @@ type Props = { 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({ open, onClose, @@ -79,52 +63,6 @@ export default function AnnouncementDialog({ const [groupOptions, setGroupOptions] = useState([]); const [groupsLoading, setGroupsLoading] = useState(false); const [notificationGroupId, setNotificationGroupId] = useState(''); - 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(() => { if (!address) { @@ -177,7 +115,7 @@ export default function AnnouncementDialog({ const announcementBase64 = await objectToBase64(annPayload); - await publishAnnouncementResources({ + const queued = enqueueQdnPublishJob({ label: 'Announcement publish', resources: [ { @@ -188,23 +126,10 @@ export default function AnnouncementDialog({ }, ], }); - - const qmailOptions = () => ({ - batchSize: 10, - onThrottle: (ctx: { sent: number; total: number; delayMs: number; nextIndex: number }) => - new Promise((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 (!queued) throw new Error('Unable to queue announcement publish.'); + await queued.completion; + invalidateAnnouncementCache(); + dispatchNewsRefreshEvent(); if ((notifyMail || notifyChat) && address) { const extraGroupId = @@ -258,7 +183,6 @@ export default function AnnouncementDialog({ publisher, qdnResource: { publisher: userName, identifier }, links, - qmailOptions: qmailOptions(), deliveries: { internal: { enabled: true, chatPingGroupId: notifyChat ? NOTIF_GROUP_ID : undefined }, qmail: notifyMail @@ -283,7 +207,6 @@ export default function AnnouncementDialog({ publisher, qdnResource: { publisher: userName, identifier }, links, - qmailOptions: qmailOptions(), deliveries: { internal: { enabled: true }, qmail: notifyMail @@ -319,18 +242,6 @@ export default function AnnouncementDialog({ const declineReported = lower.includes('user declined request'); if (e instanceof PublishJobError) { 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 { setErr( declineReported @@ -339,7 +250,7 @@ export default function AnnouncementDialog({ ); } } finally { - if (!qmailThrottle && !qdnThrottle) setBusy(false); + setBusy(false); } } @@ -442,35 +353,7 @@ export default function AnnouncementDialog({ - - {qmailThrottle && ( - `1px solid ${t.palette.warning.main}`, - borderRadius: 1, - bgcolor: (t) => t.palette.warning.light, - color: (t) => t.palette.getContrastText(t.palette.warning.light), - }} - > - - Q-Mail throttled - - - Sent {qmailThrottle.sent}/{qmailThrottle.total}. Auto-retrying in{' '} - {qmailThrottle.secondsLeft}s. - - - - - - )} + {err && {err}} diff --git a/src/components/news/NewsPublisher.tsx b/src/components/news/NewsPublisher.tsx index 60a4efc..cacfd10 100644 --- a/src/components/news/NewsPublisher.tsx +++ b/src/components/news/NewsPublisher.tsx @@ -14,7 +14,7 @@ import { } from '@mui/material'; import { useAuth } from 'qapp-core'; import TiptapEditor from '../TipTapEditor'; -import QdnPublishStatus from '../common/QdnPublishStatus'; +import PublishQueueStatus from '../common/PublishQueueStatus'; import { prepareHtmlForPublish } from '../../utils/publicationPublisher'; import { assetNewsItemId } from '../../constants/qdnConstants'; import { isNameAdminOfGroupId } from '../../utils/access'; @@ -22,8 +22,10 @@ import { uniqueId6 } from '../../utils/ids'; import { useAlert } from '../alerts'; import { publishScopedNotification } from '../../utils/notificationPublisher'; import { objectToBase64 } from '../../utils/data'; -import { useQdnProgressivePublisher } from '../../hooks/useQdnProgressivePublisher'; +import { enqueueQdnPublishJob } from '../../state/publishQueue'; import { PublishJobError } from '../../utils/qdnProgressivePublisher'; +import { resolveAssetPublicationById } from '../../utils/resolveAssetPublication'; +import { addPrivateMagic } from '../../constants/qdeckIdentifiers'; export default function NewsPublisher({ assetId, @@ -50,31 +52,38 @@ export default function NewsPublisher({ ? Number(primaryGroupId) : null; - const canPublish = async () => { + const canPublish = async (groupId?: number | null) => { if (!userName) authenticateUser(); if (isIssuer) return true; - if (!primaryGroupId) return false; - return isNameAdminOfGroupId(userName as string, primaryGroupId); + if (!groupId) return false; + return isNameAdminOfGroupId(userName as string, groupId); }; const { alert } = useAlert(); - const { - publish: publishNewsResources, - progress: qdnProgress, - throttle: qdnThrottle, - } = useQdnProgressivePublisher(); - - const showQdnStatus = - (qdnProgress && qdnProgress.status !== 'completed' && qdnProgress.status !== 'cancelled') || - !!qdnThrottle; - const handlePublish = async () => { if (!userName) { await alert('You need a Qortal name to publish.'); return; } - if (!(await canPublish())) { - await alert('Only issuer or primary group admins can publish News.'); + const privacy = assetId + ? 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; } @@ -87,21 +96,42 @@ export default function NewsPublisher({ title: newsTitle, 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); try { - await publishNewsResources({ + const queued = enqueueQdnPublishJob({ label: 'Asset news publish', resources: [ { name: userName as string, - service: 'DOCUMENT', + service, 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 links = [ @@ -116,7 +146,8 @@ export default function NewsPublisher({ ]; if (address) { - if (notifyAppSubs) { + // For private assets, avoid global notifications; only group scope. + if (!isPrivate && notifyAppSubs) { await publishScopedNotification({ scope: { kind: 'global' }, title: newsTitle, @@ -127,9 +158,9 @@ export default function NewsPublisher({ links, }); } - if (notifyGroupSubs && normalizedGroupId) { + if (notifyGroupSubs && effectiveGroupId) { await publishScopedNotification({ - scope: { kind: 'group', groupId: normalizedGroupId }, + scope: { kind: 'group', groupId: effectiveGroupId }, title: assetName ? `${assetName} group notice` : `Asset #${assetId} group notice`, html: payload, publisher: { name: userName, address, role: 'admin' }, @@ -207,15 +238,7 @@ export default function NewsPublisher({ } /> - {showQdnStatus && ( - - - - )} + - + @@ -3760,6 +3793,16 @@ export default function DataExplorer() { {loadingAllPages ? 'Loading…' : 'Load remaining'} )} + {activeName && manifestLoadState === 'success' && ( + + )} {manifestBoundaryReached && hasMore && !ignoreManifestCache && ( @@ -3769,7 +3812,9 @@ export default function DataExplorer() { action={