feature/private-assets #2

Merged
crowetic merged 3 commits from feature/private-assets into main 2025-12-04 21:14:37 +00:00
25 changed files with 1443 additions and 537 deletions
+65 -14
View File
@@ -21,9 +21,10 @@ import {
import { useAlert } from '../alerts';
import 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 -1
View File
@@ -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;
+10 -127
View File
@@ -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>
+56 -33
View File
@@ -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}>
+59 -22
View File
@@ -1,10 +1,17 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Box, Card, CardContent, Typography, Divider, Skeleton, Chip, Button } from '@mui/material';
import 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);
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { useAuth } from 'qapp-core';
import { getAccountGroups } from '../utils/qortalApi';
export function useMemberGroupIds() {
const { address } = useAuth();
const [memberGroupIds, setMemberGroupIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!address) {
setMemberGroupIds([]);
return;
}
let cancelled = false;
(async () => {
try {
setLoading(true);
const cacheKey = `memberGroups:${address}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed) && !cancelled) {
setMemberGroupIds(parsed as number[]);
}
} catch {
/* ignore cache parse */
}
}
const groups = await getAccountGroups(address);
if (!cancelled) {
const next = groups
.map((g) => Number(g.groupId))
.filter((n) => Number.isFinite(n)) as number[];
setMemberGroupIds(next);
localStorage.setItem(cacheKey, JSON.stringify(next));
}
} catch {
if (!cancelled) setMemberGroupIds([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [address]);
return { memberGroupIds, loading };
}
-85
View File
@@ -1,85 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
publishResourcesWithProgress,
type PublishJobDefinition,
type PublishJobOptions,
type PublishJobProgress,
type PublishThrottleContext,
} from '../utils/qdnProgressivePublisher';
export type PublishThrottleState = {
context: PublishThrottleContext;
secondsLeft: number;
resume: () => void;
cancel: () => void;
};
export function useQdnProgressivePublisher() {
const [progress, setProgress] = useState<PublishJobProgress | null>(null);
const [throttle, setThrottle] = useState<PublishThrottleState | null>(null);
useEffect(() => {
if (!throttle) return;
if (throttle.secondsLeft <= 0) {
throttle.resume();
return;
}
const id = setInterval(() => {
setThrottle((prev) => {
if (!prev) return prev;
if (prev.secondsLeft <= 1) {
prev.resume();
return null;
}
return { ...prev, secondsLeft: prev.secondsLeft - 1 };
});
}, 1000);
return () => clearInterval(id);
}, [throttle]);
const publish = useCallback(async (job: PublishJobDefinition, options?: PublishJobOptions) => {
const { onProgress, onThrottle, ...rest } = options || {};
setProgress(null);
const handleProgress = (ctx: PublishJobProgress) => {
setProgress(ctx);
onProgress?.(ctx);
};
const handleThrottle = async (ctx: PublishThrottleContext) => {
if (onThrottle) {
const decision = await onThrottle(ctx);
if (decision === false) return false;
}
return new Promise<boolean>((resolve) => {
const resolveAndClear = (value: boolean) => {
resolve(value);
setThrottle(null);
};
setThrottle({
context: ctx,
secondsLeft: Math.ceil(ctx.delayMs / 1000),
resume: () => resolveAndClear(true),
cancel: () => resolveAndClear(false),
});
});
};
try {
await publishResourcesWithProgress(job, {
...rest,
onProgress: handleProgress,
onThrottle: handleThrottle,
});
} finally {
setProgress(null);
setThrottle(null);
}
}, []);
return { publish, progress, throttle };
}
+9 -3
View File
@@ -24,7 +24,13 @@ const MAX_AUTO_PAGES = 40;
/**
* Generic hook for listing QDN resources for a given name, with paging support.
*/
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 };
}
-2
View File
@@ -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 };
}
+61 -3
View File
@@ -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 assets 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
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { getAssetBalances } from '../utils/qortalAssetRequests';
import {
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',
+55
View File
@@ -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 }} />
+46 -5
View File
@@ -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
View File
@@ -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={{
+95 -32
View File
@@ -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({
+395
View File
@@ -0,0 +1,395 @@
import { useSyncExternalStore } from 'react';
import { BatchPublishResource } from '../utils/useQdnBatchPublisher';
import {
PublishJobProgress,
PublishThrottleContext,
publishResourcesWithProgress,
PublishJobError,
} from '../utils/qdnProgressivePublisher';
type PublishQueueJobBase = {
id: string;
label?: string;
createdAt: number;
status: 'pending' | 'running' | 'completed' | 'error';
progress: PublishJobProgress | null;
error?: string;
};
export type PublishThrottleState = {
context: PublishThrottleContext;
secondsLeft: number;
resume: () => void;
cancel: () => void;
};
type QdnJobPayload = {
kind: 'qdn';
resources: BatchPublishResource[];
chunkSize?: number;
throttleDelayMs?: number;
};
type QmailJobPayload = {
kind: 'qmail';
resources: BatchPublishResource[];
chunkSize?: number;
throttleDelayMs?: number;
fallbackPublicKeys: string[];
identifierKeyMap: Record<string, string>;
chunkTimeoutPerResourceMs?: number;
};
type PublishQueueJob = PublishQueueJobBase & {
payload: QdnJobPayload | QmailJobPayload;
throttle: PublishThrottleState | null;
completionPromise: Promise<void>;
resolveCompletion: () => void;
rejectCompletion: (error: Error) => void;
};
type PersistedJob = {
id: string;
label?: string;
createdAt: number;
payload: QdnJobPayload | QmailJobPayload;
};
type PublishQueueSnapshot = {
jobs: PublishQueueJob[];
activeJobId?: string;
};
const STORAGE_KEY = 'qassets_publish_queue_v1';
const listeners = new Set<() => void>();
const hasWindow = typeof window !== 'undefined';
const isQmailPayload = (payload: QdnJobPayload | QmailJobPayload): payload is QmailJobPayload =>
payload.kind === 'qmail';
const createQueueJob = (
payload: QdnJobPayload | QmailJobPayload,
meta?: {
id?: string;
label?: string;
createdAt?: number;
status?: PublishQueueJob['status'];
}
): PublishQueueJob => {
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
const completionPromise = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
return {
id: meta?.id ?? generateId(),
label: meta?.label,
createdAt: meta?.createdAt ?? Date.now(),
status: meta?.status ?? 'pending',
progress: null,
throttle: null,
payload,
completionPromise,
resolveCompletion,
rejectCompletion,
};
};
const createSnapshot = (): PublishQueueSnapshot => ({
jobs: [],
activeJobId: undefined,
});
const snapshot: PublishQueueSnapshot = createSnapshot();
let processing = false;
const notify = () => {
listeners.forEach((listener) => listener());
};
const persist = () => {
if (!hasWindow) return;
try {
const serializable = snapshot.jobs
.filter((job) => job.status === 'pending')
.map((job) => ({
id: job.id,
label: job.label,
createdAt: job.createdAt,
payload: job.payload,
}));
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable));
} catch {
/* ignore */
}
};
const loadPersistedJobs = () => {
if (!hasWindow) return;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return;
parsed.forEach((item: PersistedJob) => {
if (!item || typeof item !== 'object' || !Array.isArray(item.payload?.resources)) return;
const job = createQueueJob(item.payload, {
id: item.id,
label: item.label,
createdAt: item.createdAt || Date.now(),
});
snapshot.jobs.push(job);
});
} catch {
/* ignore */
}
};
const generateId = () => `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const updateJob = (jobId: string, updater: (job: PublishQueueJob) => void) => {
const job = snapshot.jobs.find((j) => j.id === jobId);
if (!job) return;
updater(job);
notify();
};
const cleanupJob = (jobId: string) => {
const idx = snapshot.jobs.findIndex((j) => j.id === jobId);
if (idx >= 0) {
snapshot.jobs.splice(idx, 1);
}
if (snapshot.activeJobId === jobId) {
snapshot.activeJobId = undefined;
}
persist();
notify();
};
const startNextJob = async () => {
if (processing) return;
processing = true;
try {
while (true) {
const nextJob = snapshot.jobs.find((j) => j.status === 'pending');
if (!nextJob) break;
await runJob(nextJob);
}
} finally {
processing = false;
}
};
loadPersistedJobs();
if (snapshot.jobs.length) {
startNextJob();
}
if (snapshot.jobs.length) {
startNextJob();
}
const startThrottleCountdown = (
job: PublishQueueJob,
ctx: PublishThrottleContext,
resolve: (value: boolean) => void
) => {
if (!hasWindow) {
resolve(true);
return;
}
let resolved = false;
const interval = window.setInterval(() => {
updateJob(job.id, (target) => {
if (!target.throttle) return;
const next = target.throttle.secondsLeft - 1;
target.throttle = { ...target.throttle, secondsLeft: Math.max(0, next) };
if (next <= 0 && !resolved) {
resolved = true;
window.clearInterval(interval);
target.throttle = null;
resolve(true);
}
});
}, 1000);
job.throttle = {
context: ctx,
secondsLeft: Math.ceil(ctx.delayMs / 1000),
resume: () => {
if (resolved) return;
resolved = true;
if (hasWindow) window.clearInterval(interval);
updateJob(job.id, (target) => {
target.throttle = null;
});
resolve(true);
},
cancel: () => {
if (resolved) return;
resolved = true;
if (hasWindow) window.clearInterval(interval);
updateJob(job.id, (target) => {
target.throttle = null;
});
resolve(false);
},
};
notify();
};
const runJob = async (job: PublishQueueJob) => {
snapshot.activeJobId = job.id;
job.status = 'running';
job.progress = null;
job.throttle = null;
notify();
const abortController = new AbortController();
const handleProgress = (progress: PublishJobProgress) => {
updateJob(job.id, (target) => {
target.progress = progress;
});
};
const handleThrottle = (ctx: PublishThrottleContext) =>
new Promise<boolean>((resolve) => {
updateJob(job.id, (target) => {
startThrottleCountdown(target, ctx, resolve);
});
});
const execOptions: Parameters<typeof publishResourcesWithProgress>[1] = {
chunkSize: job.payload.chunkSize,
throttleDelayMs: job.payload.throttleDelayMs,
signal: abortController.signal,
onProgress: handleProgress,
onThrottle: handleThrottle,
};
if (isQmailPayload(job.payload)) {
const qmailPayload = job.payload;
execOptions.executeChunk = async (chunk) => {
const identifierMap = qmailPayload.identifierKeyMap;
const chunkKeys = new Set<string>();
chunk.forEach((resource) => {
const key = identifierMap[resource.identifier];
if (key) chunkKeys.add(key);
});
const keys = chunkKeys.size ? Array.from(chunkKeys) : qmailPayload.fallbackPublicKeys;
const perResource = qmailPayload.chunkTimeoutPerResourceMs ?? 12e5;
const timeoutMs = Math.max(perResource, chunk.length * perResource);
await qortalRequestWithTimeout(
{
action: 'PUBLISH_MULTIPLE_QDN_RESOURCES',
resources: chunk,
encrypt: true,
publicKeys: keys,
},
timeoutMs
);
};
}
try {
await publishResourcesWithProgress(
{
label: job.label,
resources: job.payload.resources,
},
execOptions
);
job.status = 'completed';
job.progress = null;
job.resolveCompletion();
} catch (error: any) {
job.status = 'error';
job.error = error instanceof PublishJobError ? error.message : error?.message || String(error);
job.rejectCompletion(
error instanceof Error ? error : new Error(error?.message || String(error))
);
} finally {
snapshot.activeJobId = undefined;
job.progress = null;
job.throttle = null;
cleanupJob(job.id);
}
};
export const enqueueQdnPublishJob = (params: {
label?: string;
resources: BatchPublishResource[];
chunkSize?: number;
throttleDelayMs?: number;
}) => {
if (!params.resources.length) return null;
const job = createQueueJob(
{
kind: 'qdn',
resources: params.resources,
chunkSize: params.chunkSize,
throttleDelayMs: params.throttleDelayMs,
},
{ label: params.label }
);
snapshot.jobs.push(job);
persist();
notify();
startNextJob();
return { id: job.id, completion: job.completionPromise };
};
export const enqueueQmailPublishJob = (params: {
label?: string;
resources: BatchPublishResource[];
fallbackPublicKeys: string[];
identifierKeyMap: Record<string, string>;
chunkSize?: number;
throttleDelayMs?: number;
chunkTimeoutPerResourceMs?: number;
}) => {
if (!params.resources.length) return null;
const job = createQueueJob(
{
kind: 'qmail',
resources: params.resources,
chunkSize: params.chunkSize,
throttleDelayMs: params.throttleDelayMs,
fallbackPublicKeys: params.fallbackPublicKeys,
identifierKeyMap: params.identifierKeyMap,
chunkTimeoutPerResourceMs: params.chunkTimeoutPerResourceMs,
},
{ label: params.label }
);
snapshot.jobs.push(job);
persist();
notify();
startNextJob();
return { id: job.id, completion: job.completionPromise };
};
export const usePublishQueue = () =>
useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => snapshot,
() => snapshot
);
export const resumeThrottledJob = (jobId: string) => {
const job = snapshot.jobs.find((j) => j.id === jobId);
job?.throttle?.resume();
};
export const cancelThrottledJob = (jobId: string) => {
const job = snapshot.jobs.find((j) => j.id === jobId);
job?.throttle?.cancel();
};
+3
View File
@@ -20,6 +20,8 @@ export type DividendPeriod = '1W' | '2W' | '1M' | '3M' | '6M' | '1Y';
export interface AssetPublication {
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 {
+29
View File
@@ -0,0 +1,29 @@
import { resolveAssetPublicationById } from './resolveAssetPublication';
export type AssetPrivacy = { isPrivate: boolean; groupId?: number };
const cache = new Map<number, AssetPrivacy>();
export async function getAssetPrivacy(assetId: number): Promise<AssetPrivacy> {
if (assetId <= 2) return { isPrivate: false };
if (cache.has(assetId)) return cache.get(assetId)!;
try {
const { publication } = await resolveAssetPublicationById(assetId);
const rawGroup = publication?.privateGroupId ?? publication?.primaryGroup?.id;
const groupId =
rawGroup != null && Number.isFinite(Number(rawGroup)) ? Number(rawGroup) : undefined;
const info: AssetPrivacy = { isPrivate: Boolean(publication?.privateAsset), groupId };
cache.set(assetId, info);
return info;
} catch {
const info: AssetPrivacy = { isPrivate: false };
cache.set(assetId, info);
return info;
}
}
export function canViewAsset(privacy: AssetPrivacy, memberGroupIds: number[]) {
if (!privacy.isPrivate) return true;
return privacy.groupId != null && memberGroupIds.includes(privacy.groupId);
}
+30 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+19 -4
View File
@@ -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);
+19 -97
View File
@@ -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;
}