feature/private-assets #2
@@ -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<ThreadComment | null> {
|
||||
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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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<GroupSummary[]>([]);
|
||||
const [groupsLoading, setGroupsLoading] = useState(false);
|
||||
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(() => {
|
||||
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<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 (!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({
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<QdnPublishStatus
|
||||
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>
|
||||
)}
|
||||
<PublishQueueStatus fallbackLabel="Publishing announcement" />
|
||||
{err && <Box sx={{ color: 'error.main', fontSize: 13 }}>{err}</Box>}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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({
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{showQdnStatus && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<QdnPublishStatus
|
||||
progress={qdnProgress}
|
||||
throttle={qdnThrottle}
|
||||
contextLabel="Publishing news article"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<PublishQueueStatus fallbackLabel="Publishing news article" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} disabled={publishing}>
|
||||
|
||||
@@ -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 Grid from '@mui/material/Grid';
|
||||
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 NewsActionBar from '../../components/news/NewsActionBar';
|
||||
import { useMemberGroupIds } from '../../hooks/useMemberGroupIds';
|
||||
|
||||
import type { NewsSummary, NewsType } from '../../types/newsAndPromos';
|
||||
|
||||
@@ -202,45 +209,54 @@ export default function QAssetsNewsSection() {
|
||||
// const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { memberGroupIds, loading: groupsLoading } = useMemberGroupIds();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// Initial load of lists
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
const loadNews = useCallback(
|
||||
async (forceFresh = false) => {
|
||||
if (forceFresh) {
|
||||
invalidateAnnouncementCache();
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const announcementLimit = showMoreAnnouncements ? 50 : 5;
|
||||
const assetNewsLimit = showMoreNews ? 50 : 8;
|
||||
const [a, n, p] = await Promise.all([
|
||||
fetchAnnouncements(announcementLimit, { includeExpired: showArchivedAnnouncements }),
|
||||
fetchLatestAssetNews(assetNewsLimit, { includeExpired: showArchivedNews }),
|
||||
fetchAnnouncements(announcementLimit, {
|
||||
includeExpired: showArchivedAnnouncements,
|
||||
forceFresh,
|
||||
}),
|
||||
fetchLatestAssetNews(assetNewsLimit, {
|
||||
includeExpired: showArchivedNews,
|
||||
allowedGroupIds: memberGroupIds,
|
||||
}),
|
||||
fetchActivePromotions(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setAnnouncements(a);
|
||||
setAssetNews(n);
|
||||
setPromotions(p);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Q-Assets news', e);
|
||||
if (!cancelled) {
|
||||
setAnnouncements([]);
|
||||
setAssetNews([]);
|
||||
setPromotions([]);
|
||||
}
|
||||
setAnnouncements([]);
|
||||
setAssetNews([]);
|
||||
setPromotions([]);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showArchivedAnnouncements, showArchivedNews, showMoreAnnouncements, showMoreNews]);
|
||||
},
|
||||
[
|
||||
showArchivedAnnouncements,
|
||||
showArchivedNews,
|
||||
showMoreAnnouncements,
|
||||
showMoreNews,
|
||||
memberGroupIds,
|
||||
]
|
||||
);
|
||||
|
||||
const loadingLists =
|
||||
loading || announcements === null || assetNews === null || promotions === null;
|
||||
loading || groupsLoading || announcements === null || assetNews === null || promotions === null;
|
||||
|
||||
const handleClickItem = (item: NewsSummary) => {
|
||||
setSelected(item);
|
||||
@@ -252,11 +268,32 @@ export default function QAssetsNewsSection() {
|
||||
// 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 assetNewsList = assetNews || [];
|
||||
|
||||
const announcementActive = announcementList.filter((item) => !item.isExpired);
|
||||
const announcementArchived = announcementList.filter((item) => item.isExpired);
|
||||
const assetNewsList = assetNews || [];
|
||||
const newsActive = assetNewsList.filter((item) => !item.isExpired);
|
||||
const newsArchived = assetNewsList.filter((item) => item.isExpired);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -24,7 +24,13 @@ const MAX_AUTO_PAGES = 40;
|
||||
/**
|
||||
* 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 [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
@@ -110,8 +116,8 @@ export function useQdnResources(name: string | null) {
|
||||
|
||||
useEffect(() => {
|
||||
resetState();
|
||||
if (name) void fetchPage(0);
|
||||
}, [name, fetchPage, resetState]);
|
||||
if (name && autoFetch) void fetchPage(0);
|
||||
}, [name, fetchPage, resetState, autoFetch]);
|
||||
|
||||
return { rows, loading, hasMore, loadMore, loadAll, error, reset: resetState, reload };
|
||||
}
|
||||
|
||||
@@ -154,8 +154,6 @@ export async function sendNotification(
|
||||
message,
|
||||
batchSize: request.qmailOptions?.batchSize,
|
||||
resumeFrom: request.qmailOptions?.resumeFrom,
|
||||
onThrottle: request.qmailOptions?.onThrottle,
|
||||
onProgress: request.qmailOptions?.onProgress,
|
||||
});
|
||||
results.qmail = { recipients: recipients.length };
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ import PublishedHtmlRenderer from '../components/PublishedHtmlRenderer';
|
||||
import { useAlert } from '../components/alerts';
|
||||
import { updateAsset, getAccountGroups, type GroupSummary, getGroupById } from '../utils/qortalApi';
|
||||
// import { getAssetInfo } from '../utils/qortalAssetRequests';
|
||||
import { useMemberGroupIds } from '../hooks/useMemberGroupIds';
|
||||
import { canViewAsset, getAssetPrivacy, type AssetPrivacy } from '../utils/assetPrivacy';
|
||||
|
||||
type Enriched = {
|
||||
assetId: number;
|
||||
@@ -174,9 +176,27 @@ export default function AssetDetail() {
|
||||
// const navigate = useNavigate();
|
||||
const isIssuer = !!asset && !!userAddress && asset.owner === userAddress;
|
||||
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 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 () => {
|
||||
if (!canPublish || !asset) return;
|
||||
setPublishing(true);
|
||||
@@ -205,7 +225,15 @@ export default function AssetDetail() {
|
||||
: undefined,
|
||||
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!');
|
||||
setAssetPub(pub);
|
||||
setHasPendingChanges(false);
|
||||
@@ -260,6 +288,8 @@ export default function AssetDetail() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!Number.isFinite(id)) return;
|
||||
if (!privacyChecked) return;
|
||||
if (assetPrivacy && !canViewAsset(assetPrivacy, memberGroupIds)) return;
|
||||
|
||||
try {
|
||||
// 1) Try sync cache
|
||||
@@ -350,7 +380,9 @@ export default function AssetDetail() {
|
||||
// Publication
|
||||
try {
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
@@ -364,7 +396,7 @@ export default function AssetDetail() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
}, [id, privacyChecked, assetPrivacy, memberGroupIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrimaryForm(
|
||||
@@ -430,6 +462,30 @@ export default function AssetDetail() {
|
||||
|
||||
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 asset’s details.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{/* Header row: title + (left info, right actions) */}
|
||||
@@ -804,6 +860,8 @@ export default function AssetDetail() {
|
||||
assetId={asset.assetId}
|
||||
primaryGroupId={parseInt(String(assetPub?.primaryGroup?.id || ''), 10)}
|
||||
issuerName={issuerName}
|
||||
isPrivate={assetPrivacy?.isPrivate}
|
||||
privateGroupId={assetPrivacy?.groupId}
|
||||
isIssuer={!!canPublish}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
+235
-57
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { getAssetBalances } from '../utils/qortalAssetRequests';
|
||||
import {
|
||||
Typography,
|
||||
@@ -23,6 +23,8 @@ import { useExplorerStats } from '../explorerStats/useExplorerStats';
|
||||
import { TRADE_FETCH_N } from '../explorerStats/types';
|
||||
import { usePrimaryGroupId } from '../utils/usePrimaryGroupId';
|
||||
import { loadStats } from '../explorerStats/storage';
|
||||
import { useMemberGroupIds } from '../hooks/useMemberGroupIds';
|
||||
import { canViewAsset, getAssetPrivacy, type AssetPrivacy } from '../utils/assetPrivacy';
|
||||
|
||||
export interface Asset {
|
||||
assetId: number;
|
||||
@@ -195,8 +197,10 @@ const AssetExplorer = () => {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('volume');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [privacyMap, setPrivacyMap] = useState<Record<number, AssetPrivacy>>({});
|
||||
|
||||
const theme = useTheme();
|
||||
const { memberGroupIds, loading: groupsLoading } = useMemberGroupIds();
|
||||
const { address: userAddress } = useAuth();
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
@@ -205,65 +209,114 @@ const AssetExplorer = () => {
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const sortedAssets = useMemo(() => {
|
||||
const copy = [...assets];
|
||||
|
||||
const getVolume = (assetId: number) => {
|
||||
// Read from local cache written by useExplorerStats
|
||||
const s = loadStats(assetId);
|
||||
// push unknown volumes to the end: use -1 sentinel
|
||||
return s?.qortVolLastN ?? -1;
|
||||
};
|
||||
|
||||
const getCirculating = (a: EnrichedAsset) =>
|
||||
typeof a.circulating === 'number' ? a.circulating : Number.NEGATIVE_INFINITY;
|
||||
|
||||
copy.sort((a, b) => {
|
||||
let av: number | string;
|
||||
let bv: number | string;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'name':
|
||||
av = a.name.toLowerCase();
|
||||
bv = b.name.toLowerCase();
|
||||
break;
|
||||
case 'assetId':
|
||||
av = a.assetId;
|
||||
bv = b.assetId;
|
||||
break;
|
||||
case 'circulating':
|
||||
av = getCirculating(a);
|
||||
bv = getCirculating(b);
|
||||
break;
|
||||
case 'volume':
|
||||
default:
|
||||
av = getVolume(a.assetId);
|
||||
bv = getVolume(b.assetId);
|
||||
break;
|
||||
}
|
||||
|
||||
let cmp: number;
|
||||
if (typeof av === 'string' && typeof bv === 'string') {
|
||||
cmp = av.localeCompare(bv);
|
||||
} else {
|
||||
cmp = (Number(av) || 0) - (Number(bv) || 0);
|
||||
}
|
||||
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
const viewableAssets = useMemo(() => {
|
||||
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]);
|
||||
|
||||
return copy;
|
||||
// include tick so list can update as stats land
|
||||
}, [assets, sortKey, sortDir, tick]);
|
||||
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 s = loadStats(assetId);
|
||||
return s?.qortVolLastN ?? -1;
|
||||
};
|
||||
const getCirculating = (a: EnrichedAsset) =>
|
||||
typeof a.circulating === 'number' ? a.circulating : Number.NEGATIVE_INFINITY;
|
||||
|
||||
copy.sort((a, b) => {
|
||||
let av: number | string;
|
||||
let bv: number | string;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'name':
|
||||
av = a.name.toLowerCase();
|
||||
bv = b.name.toLowerCase();
|
||||
break;
|
||||
case 'assetId':
|
||||
av = a.assetId;
|
||||
bv = b.assetId;
|
||||
break;
|
||||
case 'circulating':
|
||||
av = getCirculating(a);
|
||||
bv = getCirculating(b);
|
||||
break;
|
||||
case 'volume':
|
||||
default:
|
||||
av = getVolume(a.assetId);
|
||||
bv = getVolume(b.assetId);
|
||||
break;
|
||||
}
|
||||
|
||||
let cmp: number;
|
||||
if (typeof av === 'string' && typeof bv === 'string') {
|
||||
cmp = av.localeCompare(bv);
|
||||
} else {
|
||||
cmp = (Number(av) || 0) - (Number(bv) || 0);
|
||||
}
|
||||
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return copy;
|
||||
},
|
||||
[sortDir, sortKey, tick]
|
||||
);
|
||||
|
||||
const sortedPublicAssets = useMemo(() => sortAssets(publicAssets), [publicAssets, sortAssets]);
|
||||
const sortedPrivateAssets = useMemo(() => sortAssets(privateAssets), [privateAssets, sortAssets]);
|
||||
|
||||
const displayAssets = useMemo(
|
||||
() => sortedAssets.slice(0, Math.min(visibleCount, sortedAssets.length)),
|
||||
[sortedAssets, visibleCount]
|
||||
() => sortedPublicAssets.slice(0, Math.min(visibleCount, sortedPublicAssets.length)),
|
||||
[sortedPublicAssets, visibleCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -271,13 +324,15 @@ const AssetExplorer = () => {
|
||||
window.innerHeight + window.scrollY >= document.body.offsetHeight - SCROLL_THRESHOLD_PX;
|
||||
if (nearBottom) {
|
||||
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 });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [sortedAssets.length]);
|
||||
}, [sortedPublicAssets.length]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAssets() {
|
||||
@@ -410,9 +465,10 @@ const AssetExplorer = () => {
|
||||
}, [displayAssets]);
|
||||
|
||||
// inside your component render
|
||||
const overallLoading = loading || groupsLoading;
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1.25, sm: 2 } }}>
|
||||
{loading ? (
|
||||
{overallLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={8}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
@@ -667,11 +723,133 @@ const AssetExplorer = () => {
|
||||
);
|
||||
})}
|
||||
</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}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, sortedAssets.length))
|
||||
setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, sortedPublicAssets.length))
|
||||
}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Select,
|
||||
MenuItem,
|
||||
CircularProgress,
|
||||
FormHelperText,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material';
|
||||
@@ -51,6 +52,7 @@ export default function IssueAsset() {
|
||||
const [groupIsPrivate, setGroupIsPrivate] = useState(false);
|
||||
const [avatarBase64, setAvatarBase64] = useState<string>('');
|
||||
// const [newAssetID, setNewAssetID] = useState<number>(0);
|
||||
const [privateAsset, setPrivateAsset] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -106,6 +108,7 @@ export default function IssueAsset() {
|
||||
setGroupId('');
|
||||
setGroupLink('');
|
||||
setGroupIsPrivate(false);
|
||||
setPrivateAsset(false);
|
||||
setAttemptedSubmit(false);
|
||||
setAvatarBase64('');
|
||||
setHtml('');
|
||||
@@ -139,6 +142,19 @@ export default function IssueAsset() {
|
||||
setSuccess(null);
|
||||
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
|
||||
const currentAssetName = assetName;
|
||||
const currentDescription = description;
|
||||
@@ -147,6 +163,10 @@ export default function IssueAsset() {
|
||||
const currentUnspendable = isUnspendable;
|
||||
const currentAssetData = assetData;
|
||||
const currentAvatarBase64 = avatarBase64;
|
||||
const currentPrivateAsset = privateAsset;
|
||||
const parsedGroupId = groupId ? Number(groupId) : NaN;
|
||||
const normalizedPrivateGroupId =
|
||||
currentPrivateAsset && Number.isFinite(parsedGroupId) ? parsedGroupId : undefined;
|
||||
|
||||
try {
|
||||
const predictedAssetID = await predictAssetID();
|
||||
@@ -156,6 +176,8 @@ export default function IssueAsset() {
|
||||
const publication: AssetPublication = {
|
||||
description: currentDescription,
|
||||
html,
|
||||
privateAsset: currentPrivateAsset,
|
||||
privateGroupId: normalizedPrivateGroupId,
|
||||
primaryGroup: {
|
||||
name: groupName,
|
||||
id: groupId,
|
||||
@@ -346,6 +368,29 @@ export default function IssueAsset() {
|
||||
|
||||
<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 */}
|
||||
<Typography variant="h5" fontWeight={600} color="primary.contrastText">
|
||||
Asset-Related Group Data
|
||||
@@ -381,6 +426,7 @@ export default function IssueAsset() {
|
||||
if (!match) return value;
|
||||
return `${match.groupName} (#${match.groupId}) — ${match.isOpen ? 'Public' : 'Private'}`;
|
||||
}}
|
||||
error={privateAsset && attemptedSubmit && (!groupId || !groupIsPrivate)}
|
||||
>
|
||||
{groupOptions.map((group) => (
|
||||
<MenuItem key={group.groupId} value={group.groupId}>
|
||||
@@ -403,6 +449,15 @@ export default function IssueAsset() {
|
||||
<CircularProgress size={20} />
|
||||
</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>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
@@ -11,6 +11,8 @@ import { makeAssetFallbackAvatar } from '../utils/assetAvatarFallback';
|
||||
import { fetchQortVolumeLastN } from '../explorerStats/fetchers';
|
||||
import { TRADE_FETCH_N } from '../explorerStats/types';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useMemberGroupIds } from '../hooks/useMemberGroupIds';
|
||||
import { canViewAsset, getAssetPrivacy, type AssetPrivacy } from '../utils/assetPrivacy';
|
||||
|
||||
type Row = {
|
||||
assetId: number;
|
||||
@@ -30,6 +32,8 @@ export default function TradeMarkets() {
|
||||
type VolInfo = { sum: number; count: number; ts: number };
|
||||
const VOL_TTL_MS = 10 * 60 * 1000;
|
||||
const theme = useTheme();
|
||||
const { memberGroupIds, loading: groupsLoading } = useMemberGroupIds();
|
||||
const [privacyMap, setPrivacyMap] = useState<Record<number, AssetPrivacy>>({});
|
||||
|
||||
const [volumes, setVolumes] = useState<Record<number, VolInfo>>({});
|
||||
const [sortKey, setSortKey] = useState<'volume' | 'name' | 'assetId'>('volume');
|
||||
@@ -45,7 +49,9 @@ export default function TradeMarkets() {
|
||||
const writeVolCache = (m: Record<number, VolInfo>) => {
|
||||
try {
|
||||
localStorage.setItem('marketVolumes', JSON.stringify(m));
|
||||
} catch {}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
if (rows.length === 0) return;
|
||||
@@ -155,16 +187,25 @@ export default function TradeMarkets() {
|
||||
};
|
||||
}, [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 s = q.trim().toLowerCase();
|
||||
if (!s) return rows;
|
||||
return rows.filter(
|
||||
if (!s) return viewableRows;
|
||||
return viewableRows.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(s) ||
|
||||
r.description?.toLowerCase().includes(s) ||
|
||||
String(r.assetId).includes(s)
|
||||
);
|
||||
}, [rows, q]);
|
||||
}, [viewableRows, q]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...filtered];
|
||||
@@ -236,7 +277,7 @@ export default function TradeMarkets() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
{(loading || groupsLoading) && viewableRows.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" py={6}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
|
||||
+73
-4
@@ -52,6 +52,8 @@ import SmartPriceChart from '../components/trade/SmartPriceChart';
|
||||
import ActionsToolbar from '../components/asset/ActionsToolbar';
|
||||
import { useAlert } from '../components/alerts';
|
||||
import { useTheme, useMediaQuery } from '@mui/material';
|
||||
import { useMemberGroupIds } from '../hooks/useMemberGroupIds';
|
||||
import { getAssetPrivacy, canViewAsset, type AssetPrivacy } from '../utils/assetPrivacy';
|
||||
|
||||
export default function TradePair() {
|
||||
const { assetId } = useParams<{ assetId: string }>();
|
||||
@@ -73,6 +75,8 @@ export default function TradePair() {
|
||||
const [issuerAddr, setIssuerAddr] = useState<string | null>(null);
|
||||
const [balAsset, setBalAsset] = 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
|
||||
const [rangeHours, setRangeHours] = useState<number>(720); // set default range hours
|
||||
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 { 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 lookbackMs = rangeHours * 60 * 60 * 1000;
|
||||
@@ -139,6 +149,11 @@ export default function TradePair() {
|
||||
}
|
||||
|
||||
async function refreshBalances() {
|
||||
if (!privacyAllowed) {
|
||||
setBalAsset(null);
|
||||
setBalQort(null);
|
||||
return;
|
||||
}
|
||||
if (!authAddress) {
|
||||
setBalAsset(null);
|
||||
setBalQort(null);
|
||||
@@ -179,6 +194,26 @@ export default function TradePair() {
|
||||
const DP = 8;
|
||||
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 {
|
||||
const m = s.trim().match(/^(\d+)(?:\.(\d{0,18}))?$/); // allow up to 18 just in case
|
||||
if (!m) throw new Error('bad decimal');
|
||||
@@ -292,6 +327,10 @@ export default function TradePair() {
|
||||
}
|
||||
|
||||
const refreshMarket = useCallback(async () => {
|
||||
if (!privacyAllowed) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -393,9 +432,13 @@ export default function TradePair() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, authAddress, divisible]);
|
||||
}, [id, authAddress, divisible, privacyAllowed]);
|
||||
|
||||
const refreshMyFills = useCallback(async () => {
|
||||
if (!privacyAllowed) {
|
||||
setMyFills([]);
|
||||
return;
|
||||
}
|
||||
if (!authAddress) {
|
||||
setMyFills([]);
|
||||
return;
|
||||
@@ -419,7 +462,7 @@ export default function TradePair() {
|
||||
console.debug('[fills] error', e);
|
||||
setMyFills([]);
|
||||
}
|
||||
}, [authAddress, authPublicKey, id]);
|
||||
}, [authAddress, authPublicKey, id, privacyAllowed]);
|
||||
|
||||
useMarketConfirmRefresh({
|
||||
assetId: id,
|
||||
@@ -438,7 +481,11 @@ export default function TradePair() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
if (!cancelled) {
|
||||
if (!cancelled && privacyChecked) {
|
||||
if (!privacyAllowed) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await refreshMarket();
|
||||
await refreshBalances();
|
||||
await refreshMyFills();
|
||||
@@ -450,7 +497,7 @@ export default function TradePair() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [privacyAllowed, privacyChecked, refreshMarket, refreshBalances, refreshMyFills]);
|
||||
|
||||
// ----- Place order state
|
||||
const [side, setSide] = useState<'buy' | 'sell'>('buy');
|
||||
@@ -621,6 +668,28 @@ export default function TradePair() {
|
||||
return balAsset != null && needAsset > (balAsset ?? 0);
|
||||
}, [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 (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
} from '../../../constants/qdeckIdentifiers';
|
||||
import { collectRecipientPublicKeys } from '../../../utils/qdeckAccess';
|
||||
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 {
|
||||
MANIFEST_IDENTIFIER,
|
||||
@@ -114,7 +114,7 @@ type PreviewDialogState = {
|
||||
title?: string;
|
||||
content?: string;
|
||||
dataUrl?: string;
|
||||
type?: 'text' | 'binary' | 'image';
|
||||
type?: 'text' | 'binary' | 'image' | 'video';
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
steps: PreviewStep[];
|
||||
@@ -140,6 +140,7 @@ const createPreviewDialogState = (): PreviewDialogState => ({
|
||||
});
|
||||
|
||||
const PUBLISH_MODE_STORAGE_KEY = 'qassets_publish_mode_preference_v1';
|
||||
type ManifestLoadState = 'idle' | 'loading' | 'success' | 'missing';
|
||||
|
||||
const SERVICE_OPTIONS = ALL_QDN_SERVICES;
|
||||
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 applyPrivateMagicIfNeeded = (base64: string, service?: string) => {
|
||||
if (isPrivateService(service)) console.log('private service:', service);
|
||||
type EncryptionMode = 'group' | 'direct' | null | undefined;
|
||||
|
||||
const applyPrivateMagicIfNeeded = (base64: string, service?: string, mode?: EncryptionMode) => {
|
||||
if (!isPrivateService(service)) return base64;
|
||||
if (mode !== 'group') return base64;
|
||||
return hasPrivateMagicPrefix(base64) ? base64 : addPrivateMagic(base64);
|
||||
};
|
||||
|
||||
const stripPrivateMagicIfNeeded = (base64: string, _service?: string) => {
|
||||
console.log(_service);
|
||||
return stripPrivateMagic(base64);
|
||||
const stripPrivateMagicIfNeeded = (base64: string, service?: string, mode?: EncryptionMode) => {
|
||||
if (!isPrivateService(service)) return base64;
|
||||
if (mode !== 'group') return base64;
|
||||
return hasPrivateMagicPrefix(base64) ? stripPrivateMagic(base64) : base64;
|
||||
};
|
||||
|
||||
const resolveMimeForResource = (
|
||||
@@ -511,7 +516,7 @@ async function decryptPrivateBase64(
|
||||
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)
|
||||
try {
|
||||
@@ -889,6 +894,7 @@ export default function DataExplorer() {
|
||||
const [previewDialog, setPreviewDialog] = useState<PreviewDialogState>(
|
||||
createPreviewDialogState()
|
||||
);
|
||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [manifestDialog, setManifestDialog] = useState<{
|
||||
open: boolean;
|
||||
entry: StructuredEntry | null;
|
||||
@@ -992,6 +998,7 @@ export default function DataExplorer() {
|
||||
const [manifestPublishing, setManifestPublishing] = useState(false);
|
||||
const [manifestError, setManifestError] = useState<string | null>(null);
|
||||
const [manifestRefreshBlockedUntil, setManifestRefreshBlockedUntil] = useState(0);
|
||||
const [manifestLoadState, setManifestLoadState] = useState<ManifestLoadState>('idle');
|
||||
const [ignoreManifestCache, setIgnoreManifestCache] = useState(false);
|
||||
const [loadingAllPages, setLoadingAllPages] = useState(false);
|
||||
const [detectedTypes, setDetectedTypes] = useState<Record<string, string>>({});
|
||||
@@ -1244,11 +1251,15 @@ export default function DataExplorer() {
|
||||
}
|
||||
}, [pendingFolders, activeName]);
|
||||
|
||||
const applyManifestState = useCallback((doc: ManifestDoc | null, dirty: boolean) => {
|
||||
setManifestDoc(doc);
|
||||
setDetectedTypes(doc?.resourceTypes || {});
|
||||
setManifestDirty(dirty);
|
||||
}, []);
|
||||
const applyManifestState = useCallback(
|
||||
(doc: ManifestDoc | null, dirty: boolean, loadState?: ManifestLoadState) => {
|
||||
setManifestDoc(doc);
|
||||
setDetectedTypes(doc?.resourceTypes || {});
|
||||
setManifestDirty(dirty);
|
||||
if (loadState) setManifestLoadState(loadState);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchManifestDoc = useCallback(async (): Promise<ManifestDoc | null> => {
|
||||
if (!activeName) return null;
|
||||
@@ -1263,7 +1274,7 @@ export default function DataExplorer() {
|
||||
const data64 = normalizeData64(res);
|
||||
if (!data64) return null;
|
||||
if (!decrypt) return JSON.parse(base64ToUtf8(data64));
|
||||
const payload = stripPrivateMagicIfNeeded(data64, service);
|
||||
const payload = stripPrivateMagicIfNeeded(data64, service, 'direct');
|
||||
const clear = await qortalRequest({
|
||||
action: 'DECRYPT_DATA',
|
||||
encryptedData: payload,
|
||||
@@ -1284,10 +1295,11 @@ export default function DataExplorer() {
|
||||
|
||||
const refreshManifestDoc = useCallback(async () => {
|
||||
if (!activeName) {
|
||||
applyManifestState(null, false);
|
||||
applyManifestState(null, false, 'idle');
|
||||
return;
|
||||
}
|
||||
if (Date.now() < manifestRefreshBlockedUntil) return;
|
||||
setManifestLoadState('loading');
|
||||
try {
|
||||
const doc = await fetchManifestDoc();
|
||||
if (
|
||||
@@ -1296,26 +1308,28 @@ export default function DataExplorer() {
|
||||
doc.generatedAt &&
|
||||
doc.generatedAt <= manifestDoc.generatedAt
|
||||
) {
|
||||
setManifestLoadState('success');
|
||||
return;
|
||||
}
|
||||
applyManifestState(doc, false);
|
||||
applyManifestState(doc, false, doc ? 'success' : 'missing');
|
||||
} catch {
|
||||
applyManifestState(null, true);
|
||||
applyManifestState(null, true, 'missing');
|
||||
}
|
||||
}, [activeName, fetchManifestDoc, applyManifestState, manifestDoc, manifestRefreshBlockedUntil]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeName) {
|
||||
applyManifestState(null, false);
|
||||
applyManifestState(null, false, 'idle');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setManifestLoadState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const doc = await fetchManifestDoc();
|
||||
if (!cancelled) applyManifestState(doc, false);
|
||||
if (!cancelled) applyManifestState(doc, false, doc ? 'success' : 'missing');
|
||||
} catch {
|
||||
if (!cancelled) applyManifestState(null, true);
|
||||
if (!cancelled) applyManifestState(null, true, 'missing');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -2235,22 +2249,25 @@ export default function DataExplorer() {
|
||||
setSharePage(1);
|
||||
};
|
||||
|
||||
const handleReload = async () => {
|
||||
const handleLoadFromNetwork = async () => {
|
||||
await refreshResources();
|
||||
};
|
||||
|
||||
const handleLoadRemaining = useCallback(async () => {
|
||||
if (!hasMore || resourcesLoading || loadingAllPages) return;
|
||||
if (resourcesLoading || loadingAllPages) return;
|
||||
setIgnoreManifestCache(true);
|
||||
setLoadingAllPages(true);
|
||||
try {
|
||||
if (!rows.length) {
|
||||
await reload();
|
||||
}
|
||||
await loadAll();
|
||||
} catch {
|
||||
// errors surfaced via useQdnResources error state
|
||||
} finally {
|
||||
setLoadingAllPages(false);
|
||||
}
|
||||
}, [hasMore, resourcesLoading, loadingAllPages, loadAll]);
|
||||
}, [resourcesLoading, loadingAllPages, loadAll, reload, rows.length]);
|
||||
|
||||
const handlePublishOpen = (variant: 'single' | 'multiple') => {
|
||||
if (!activeName) {
|
||||
@@ -2345,7 +2362,7 @@ export default function DataExplorer() {
|
||||
isAdmins: groupAdminsOnly,
|
||||
});
|
||||
const finalService = ensurePrivateService(form.service);
|
||||
const privData64 = applyPrivateMagicIfNeeded(enc, finalService);
|
||||
const privData64 = applyPrivateMagicIfNeeded(enc, finalService, 'group');
|
||||
return {
|
||||
data64: privData64,
|
||||
service: finalService,
|
||||
@@ -2375,7 +2392,7 @@ export default function DataExplorer() {
|
||||
});
|
||||
const finalService = ensurePrivateService(form.service);
|
||||
return {
|
||||
data64: applyPrivateMagicIfNeeded(enc, finalService),
|
||||
data64: applyPrivateMagicIfNeeded(enc, finalService, 'direct'),
|
||||
service: finalService,
|
||||
metadataExtra: { encrypted: { mode: 'direct', recipients } },
|
||||
tagExtra: ['private', 'encrypted:direct'],
|
||||
@@ -2579,6 +2596,19 @@ export default function DataExplorer() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loaded.mime.startsWith('video/')) {
|
||||
setPreviewDialog((prev) => ({
|
||||
...prev,
|
||||
open: true,
|
||||
loading: false,
|
||||
title: getResourceLabel(target),
|
||||
type: 'video',
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = base64ToUtf8(loaded.base64);
|
||||
if (isProbablyText(text)) {
|
||||
@@ -2811,7 +2841,7 @@ export default function DataExplorer() {
|
||||
base64: data64,
|
||||
publicKeys,
|
||||
});
|
||||
const privateData64 = applyPrivateMagicIfNeeded(encrypted, MANIFEST_SERVICE);
|
||||
const privateData64 = applyPrivateMagicIfNeeded(encrypted, MANIFEST_SERVICE, 'direct');
|
||||
const metadata = {
|
||||
qassetsManifest: { version: 1, visibility: 'private' },
|
||||
encrypted: { mode: 'direct', recipients: [publisherAddress] },
|
||||
@@ -2829,8 +2859,7 @@ export default function DataExplorer() {
|
||||
},
|
||||
]);
|
||||
setManifestRefreshBlockedUntil(Date.now() + MANIFEST_REFRESH_COOLDOWN);
|
||||
setManifestDoc(manifestPayload);
|
||||
setManifestDirty(false);
|
||||
applyManifestState(manifestPayload, false, 'success');
|
||||
} catch (e: any) {
|
||||
setManifestError(e?.message || 'Manifest publish failed');
|
||||
} finally {
|
||||
@@ -2844,6 +2873,7 @@ export default function DataExplorer() {
|
||||
publishResources,
|
||||
resolvePublisherAddress,
|
||||
flushPendingPublishRequests,
|
||||
applyManifestState,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2926,7 +2956,7 @@ export default function DataExplorer() {
|
||||
isAdmins: false,
|
||||
});
|
||||
const service = ensurePrivateService(resource.service);
|
||||
const privData = applyPrivateMagicIfNeeded(enc, service);
|
||||
const privData = applyPrivateMagicIfNeeded(enc, service, 'group');
|
||||
shareRequests.push({
|
||||
name: publisherName,
|
||||
service,
|
||||
@@ -2967,7 +2997,7 @@ export default function DataExplorer() {
|
||||
publicKeys,
|
||||
});
|
||||
const service = ensurePrivateService(resource.service);
|
||||
const privData = applyPrivateMagicIfNeeded(enc, service);
|
||||
const privData = applyPrivateMagicIfNeeded(enc, service, 'direct');
|
||||
shareRequests.push({
|
||||
name: publisherName,
|
||||
service,
|
||||
@@ -3706,7 +3736,10 @@ export default function DataExplorer() {
|
||||
</Button>
|
||||
<Tooltip title="Refresh current folder">
|
||||
<span>
|
||||
<IconButton onClick={handleReload} disabled={!activeName || resourcesLoading}>
|
||||
<IconButton
|
||||
onClick={handleLoadFromNetwork}
|
||||
disabled={!activeName || resourcesLoading}
|
||||
>
|
||||
<RefreshRoundedIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
@@ -3760,6 +3793,16 @@ export default function DataExplorer() {
|
||||
{loadingAllPages ? 'Loading…' : 'Load remaining'}
|
||||
</Button>
|
||||
)}
|
||||
{activeName && manifestLoadState === 'success' && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleLoadFromNetwork}
|
||||
disabled={resourcesLoading || loadingAllPages}
|
||||
>
|
||||
{loadingAllPages ? 'Loading…' : 'Load from network'}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{manifestBoundaryReached && hasMore && !ignoreManifestCache && (
|
||||
@@ -3769,7 +3812,9 @@ export default function DataExplorer() {
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleLoadRemaining}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleLoadFromNetwork}
|
||||
disabled={resourcesLoading || loadingAllPages}
|
||||
>
|
||||
{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' && (
|
||||
<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 applyPrivateMagicIfNeeded = (base64: string, service?: string) => {
|
||||
if (isPrivateService(service)) return base64;
|
||||
type EncryptionMode = 'group' | 'direct' | null | undefined;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export const stripPrivateMagicIfNeeded = (base64: string, _service?: string) => {
|
||||
console.log('removing private magic', base64, 'from service (if passed)', _service);
|
||||
return stripPrivateMagic(base64);
|
||||
export const stripPrivateMagicIfNeeded = (
|
||||
base64: string,
|
||||
service?: string,
|
||||
mode?: EncryptionMode
|
||||
) => {
|
||||
if (!isPrivateService(service)) return base64;
|
||||
if (mode !== 'group') return base64;
|
||||
return hasPrivateMagicPrefix(base64) ? stripPrivateMagic(base64) : base64;
|
||||
};
|
||||
|
||||
type GroupDecryptAttempt = {
|
||||
@@ -135,7 +147,7 @@ async function decryptPrivateBase64(
|
||||
mode = 'direct';
|
||||
}
|
||||
|
||||
const encryptedPayload = stripPrivateMagicIfNeeded(encryptedWithMagic, resource.service);
|
||||
const encryptedPayload = stripPrivateMagicIfNeeded(encryptedWithMagic, resource.service, mode);
|
||||
|
||||
try {
|
||||
const direct = await qortalRequest({
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -20,6 +20,8 @@ export type DividendPeriod = '1W' | '2W' | '1M' | '3M' | '6M' | '1Y';
|
||||
export interface AssetPublication {
|
||||
description?: string;
|
||||
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
|
||||
primaryGroup?: {
|
||||
name: string;
|
||||
@@ -47,6 +49,7 @@ export function normalizePublication(pub?: AssetPublication): AssetPublication {
|
||||
const p: AssetPublication = { ...(pub ?? {}) };
|
||||
if (p.dividends == null) p.dividends = false;
|
||||
if (p.dividends && !isValidDividendPeriod(p.dividendPeriod)) p.dividendPeriod = '1M';
|
||||
if (p.privateAsset == null) p.privateAsset = false;
|
||||
return p;
|
||||
}
|
||||
export function isValidDividendPeriod(x: any): x is DividendPeriod {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,27 +1,48 @@
|
||||
import { getAssetIdentifiers } from '../constants/qdnConstants';
|
||||
import { base64ToObject } from './data';
|
||||
import type { AssetPublication } from '../types/AssetPublicationMetadata';
|
||||
import { stripPrivateMagic } from '../constants/qdeckIdentifiers';
|
||||
|
||||
export const fetchAssetPublication = async (
|
||||
name: string,
|
||||
assetName: string,
|
||||
assetId?: number
|
||||
assetId?: number,
|
||||
opts?: { preferPrivate?: boolean }
|
||||
): Promise<AssetPublication | null> => {
|
||||
const publishInfo = await getAssetIdentifiers(assetName, assetId);
|
||||
|
||||
// Try correct, ID-based identifier first
|
||||
try {
|
||||
const pub = await qortalRequest({
|
||||
const tryFetch = async (service: any, identifier: string, isPrivate: boolean) => {
|
||||
const res = await qortalRequest({
|
||||
action: 'FETCH_QDN_RESOURCE',
|
||||
name,
|
||||
service: publishInfo.services.genesisPost,
|
||||
identifier: publishInfo.identifiers.genesisPost,
|
||||
service,
|
||||
identifier,
|
||||
encoding: 'base64',
|
||||
});
|
||||
const raw = res?.data64 ?? res;
|
||||
const cleaned = isPrivate && typeof raw === 'string' ? stripPrivateMagic(raw) : raw;
|
||||
return base64ToObject(cleaned);
|
||||
};
|
||||
|
||||
return await base64ToObject(pub);
|
||||
} catch {
|
||||
console.warn(`No publication for correct ID ${assetId}. Trying fallback search...`);
|
||||
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 {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
console.warn(`No publication for correct ID ${assetId}. Trying fallback search...`);
|
||||
|
||||
// Fallback: search for anything resembling the asset name
|
||||
const results = await qortalRequest({
|
||||
|
||||
+83
-31
@@ -7,7 +7,8 @@ import { stripHtml, extractTitleFromHtml, isManagementAdminPublisher } from './n
|
||||
import { loadAnnouncementApprovalDoc } from './announcementApprovals';
|
||||
import { getNewsPromoExpiryDays, publisherHasPermission } from './managementManifest';
|
||||
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> {
|
||||
try {
|
||||
@@ -54,10 +55,15 @@ const decodeAnnouncementResource = async (data64?: string | 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 ITEM_CACHE_MS = 5 * 60_000;
|
||||
export const NEWS_REFRESH_EVENT = 'qassets:news-refresh';
|
||||
|
||||
export async function fetchAnnouncements(
|
||||
limit = 5,
|
||||
@@ -66,8 +72,10 @@ export async function fetchAnnouncements(
|
||||
try {
|
||||
const includeExpired = options?.includeExpired ?? false;
|
||||
const listKey = `ann:list:${includeExpired}:${limit}`;
|
||||
const cachedList = getCached<NewsSummary[]>(listKey);
|
||||
if (cachedList) return cachedList;
|
||||
if (!options?.forceFresh) {
|
||||
const cachedList = getCached<NewsSummary[]>(listKey);
|
||||
if (cachedList) return cachedList;
|
||||
}
|
||||
|
||||
const expiryDays = Number(await getNewsPromoExpiryDays());
|
||||
const expiryCutoff =
|
||||
@@ -134,7 +142,8 @@ export async function fetchAnnouncements(
|
||||
if (approvedEntries.length) {
|
||||
const ordered = approvedEntries
|
||||
.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) {
|
||||
const dedupeKey = keyFor(entry.publisher, entry.identifier);
|
||||
@@ -154,33 +163,30 @@ export async function fetchAnnouncements(
|
||||
}
|
||||
|
||||
// Also surface admin-published announcements even if not explicitly approved
|
||||
if (items.length < limit) {
|
||||
let docHits: Awaited<ReturnType<typeof searchSimpleByIdentifierPrefix>> = [];
|
||||
let jsonHits: Awaited<ReturnType<typeof searchSimpleByIdentifierPrefix>> = [];
|
||||
try {
|
||||
[docHits, jsonHits] = await Promise.all([
|
||||
searchSimpleByIdentifierPrefix('DOCUMENT', qaAnnouncementPrefix, limit),
|
||||
searchSimpleByIdentifierPrefix('JSON', qaAnnouncementPrefix, limit).catch(() => []),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch announcement list', e);
|
||||
}
|
||||
const allHits = [...docHits, ...jsonHits].sort(
|
||||
(a, b) => (b.created || b.updated || 0) - (a.created || a.updated || 0)
|
||||
);
|
||||
let docHits: Awaited<ReturnType<typeof searchSimpleByIdentifierPrefix>> = [];
|
||||
|
||||
for (const hit of allHits) {
|
||||
const dedupeKey = keyFor(hit.name, hit.identifier);
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
const allowed = await canPublishAnnouncement(hit.name);
|
||||
if (!allowed) continue;
|
||||
const finalService = (hit.service as Service) || ('DOCUMENT' as Service);
|
||||
const added = await pushAnnouncement(hit.name, hit.identifier, finalService, hit.created);
|
||||
if (added) {
|
||||
seen.add(dedupeKey);
|
||||
}
|
||||
if (items.length >= limit) break;
|
||||
try {
|
||||
[docHits] = await Promise.all([
|
||||
searchSimpleByIdentifierPrefix('DOCUMENT', qaAnnouncementPrefix, limit * 2),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch announcement list', e);
|
||||
}
|
||||
const allHits = [...docHits].sort(
|
||||
(a, b) => (b.created || b.updated || 0) - (a.created || a.updated || 0)
|
||||
);
|
||||
|
||||
for (const hit of allHits) {
|
||||
const dedupeKey = keyFor(hit.name, hit.identifier);
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
const allowed = await canPublishAnnouncement(hit.name);
|
||||
if (!allowed) continue;
|
||||
const finalService = (hit.service as Service) || ('DOCUMENT' as Service);
|
||||
const added = await pushAnnouncement(hit.name, hit.identifier, finalService, hit.created);
|
||||
if (added) {
|
||||
seen.add(dedupeKey);
|
||||
}
|
||||
if (items.length >= limit * 2) break;
|
||||
}
|
||||
|
||||
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(
|
||||
limit = 10,
|
||||
options?: FetchNewsOptions
|
||||
): Promise<NewsSummary[]> {
|
||||
try {
|
||||
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);
|
||||
if (cachedList) return cachedList;
|
||||
|
||||
@@ -218,6 +241,26 @@ export async function fetchLatestAssetNews(
|
||||
|
||||
const items: NewsSummary[] = [];
|
||||
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) {
|
||||
const dedupeKey = `${hit.name}::${hit.identifier}`;
|
||||
@@ -286,6 +329,15 @@ export async function fetchLatestAssetNews(
|
||||
|
||||
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(
|
||||
html,
|
||||
assetId != null ? `News for ${assetName}` : 'Asset news'
|
||||
|
||||
+8
-2
@@ -15,7 +15,8 @@ import {
|
||||
import { base64ToObject, objectToBase64 } from './data';
|
||||
import { sendChatMessage } from './qchat';
|
||||
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 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)
|
||||
if (scopeKey === 'global' && args.chatGroupForGlobal) {
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
import { objectToBase64 } from './data';
|
||||
import type { AssetPublication } from '../types/AssetPublicationMetadata';
|
||||
import { getAssetIdentifiers } from '../constants/qdnConstants';
|
||||
// import { useAuth } from 'qapp-core';
|
||||
import { addPrivateMagic } from '../constants/qdeckIdentifiers';
|
||||
|
||||
export const publishAssetPublication = async (
|
||||
owner: string,
|
||||
assetName: string,
|
||||
pub: AssetPublication
|
||||
pub: AssetPublication,
|
||||
opts?: { privateGroupId?: number }
|
||||
) => {
|
||||
if (!owner) throw new Error('ownerName is required');
|
||||
if (!assetName) throw new Error('assetName is required');
|
||||
|
||||
const publishInfo = await getAssetIdentifiers(assetName);
|
||||
const identifier = publishInfo.identifiers.genesisPost;
|
||||
const service = publishInfo.services.genesisPost;
|
||||
const baseService = publishInfo.services.genesisPost;
|
||||
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 {
|
||||
await qortalRequest({
|
||||
action: 'PUBLISH_QDN_RESOURCE',
|
||||
name: owner,
|
||||
service,
|
||||
identifier,
|
||||
data64,
|
||||
data64: finalData,
|
||||
});
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || String(e);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { objectToBase64 } from './data';
|
||||
import { NotificationRecipient } from './notificationRecipients';
|
||||
import { uniqueId6 } from './ids';
|
||||
import type { Service } from 'qapp-core';
|
||||
import { publishResourcesWithProgress } from './qdnProgressivePublisher';
|
||||
import { enqueueQmailPublishJob } from '../state/publishQueue';
|
||||
|
||||
const MAIL_SERVICE_TYPE: Service = 'MAIL_PRIVATE';
|
||||
const QMAIL_IDENTIFIER_PREFIX = '_mail_qortal_qmail_';
|
||||
@@ -14,29 +14,7 @@ type SendQmailParams = {
|
||||
message: string;
|
||||
batchSize?: 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) {
|
||||
const safeName = (recipientName || '').slice(0, 20).replace(/\s+/g, '');
|
||||
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 total = validRecipients.length;
|
||||
const startIndex = Math.min(total, Math.max(0, params.resumeFrom ?? 0));
|
||||
if (startIndex > 0) {
|
||||
params.onProgress?.({ sent: startIndex, total });
|
||||
}
|
||||
if (startIndex >= total) {
|
||||
params.onProgress?.({ sent: total, total });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const resourcesWithKeys = await Promise.all(
|
||||
@@ -98,79 +72,27 @@ export async function sendQmailNotifications(params: SendQmailParams) {
|
||||
})
|
||||
);
|
||||
|
||||
const identifierToKey = new Map(
|
||||
resourcesWithKeys.map(({ resource, publicKey }) => [resource.identifier, publicKey])
|
||||
);
|
||||
const identifierKeyMap: Record<string, string> = {};
|
||||
resourcesWithKeys.forEach(({ resource, publicKey }) => {
|
||||
identifierKeyMap[resource.identifier] = publicKey;
|
||||
});
|
||||
|
||||
const pendingResources = resourcesWithKeys.slice(startIndex).map((entry) => entry.resource);
|
||||
if (!pendingResources.length) {
|
||||
params.onProgress?.({ sent: total, total });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
let latestSent = startIndex;
|
||||
let userCancelled = false;
|
||||
const queued = enqueueQmailPublishJob({
|
||||
label: subject,
|
||||
resources: pendingResources,
|
||||
fallbackPublicKeys,
|
||||
identifierKeyMap,
|
||||
chunkSize: batchSize,
|
||||
throttleDelayMs: DEFAULT_THROTTLE_DELAY,
|
||||
chunkTimeoutPerResourceMs: CHUNK_PUBLISH_TIMEOUT_PER_RESOURCE,
|
||||
});
|
||||
|
||||
const toGlobalCount = (completedResources: number) =>
|
||||
Math.min(total, startIndex + completedResources);
|
||||
|
||||
try {
|
||||
await publishResourcesWithProgress(
|
||||
{
|
||||
label: 'Q-Mail notifications',
|
||||
resources: pendingResources,
|
||||
},
|
||||
{
|
||||
chunkSize: batchSize,
|
||||
throttleDelayMs: DEFAULT_THROTTLE_DELAY,
|
||||
onProgress: (ctx) => {
|
||||
latestSent = toGlobalCount(ctx.completedResources);
|
||||
params.onProgress?.({ sent: latestSent, total });
|
||||
},
|
||||
onThrottle: async (ctx) => {
|
||||
const sent = toGlobalCount(ctx.completedResources);
|
||||
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;
|
||||
}
|
||||
if (!queued) return null;
|
||||
await queued.completion;
|
||||
return queued.id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user