Added encrypted/chunked publishing and streaming encrypted video playback. #3
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, 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';
|
||||
@@ -197,7 +197,9 @@ export default function QAssetsNewsSection() {
|
||||
const [announcements, setAnnouncements] = useState<NewsSummary[] | null>(null);
|
||||
const [assetNews, setAssetNews] = useState<NewsSummary[] | null>(null);
|
||||
const [promotions, setPromotions] = useState<NewsSummary[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingAnnouncements, setLoadingAnnouncements] = useState(true);
|
||||
const [loadingAssetNews, setLoadingAssetNews] = useState(true);
|
||||
const [loadingPromotions, setLoadingPromotions] = useState(true);
|
||||
|
||||
const [showArchivedAnnouncements, setShowArchivedAnnouncements] = useState(false);
|
||||
const [showArchivedNews, setShowArchivedNews] = useState(false);
|
||||
@@ -212,52 +214,119 @@ export default function QAssetsNewsSection() {
|
||||
const { memberGroupIds, loading: groupsLoading } = useMemberGroupIds();
|
||||
|
||||
const theme = useTheme();
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const createAbortController = useCallback(() => {
|
||||
controllerRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
controllerRef.current = ctrl;
|
||||
return ctrl;
|
||||
}, []);
|
||||
|
||||
const isSignalCurrent = useCallback(
|
||||
(signal: AbortSignal) => controllerRef.current?.signal === signal,
|
||||
[]
|
||||
);
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
Boolean(
|
||||
error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError'
|
||||
);
|
||||
|
||||
// Initial load of lists
|
||||
const loadNews = useCallback(
|
||||
async (forceFresh = false) => {
|
||||
(forceFresh = false) => {
|
||||
const controller = createAbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
setLoadingAnnouncements(true);
|
||||
setLoadingAssetNews(true);
|
||||
setLoadingPromotions(true);
|
||||
|
||||
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, {
|
||||
|
||||
const loadAnnouncements = async () => {
|
||||
try {
|
||||
const results = await fetchAnnouncements(announcementLimit, {
|
||||
includeExpired: showArchivedAnnouncements,
|
||||
forceFresh,
|
||||
}),
|
||||
fetchLatestAssetNews(assetNewsLimit, {
|
||||
signal,
|
||||
});
|
||||
if (!isSignalCurrent(signal)) return;
|
||||
setAnnouncements(results);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return;
|
||||
console.error('Failed to load Q-Assets announcements', err);
|
||||
if (isSignalCurrent(signal)) {
|
||||
setAnnouncements([]);
|
||||
}
|
||||
} finally {
|
||||
if (isSignalCurrent(signal)) {
|
||||
setLoadingAnnouncements(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssetNews = async () => {
|
||||
try {
|
||||
const results = await fetchLatestAssetNews(assetNewsLimit, {
|
||||
includeExpired: showArchivedNews,
|
||||
allowedGroupIds: memberGroupIds,
|
||||
}),
|
||||
fetchActivePromotions(),
|
||||
]);
|
||||
setAnnouncements(a);
|
||||
setAssetNews(n);
|
||||
setPromotions(p);
|
||||
} catch (e) {
|
||||
console.error('Failed to load Q-Assets news', e);
|
||||
setAnnouncements([]);
|
||||
signal,
|
||||
});
|
||||
if (!isSignalCurrent(signal)) return;
|
||||
setAssetNews(results);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return;
|
||||
console.error('Failed to load asset news publications', err);
|
||||
if (isSignalCurrent(signal)) {
|
||||
setAssetNews([]);
|
||||
setPromotions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
if (isSignalCurrent(signal)) {
|
||||
setLoadingAssetNews(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadPromotions = async () => {
|
||||
try {
|
||||
const results = await fetchActivePromotions(Date.now(), { signal });
|
||||
if (!isSignalCurrent(signal)) return;
|
||||
setPromotions(results);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return;
|
||||
console.error('Failed to load promotions', err);
|
||||
if (isSignalCurrent(signal)) {
|
||||
setPromotions([]);
|
||||
}
|
||||
} finally {
|
||||
if (isSignalCurrent(signal)) {
|
||||
setLoadingPromotions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadAnnouncements();
|
||||
void loadAssetNews();
|
||||
void loadPromotions();
|
||||
},
|
||||
[
|
||||
createAbortController,
|
||||
isSignalCurrent,
|
||||
memberGroupIds,
|
||||
showArchivedAnnouncements,
|
||||
showArchivedNews,
|
||||
showMoreAnnouncements,
|
||||
showMoreNews,
|
||||
memberGroupIds,
|
||||
]
|
||||
);
|
||||
|
||||
const loadingLists =
|
||||
loading || groupsLoading || announcements === null || assetNews === null || promotions === null;
|
||||
|
||||
const handleClickItem = (item: NewsSummary) => {
|
||||
setSelected(item);
|
||||
};
|
||||
@@ -269,27 +338,26 @@ export default function QAssetsNewsSection() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (cancelled) return;
|
||||
await loadNews(true);
|
||||
})();
|
||||
if (groupsLoading) return;
|
||||
loadNews(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = null;
|
||||
};
|
||||
}, [loadNews]);
|
||||
}, [groupsLoading, loadNews]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (groupsLoading) return;
|
||||
loadNews(true);
|
||||
};
|
||||
window.addEventListener(NEWS_REFRESH_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(NEWS_REFRESH_EVENT, handler);
|
||||
};
|
||||
}, [loadNews]);
|
||||
}, [groupsLoading, loadNews]);
|
||||
|
||||
const announcementList = announcements || [];
|
||||
const announcementList = announcements ?? [];
|
||||
|
||||
const announcementActive = announcementList.filter((item) => !item.isExpired);
|
||||
const announcementArchived = announcementList.filter((item) => item.isExpired);
|
||||
@@ -305,6 +373,32 @@ export default function QAssetsNewsSection() {
|
||||
showMoreNews ? Number.MAX_SAFE_INTEGER : maxPerList
|
||||
);
|
||||
|
||||
const promotionsList = promotions ?? [];
|
||||
|
||||
const showAnnouncementSkeleton = loadingAnnouncements && announcements === null;
|
||||
const showAssetNewsSkeleton = loadingAssetNews && assetNews === null;
|
||||
const showPromotionsSkeleton = loadingPromotions && promotions === null;
|
||||
|
||||
const ColumnSkeleton = () => (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.08)',
|
||||
border: `1px solid ${alpha(theme.palette.common.white, 0.08)}`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Skeleton variant="text" width="60%" sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="90%" />
|
||||
<Skeleton variant="text" width="80%" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Helper for label
|
||||
const typeLabel = (type: NewsType | undefined) => {
|
||||
if (!type) return '';
|
||||
@@ -453,23 +547,12 @@ export default function QAssetsNewsSection() {
|
||||
Announcements from Q-Assets, latest news from all issuers, and paid promotional content.
|
||||
</Typography>
|
||||
|
||||
{loadingLists ? (
|
||||
<Grid container spacing={2}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Grid key={i} size={{ xs: 12, md: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Skeleton variant="text" width="60%" />
|
||||
<Skeleton variant="text" width="90%" />
|
||||
<Skeleton variant="text" width="80%" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
{showAnnouncementSkeleton ? (
|
||||
<ColumnSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<NewsListColumn
|
||||
title="Q-Assets Announcements"
|
||||
items={visibleAnnouncements}
|
||||
@@ -504,8 +587,14 @@ export default function QAssetsNewsSection() {
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
{showAssetNewsSkeleton ? (
|
||||
<ColumnSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<NewsListColumn
|
||||
title="Asset News Publications"
|
||||
items={visibleNews}
|
||||
@@ -519,7 +608,11 @@ export default function QAssetsNewsSection() {
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1 }}>
|
||||
{(showArchivedNews ? newsArchived : newsActive).length > maxPerList && (
|
||||
<Button size="small" onClick={() => setShowMoreNews((v) => !v)} variant="outlined">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowMoreNews((v) => !v)}
|
||||
variant="outlined"
|
||||
>
|
||||
{showMoreNews ? 'Show less' : 'Show more'}
|
||||
</Button>
|
||||
)}
|
||||
@@ -535,18 +628,23 @@ export default function QAssetsNewsSection() {
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
{showPromotionsSkeleton ? (
|
||||
<ColumnSkeleton />
|
||||
) : (
|
||||
<NewsListColumn
|
||||
title="Promotions"
|
||||
items={promotions ?? []}
|
||||
items={promotionsList}
|
||||
emptyText="No active promotions."
|
||||
onClickItem={handleClickItem}
|
||||
variant="promotion"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<NewsActionBar
|
||||
treasuryAddress="Q-Assets" // TODO put real address for the treasury account. We do not want to utilize Q-Assets.
|
||||
defaultPromoPriceQort={5}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
objectToBase64,
|
||||
base64ToUtf8,
|
||||
base64ToUint8Array,
|
||||
uint8ArrayToBase64,
|
||||
} from '../../../utils/data';
|
||||
import { uniqueId6 } from '../../../utils/ids';
|
||||
import { stripPrivateMagic, PRIVATE_MAGIC_B64 } from '../../../constants/qdeckIdentifiers';
|
||||
@@ -83,6 +84,16 @@ import {
|
||||
getEncryptionInfo,
|
||||
resourceIsPrivate,
|
||||
} from '../../../utils/qdnEncryption';
|
||||
import {
|
||||
MAX_INLINE_FILE_SIZE,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
iterateFileChunks,
|
||||
buildChunkIdentifier,
|
||||
createChunkedManifest,
|
||||
FileChunkDescriptor,
|
||||
ChunkedFileManifest,
|
||||
CHUNK_FORCED_THRESHOLD,
|
||||
} from '../../../utils/fileChunking';
|
||||
import type {
|
||||
FolderDescriptor,
|
||||
FolderNode,
|
||||
@@ -124,6 +135,8 @@ type PreviewDialogState = {
|
||||
resource?: QdnResource | null;
|
||||
zoomed?: boolean;
|
||||
expanded?: boolean;
|
||||
videoUrl?: string;
|
||||
chunked?: boolean;
|
||||
};
|
||||
|
||||
const PREVIEW_STEPS: PreviewStep[] = [
|
||||
@@ -140,6 +153,8 @@ const createPreviewDialogState = (): PreviewDialogState => ({
|
||||
zoomed: false,
|
||||
expanded: false,
|
||||
loading: false,
|
||||
videoUrl: undefined,
|
||||
chunked: false,
|
||||
});
|
||||
|
||||
const PUBLISH_MODE_STORAGE_KEY = 'qassets_publish_mode_preference_v1';
|
||||
@@ -150,6 +165,7 @@ const PENDING_FOLDERS_KEY = 'qassets_data_pending_folders_v1';
|
||||
const PUBLISH_CHUNK_SIZE = 25;
|
||||
const MANIFEST_REFRESH_COOLDOWN = 90 * 1000;
|
||||
const MAX_FILE_IDENTIFIER_LENGTH = QASSETS_FILE_ID_MAX;
|
||||
const CHUNK_METADATA_TAG = 'qassets-chunk';
|
||||
const MANIFEST_SERVICE = ensurePrivateService('DOCUMENT_PRIVATE');
|
||||
type ResourceSort =
|
||||
| 'name-asc'
|
||||
@@ -455,6 +471,7 @@ type LoadedResourceContent = {
|
||||
key: string;
|
||||
base64: string;
|
||||
mime: string;
|
||||
chunkedManifest?: ChunkedFileManifest;
|
||||
};
|
||||
|
||||
const normalizeData64 = (payload: any): string | null => {
|
||||
@@ -853,6 +870,32 @@ export default function DataExplorer() {
|
||||
createPreviewDialogState()
|
||||
);
|
||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const previewChunkedBlobUrlRef = useRef<string | null>(null);
|
||||
const chunkedMediaSourceRef = useRef<{ objectUrl: string; cancel: () => void } | null>(null);
|
||||
const setPreviewChunkedBlobUrl = useCallback((url?: string) => {
|
||||
if (previewChunkedBlobUrlRef.current && previewChunkedBlobUrlRef.current !== url) {
|
||||
URL.revokeObjectURL(previewChunkedBlobUrlRef.current);
|
||||
}
|
||||
previewChunkedBlobUrlRef.current = url ?? null;
|
||||
}, []);
|
||||
const cleanupChunkedBlobUrl = useCallback(() => {
|
||||
if (previewChunkedBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(previewChunkedBlobUrlRef.current);
|
||||
previewChunkedBlobUrlRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const cleanupChunkedMediaSource = useCallback(() => {
|
||||
const entry = chunkedMediaSourceRef.current;
|
||||
chunkedMediaSourceRef.current = null;
|
||||
if (entry) {
|
||||
entry.cancel();
|
||||
URL.revokeObjectURL(entry.objectUrl);
|
||||
}
|
||||
}, []);
|
||||
const cleanupChunkedVideoPreview = useCallback(() => {
|
||||
cleanupChunkedMediaSource();
|
||||
cleanupChunkedBlobUrl();
|
||||
}, [cleanupChunkedBlobUrl, cleanupChunkedMediaSource]);
|
||||
const [manifestDialog, setManifestDialog] = useState<{
|
||||
open: boolean;
|
||||
entry: StructuredEntry | null;
|
||||
@@ -1023,6 +1066,19 @@ export default function DataExplorer() {
|
||||
base64,
|
||||
mime: inferredMime,
|
||||
};
|
||||
if ((resource.metadata?.qassetsFs as any)?.chunked) {
|
||||
try {
|
||||
const manifest = JSON.parse(base64ToUtf8(base64)) as ChunkedFileManifest;
|
||||
if (manifest && Array.isArray(manifest.chunks)) {
|
||||
entry.chunkedManifest = manifest;
|
||||
if (manifest.mimeType) {
|
||||
entry.mime = manifest.mimeType;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed chunk manifest
|
||||
}
|
||||
}
|
||||
setLoadedContent(entry);
|
||||
setDetectedTypes((prev) =>
|
||||
prev[resource.identifier] === inferredMime
|
||||
@@ -1445,6 +1501,112 @@ export default function DataExplorer() {
|
||||
return map;
|
||||
}, [combinedResources]);
|
||||
|
||||
const createChunkedBlobUrl = useCallback(
|
||||
async (manifest: ChunkedFileManifest) => {
|
||||
if (!manifest.chunks?.length) {
|
||||
throw new Error('Chunk manifest is empty.');
|
||||
}
|
||||
const buffers: Uint8Array[] = [];
|
||||
for (const chunk of manifest.chunks) {
|
||||
const chunkResource = combinedResourceMap.get(chunk.identifier);
|
||||
if (!chunkResource) {
|
||||
throw new Error(`Chunk ${chunk.identifier} is not available yet.`);
|
||||
}
|
||||
const chunkBase64 = await resolveResourceBase64(chunkResource);
|
||||
buffers.push(base64ToUint8Array(chunkBase64));
|
||||
}
|
||||
const blob = new Blob(buffers, {
|
||||
type: manifest.mimeType || 'application/octet-stream',
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
},
|
||||
[combinedResourceMap, resolveResourceBase64]
|
||||
);
|
||||
|
||||
const streamChunkedVideo = useCallback(
|
||||
(manifest: ChunkedFileManifest) => {
|
||||
if (!manifest.chunks?.length) {
|
||||
throw new Error('Chunk manifest is empty.');
|
||||
}
|
||||
if (typeof window === 'undefined' || typeof MediaSource === 'undefined') {
|
||||
throw new Error('Chunk streaming is not supported in this environment.');
|
||||
}
|
||||
cleanupChunkedMediaSource();
|
||||
const mediaSource = new MediaSource();
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
const sortedChunks = [...manifest.chunks].sort((a, b) => a.index - b.index);
|
||||
let resolved = false;
|
||||
let rejectStream: ((reason?: any) => void) | null = null;
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
rejectStream = reject;
|
||||
const handleSourceOpen = () => {
|
||||
mediaSource.removeEventListener('sourceopen', handleSourceOpen);
|
||||
let sourceBuffer: SourceBuffer;
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(manifest.mimeType || 'video/mp4');
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
let nextIndex = 0;
|
||||
const appendNextChunk = async () => {
|
||||
if (resolved) return;
|
||||
if (nextIndex >= sortedChunks.length) {
|
||||
resolved = true;
|
||||
if (mediaSource.readyState === 'open') {
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (sourceBuffer.updating) {
|
||||
sourceBuffer.addEventListener('updateend', appendNextChunk, { once: true });
|
||||
return;
|
||||
}
|
||||
const chunkMeta = sortedChunks[nextIndex];
|
||||
const chunkResource = combinedResourceMap.get(chunkMeta.identifier);
|
||||
if (!chunkResource) {
|
||||
reject(new Error(`Chunk ${chunkMeta.identifier} is not available yet.`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const chunkBase64 = await resolveResourceBase64(chunkResource);
|
||||
const chunkData = base64ToUint8Array(chunkBase64);
|
||||
sourceBuffer.appendBuffer(chunkData);
|
||||
nextIndex += 1;
|
||||
sourceBuffer.addEventListener('updateend', appendNextChunk, { once: true });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
appendNextChunk();
|
||||
};
|
||||
mediaSource.addEventListener('sourceopen', handleSourceOpen);
|
||||
});
|
||||
const cancel = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (rejectStream) {
|
||||
rejectStream(new Error('Chunk streaming cancelled.'));
|
||||
}
|
||||
if (mediaSource.readyState === 'open') {
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
chunkedMediaSourceRef.current = { objectUrl, cancel };
|
||||
return { objectUrl, promise };
|
||||
},
|
||||
[cleanupChunkedMediaSource, combinedResourceMap, resolveResourceBase64]
|
||||
);
|
||||
|
||||
const resourceStructuredEntries = useMemo(
|
||||
() =>
|
||||
combinedResources
|
||||
@@ -2319,8 +2481,8 @@ export default function DataExplorer() {
|
||||
groupId,
|
||||
groupAdminsOnly,
|
||||
directRecipients,
|
||||
chunkedPublishing,
|
||||
}: PublishSubmitPayload) => {
|
||||
console.log('files', files);
|
||||
if (!activeName) {
|
||||
setPublishStatus('Select a Qortal name before publishing.');
|
||||
return;
|
||||
@@ -2331,18 +2493,104 @@ export default function DataExplorer() {
|
||||
}
|
||||
|
||||
const normalizedFolder = normalizePathSegments(form.folderPath).join('/');
|
||||
const hasForcedChunking =
|
||||
encryptionMode !== 'none' && files.some((file) => file.size > CHUNK_FORCED_THRESHOLD);
|
||||
const requiresChunkGroup = chunkedPublishing && encryptionMode !== 'none';
|
||||
const needsForcedGroup = hasForcedChunking;
|
||||
if (requiresChunkGroup && encryptionMode !== 'group') {
|
||||
setPublishStatus('Chunked publishing requires group encryption.');
|
||||
return;
|
||||
}
|
||||
if ((requiresChunkGroup || needsForcedGroup) && !groupId) {
|
||||
setPublishStatus('Select a private group for chunked publishing.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPublishing(true);
|
||||
setPublishStatus(null);
|
||||
const baseId = sanitizeIdentifier(form.identifier || '');
|
||||
let success = false;
|
||||
try {
|
||||
const publisherAddress = await resolvePublisherAddress();
|
||||
const directRecipientList = parseRecipientList(directRecipients);
|
||||
let directPublicKeys: string[] = [];
|
||||
if (encryptionMode === 'direct') {
|
||||
if (!directRecipientList.length) {
|
||||
alert('No recipients specified. Files will be encrypted for you only.');
|
||||
}
|
||||
const { publicKeys } = await collectRecipientPublicKeys({
|
||||
usersAllowed: directRecipientList,
|
||||
includeSelf: true,
|
||||
me: {
|
||||
name: activeName || authName || undefined,
|
||||
address: publisherAddress,
|
||||
},
|
||||
});
|
||||
directPublicKeys = publicKeys;
|
||||
if (!directPublicKeys.length) {
|
||||
throw new Error('No recipient public keys resolved.');
|
||||
}
|
||||
}
|
||||
|
||||
const encryptPayload = async (
|
||||
payload64: string
|
||||
): Promise<{
|
||||
base64: string;
|
||||
service: Service;
|
||||
tagExtra: string[];
|
||||
privateMode?: 'group' | 'direct';
|
||||
}> => {
|
||||
if (encryptionMode === 'none') {
|
||||
return {
|
||||
base64: payload64,
|
||||
service: form.service,
|
||||
tagExtra: [],
|
||||
};
|
||||
}
|
||||
if (encryptionMode === 'group') {
|
||||
if (!groupId) throw new Error('Select a group for encryption.');
|
||||
const enc64Group = await qortalRequest({
|
||||
action: 'ENCRYPT_QORTAL_GROUP_DATA',
|
||||
base64: payload64,
|
||||
groupId,
|
||||
isAdmins: groupAdminsOnly,
|
||||
});
|
||||
const tagExtra = buildEncryptionTagSet({
|
||||
mode: 'group',
|
||||
publisher: publisherAddress,
|
||||
groupId,
|
||||
adminsOnly: groupAdminsOnly,
|
||||
});
|
||||
return {
|
||||
base64: enc64Group,
|
||||
service: resolveServiceForEncryptionMode(form.service, 'group'),
|
||||
tagExtra,
|
||||
privateMode: 'group',
|
||||
};
|
||||
}
|
||||
const enc64Direct = await qortalRequest({
|
||||
action: 'ENCRYPT_DATA',
|
||||
base64: payload64,
|
||||
publicKeys: directPublicKeys,
|
||||
});
|
||||
const tagExtra = buildEncryptionTagSet({
|
||||
mode: 'direct',
|
||||
publisher: publisherAddress,
|
||||
userCount: directPublicKeys.length || directRecipientList.length || 1,
|
||||
});
|
||||
return {
|
||||
base64: enc64Direct,
|
||||
service: resolveServiceForEncryptionMode(form.service, 'direct'),
|
||||
tagExtra,
|
||||
privateMode: 'direct',
|
||||
};
|
||||
};
|
||||
|
||||
const publishRequests: BatchPublishResource[] = [];
|
||||
const chunkSize = DEFAULT_CHUNK_SIZE;
|
||||
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const file = files[i];
|
||||
console.log('incoming file[i]', file);
|
||||
const file64 = await fileToBase64(file);
|
||||
console.log('base64 from file (first 100):', file64.slice(0, 100));
|
||||
const title = form.title || file.name;
|
||||
const description = form.description || `Published via Q-Assets Data Explorer`;
|
||||
let tags: string[] = [];
|
||||
@@ -2361,81 +2609,6 @@ export default function DataExplorer() {
|
||||
metadata.title = file.name;
|
||||
}
|
||||
|
||||
const applyEncryption = async (
|
||||
file64: string
|
||||
): Promise<{
|
||||
base64: string;
|
||||
service: Service;
|
||||
tagExtra: string[];
|
||||
privateMode?: 'group' | 'direct';
|
||||
}> => {
|
||||
if (encryptionMode === 'none') {
|
||||
return {
|
||||
base64: file64,
|
||||
service: form.service,
|
||||
tagExtra: [],
|
||||
};
|
||||
}
|
||||
const publisherAddress = await resolvePublisherAddress();
|
||||
if (encryptionMode === 'group') {
|
||||
if (!groupId) throw new Error('Select a group for encryption.');
|
||||
const enc64Group = await qortalRequest({
|
||||
action: 'ENCRYPT_QORTAL_GROUP_DATA',
|
||||
base64: file64,
|
||||
groupId,
|
||||
isAdmins: groupAdminsOnly,
|
||||
});
|
||||
// const privData64 = applyPrivateMagicIfNeeded(enc);
|
||||
const tagExtra = buildEncryptionTagSet({
|
||||
mode: 'group',
|
||||
publisher: publisherAddress,
|
||||
groupId,
|
||||
adminsOnly: groupAdminsOnly,
|
||||
});
|
||||
return {
|
||||
base64: enc64Group,
|
||||
service: resolveServiceForEncryptionMode(form.service, 'group'),
|
||||
tagExtra,
|
||||
privateMode: 'group',
|
||||
};
|
||||
}
|
||||
const recipients = parseRecipientList(directRecipients);
|
||||
if (!recipients.length) {
|
||||
alert('No recipients specified. Files will be encrypted for you only.');
|
||||
}
|
||||
const { publicKeys } = await collectRecipientPublicKeys({
|
||||
usersAllowed: recipients,
|
||||
includeSelf: true,
|
||||
me: { name: activeName || authName || undefined, address: publisherAddress },
|
||||
});
|
||||
if (!publicKeys.length) throw new Error('No recipient public keys resolved.');
|
||||
const enc64Direct = await qortalRequest({
|
||||
action: 'ENCRYPT_DATA',
|
||||
base64: file64,
|
||||
publicKeys,
|
||||
});
|
||||
const tagExtra = buildEncryptionTagSet({
|
||||
mode: 'direct',
|
||||
publisher: publisherAddress,
|
||||
userCount: publicKeys.length || recipients.length || 1,
|
||||
});
|
||||
return {
|
||||
base64: enc64Direct,
|
||||
service: resolveServiceForEncryptionMode(form.service, 'direct'),
|
||||
tagExtra,
|
||||
privateMode: 'direct',
|
||||
};
|
||||
};
|
||||
|
||||
const {
|
||||
base64: finalData64,
|
||||
service: finalService,
|
||||
tagExtra,
|
||||
privateMode,
|
||||
} = await applyEncryption(file64);
|
||||
if (tagExtra.length) {
|
||||
tags = tagExtra.concat(tags);
|
||||
}
|
||||
let identifier: string;
|
||||
if (baseId) {
|
||||
const suffix = publishDialog.variant === 'multiple' ? `-${i + 1}-${uniqueId6()}` : '';
|
||||
@@ -2444,9 +2617,22 @@ export default function DataExplorer() {
|
||||
? `${baseId}${suffix}`.slice(0, MAX_FILE_IDENTIFIER_LENGTH)
|
||||
: baseId.slice(0, MAX_FILE_IDENTIFIER_LENGTH);
|
||||
} else {
|
||||
identifier = buildQassetsFileIdentifier(finalService as Service, activeName);
|
||||
identifier = buildQassetsFileIdentifier(form.service as Service, activeName);
|
||||
}
|
||||
|
||||
const shouldChunk =
|
||||
encryptionMode !== 'none' &&
|
||||
(file.size > CHUNK_FORCED_THRESHOLD ||
|
||||
(chunkedPublishing && file.size > MAX_INLINE_FILE_SIZE));
|
||||
if (!shouldChunk) {
|
||||
const file64 = await fileToBase64(file);
|
||||
const {
|
||||
base64: finalData64,
|
||||
service: finalService,
|
||||
tagExtra,
|
||||
privateMode,
|
||||
} = await encryptPayload(file64);
|
||||
const finalTags = tagExtra.length ? tagExtra.concat(tags) : tags;
|
||||
publishRequests.push({
|
||||
name: activeName,
|
||||
service: finalService,
|
||||
@@ -2454,11 +2640,97 @@ export default function DataExplorer() {
|
||||
base64: finalData64,
|
||||
title,
|
||||
description,
|
||||
tags,
|
||||
tags: finalTags,
|
||||
metadata,
|
||||
// disableEncrypt: encryptionMode !== 'none',
|
||||
privateMode,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const chunkDescriptors: FileChunkDescriptor[] = [];
|
||||
const chunkPublishRequests: BatchPublishResource[] = [];
|
||||
for await (const chunk of iterateFileChunks(file, chunkSize)) {
|
||||
const chunk64 = uint8ArrayToBase64(chunk.uint8);
|
||||
const chunkIndexId = buildChunkIdentifier(identifier, chunk.index).slice(
|
||||
0,
|
||||
MAX_FILE_IDENTIFIER_LENGTH
|
||||
);
|
||||
const {
|
||||
base64: chunkData64,
|
||||
service: chunkService,
|
||||
tagExtra,
|
||||
privateMode,
|
||||
} = await encryptPayload(chunk64);
|
||||
chunkDescriptors.push({
|
||||
index: chunk.index,
|
||||
identifier: chunkIndexId,
|
||||
size: chunk.size,
|
||||
});
|
||||
|
||||
const chunkTags = Array.from(new Set([...tagExtra, CHUNK_METADATA_TAG]));
|
||||
chunkPublishRequests.push({
|
||||
name: activeName,
|
||||
service: chunkService,
|
||||
identifier: chunkIndexId,
|
||||
base64: chunkData64,
|
||||
title: `${title} (chunk ${chunk.index + 1})`,
|
||||
description,
|
||||
tags: chunkTags,
|
||||
metadata: {
|
||||
qassetsChunk: {
|
||||
parentIdentifier: identifier,
|
||||
index: chunk.index,
|
||||
chunked: true,
|
||||
},
|
||||
},
|
||||
privateMode,
|
||||
});
|
||||
}
|
||||
|
||||
publishRequests.push(...chunkPublishRequests);
|
||||
|
||||
const chunkEncryptionInfo: ChunkedFileManifest['encryption'] | undefined =
|
||||
encryptionMode === 'group'
|
||||
? { mode: 'group', groupId: groupId ?? undefined, adminsOnly: groupAdminsOnly }
|
||||
: {
|
||||
mode: 'direct',
|
||||
recipientCount: directPublicKeys.length || directRecipientList.length || 1,
|
||||
};
|
||||
const manifest = createChunkedManifest(
|
||||
file,
|
||||
chunkSize,
|
||||
chunkDescriptors,
|
||||
chunkEncryptionInfo
|
||||
);
|
||||
const manifestJson = await objectToBase64(manifest);
|
||||
const {
|
||||
base64: manifestData64,
|
||||
service: manifestService,
|
||||
tagExtra: manifestTagExtra,
|
||||
privateMode: manifestPrivateMode,
|
||||
} = await encryptPayload(manifestJson);
|
||||
const manifestTags = Array.from(new Set([...manifestTagExtra, ...tags]));
|
||||
const manifestMetadata = {
|
||||
...metadata,
|
||||
qassetsFs: {
|
||||
...(metadata.qassetsFs || {}),
|
||||
chunked: true,
|
||||
chunkManifestId: identifier,
|
||||
chunkCount: chunkDescriptors.length,
|
||||
chunkSize,
|
||||
},
|
||||
};
|
||||
publishRequests.push({
|
||||
name: activeName,
|
||||
service: manifestService,
|
||||
identifier,
|
||||
base64: manifestData64,
|
||||
title,
|
||||
description,
|
||||
tags: manifestTags,
|
||||
metadata: manifestMetadata,
|
||||
privateMode: manifestPrivateMode,
|
||||
});
|
||||
}
|
||||
|
||||
await publishResources(publishRequests);
|
||||
@@ -2595,6 +2867,7 @@ export default function DataExplorer() {
|
||||
if (resourceArg && resourceArg.identifier !== selectedResourceId) {
|
||||
setSelectedResourceId(resourceArg.identifier);
|
||||
}
|
||||
cleanupChunkedVideoPreview();
|
||||
const initialSteps = clonePreviewSteps();
|
||||
setPreviewDialog({
|
||||
open: true,
|
||||
@@ -2604,6 +2877,8 @@ export default function DataExplorer() {
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
expanded: false,
|
||||
videoUrl: undefined,
|
||||
chunked: false,
|
||||
});
|
||||
const updateStep = (key: PreviewStepKey, status: PreviewStepStatus, message?: string) => {
|
||||
setPreviewDialog((prev) => ({
|
||||
@@ -2613,6 +2888,88 @@ export default function DataExplorer() {
|
||||
};
|
||||
try {
|
||||
const loaded = await ensureResourceContent(target, { onStep: updateStep });
|
||||
if (loaded.chunkedManifest) {
|
||||
const chunkManifest = loaded.chunkedManifest!;
|
||||
const chunkedMime = chunkManifest.mimeType || loaded.mime;
|
||||
if (chunkedMime.startsWith('video/')) {
|
||||
updateStep('analyze', 'active', 'Streaming video from chunks…');
|
||||
try {
|
||||
const { objectUrl, promise } = streamChunkedVideo(chunkManifest);
|
||||
setPreviewDialog((prev) => ({
|
||||
...prev,
|
||||
open: true,
|
||||
loading: false,
|
||||
title: getResourceLabel(target),
|
||||
type: 'video',
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
chunked: true,
|
||||
videoUrl: objectUrl,
|
||||
error: undefined,
|
||||
}));
|
||||
void promise.catch(async (streamError) => {
|
||||
updateStep('analyze', 'error', streamError?.message);
|
||||
cleanupChunkedMediaSource();
|
||||
try {
|
||||
const fallbackUrl = await createChunkedBlobUrl(chunkManifest);
|
||||
setPreviewChunkedBlobUrl(fallbackUrl);
|
||||
setPreviewDialog((prev) => ({
|
||||
...prev,
|
||||
videoUrl: fallbackUrl,
|
||||
error:
|
||||
streamError?.message ||
|
||||
'Streaming unavailable; playing buffered video instead.',
|
||||
}));
|
||||
} catch (fallbackError: any) {
|
||||
setPreviewDialog((prev) => ({
|
||||
...prev,
|
||||
error:
|
||||
fallbackError?.message ||
|
||||
streamError?.message ||
|
||||
'Unable to load chunked video.',
|
||||
}));
|
||||
}
|
||||
});
|
||||
updateStep('analyze', 'success');
|
||||
return;
|
||||
} catch (startError: any) {
|
||||
updateStep('analyze', 'error', startError?.message);
|
||||
cleanupChunkedMediaSource();
|
||||
const fallbackMessage = startError?.message || 'Streaming unavailable.';
|
||||
try {
|
||||
const fallbackUrl = await createChunkedBlobUrl(chunkManifest);
|
||||
setPreviewChunkedBlobUrl(fallbackUrl);
|
||||
setPreviewDialog((prev) => ({
|
||||
...prev,
|
||||
open: true,
|
||||
loading: false,
|
||||
title: getResourceLabel(target),
|
||||
type: 'video',
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
chunked: true,
|
||||
videoUrl: fallbackUrl,
|
||||
error: fallbackMessage,
|
||||
}));
|
||||
} catch (fallbackError: any) {
|
||||
setPreviewDialog((prev) => ({
|
||||
...prev,
|
||||
open: true,
|
||||
loading: false,
|
||||
title: getResourceLabel(target),
|
||||
type: 'video',
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
chunked: false,
|
||||
videoUrl: undefined,
|
||||
error:
|
||||
fallbackError?.message || startError?.message || 'Unable to load chunked video.',
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (loaded.mime.startsWith('image/')) {
|
||||
setPreviewDialog((prev) => ({
|
||||
...prev,
|
||||
@@ -2623,6 +2980,8 @@ export default function DataExplorer() {
|
||||
dataUrl: `data:${loaded.mime};base64,${loaded.base64}`,
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
chunked: false,
|
||||
videoUrl: undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -2636,6 +2995,8 @@ export default function DataExplorer() {
|
||||
type: 'video',
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
chunked: false,
|
||||
videoUrl: undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -2652,6 +3013,8 @@ export default function DataExplorer() {
|
||||
content: text,
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
chunked: false,
|
||||
videoUrl: undefined,
|
||||
}));
|
||||
} else {
|
||||
setPreviewDialog((prev) => ({
|
||||
@@ -2663,6 +3026,8 @@ export default function DataExplorer() {
|
||||
content: 'This resource appears to be binary. Use Save to system to download it.',
|
||||
resource: target,
|
||||
zoomed: false,
|
||||
chunked: false,
|
||||
videoUrl: undefined,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
@@ -2676,6 +3041,8 @@ export default function DataExplorer() {
|
||||
content: 'Preview not available. Use Save to system to download this resource.',
|
||||
resource: fallbackResource || null,
|
||||
zoomed: false,
|
||||
chunked: false,
|
||||
videoUrl: undefined,
|
||||
}));
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -2689,6 +3056,8 @@ export default function DataExplorer() {
|
||||
error: e?.message || 'Unable to preview this resource.',
|
||||
resource: fallbackResource || null,
|
||||
zoomed: false,
|
||||
chunked: false,
|
||||
videoUrl: undefined,
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -2717,7 +3086,10 @@ export default function DataExplorer() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePreviewClose = () => setPreviewDialog(createPreviewDialogState());
|
||||
const handlePreviewClose = () => {
|
||||
cleanupChunkedVideoPreview();
|
||||
setPreviewDialog(createPreviewDialogState());
|
||||
};
|
||||
|
||||
const handleManifestDialogOpen = (entry: StructuredEntry) => {
|
||||
setManifestDialog({
|
||||
@@ -5085,6 +5457,20 @@ export default function DataExplorer() {
|
||||
height: previewDialog.expanded ? '70vh' : 420,
|
||||
}}
|
||||
>
|
||||
{previewDialog.videoUrl ? (
|
||||
<video
|
||||
ref={previewVideoRef}
|
||||
src={previewDialog.videoUrl}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<VideoPlayer
|
||||
videoRef={previewVideoRef}
|
||||
qortalVideoResource={{
|
||||
@@ -5093,6 +5479,7 @@ export default function DataExplorer() {
|
||||
identifier: previewDialog.resource.identifier,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{previewDialog.type === 'binary' && (
|
||||
@@ -5139,6 +5526,7 @@ export default function DataExplorer() {
|
||||
: null;
|
||||
if (entry) {
|
||||
void handleDeleteFilesCopy([entry]);
|
||||
cleanupChunkedVideoPreview();
|
||||
setPreviewDialog(createPreviewDialogState());
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { GroupSummary } from '../../../../utils/qortalApi';
|
||||
import { ALL_QDN_SERVICES } from '../constants';
|
||||
import { formatBytes } from '../viewHelpers';
|
||||
import { getServiceLimit } from '../../../../utils/useQdnBatchPublisher';
|
||||
import { MAX_INLINE_FILE_SIZE, CHUNK_FORCED_THRESHOLD } from '../../../../utils/fileChunking';
|
||||
|
||||
export type PublishFormState = {
|
||||
service: Service;
|
||||
@@ -38,6 +39,7 @@ export type PublishSubmitPayload = {
|
||||
groupId: number | null;
|
||||
groupAdminsOnly: boolean;
|
||||
directRecipients: string;
|
||||
chunkedPublishing: boolean;
|
||||
};
|
||||
|
||||
type PublishDialogProps = {
|
||||
@@ -71,6 +73,7 @@ export function PublishDialog({
|
||||
const [groupId, setGroupId] = useState<number | null>(null);
|
||||
const [groupAdminsOnly, setGroupAdminsOnly] = useState(false);
|
||||
const [directRecipients, setDirectRecipients] = useState('');
|
||||
const [chunkedPublishing, setChunkedPublishing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -80,6 +83,7 @@ export function PublishDialog({
|
||||
setGroupId(null);
|
||||
setGroupAdminsOnly(false);
|
||||
setDirectRecipients('');
|
||||
setChunkedPublishing(false);
|
||||
onStatusChange(null);
|
||||
}, [defaults, open, onStatusChange]);
|
||||
|
||||
@@ -88,6 +92,27 @@ export function PublishDialog({
|
||||
[form.service]
|
||||
);
|
||||
|
||||
const hasOptionalChunkable = files.some((file) => file.size > MAX_INLINE_FILE_SIZE);
|
||||
const hasForcedChunking = files.some((file) => file.size > CHUNK_FORCED_THRESHOLD);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasForcedChunking) {
|
||||
setChunkedPublishing(true);
|
||||
}
|
||||
}, [hasForcedChunking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chunkedPublishing && encryptionMode !== 'group') {
|
||||
setEncryptionMode('group');
|
||||
}
|
||||
}, [chunkedPublishing, encryptionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (encryptionMode === 'none' && chunkedPublishing) {
|
||||
setChunkedPublishing(false);
|
||||
}
|
||||
}, [encryptionMode, chunkedPublishing]);
|
||||
|
||||
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextFiles = Array.from(event.target.files ?? []);
|
||||
setFiles(nextFiles);
|
||||
@@ -102,6 +127,7 @@ export function PublishDialog({
|
||||
groupId,
|
||||
groupAdminsOnly,
|
||||
directRecipients,
|
||||
chunkedPublishing,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -183,9 +209,13 @@ export function PublishDialog({
|
||||
value={encryptionMode}
|
||||
onChange={(_event, value) => value && setEncryptionMode(value)}
|
||||
>
|
||||
<ToggleButton value="none">None</ToggleButton>
|
||||
<ToggleButton value="none" disabled={chunkedPublishing}>
|
||||
None
|
||||
</ToggleButton>
|
||||
<ToggleButton value="group">Group</ToggleButton>
|
||||
<ToggleButton value="direct">Direct</ToggleButton>
|
||||
<ToggleButton value="direct" disabled={chunkedPublishing}>
|
||||
Direct
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{encryptionMode === 'group' && (
|
||||
<>
|
||||
@@ -227,9 +257,32 @@ export function PublishDialog({
|
||||
value={directRecipients}
|
||||
onChange={(event) => setDirectRecipients(event.target.value)}
|
||||
helperText="Direct encryption will use resolved public keys for the listed recipients."
|
||||
disabled={chunkedPublishing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{encryptionMode !== 'none' && hasOptionalChunkable && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={chunkedPublishing}
|
||||
onChange={(_event, checked) => setChunkedPublishing(checked)}
|
||||
disabled={hasForcedChunking}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
hasForcedChunking
|
||||
? 'Chunked upload required for files over 100MB'
|
||||
: 'Use chunked publishing for large files'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{chunkedPublishing && !groupId && (
|
||||
<Typography variant="caption" color="warning.main">
|
||||
Chunked publishing requires a private group and group encryption.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Button variant="outlined" component="label">
|
||||
{files.length ? 'Replace selection' : 'Select files'}
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
export const MAX_INLINE_FILE_SIZE = 25 * 1024 * 1024; // 25 MB for inlined files
|
||||
export const DEFAULT_CHUNK_SIZE = 25 * 1024 * 1024; // 25 MB per chunk (adjust if needed)
|
||||
export const CHUNK_FORCED_THRESHOLD = 100 * 1024 * 1024; // 100 MB enforce chunked
|
||||
|
||||
export interface FileChunkDescriptor {
|
||||
index: number;
|
||||
identifier: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ChunkedFileManifest {
|
||||
version: 1;
|
||||
fileName: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
chunkSize: number;
|
||||
chunks: FileChunkDescriptor[];
|
||||
encryption?: {
|
||||
mode: 'none' | 'group' | 'direct';
|
||||
groupId?: number;
|
||||
adminsOnly?: boolean;
|
||||
recipientCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function* iterateFileChunks(
|
||||
file: File,
|
||||
chunkSize: number = DEFAULT_CHUNK_SIZE
|
||||
): AsyncGenerator<{ index: number; uint8: Uint8Array; size: number }, void, void> {
|
||||
let offset = 0;
|
||||
let index = 0;
|
||||
while (offset < file.size) {
|
||||
const end = Math.min(offset + chunkSize, file.size);
|
||||
const slice = file.slice(offset, end);
|
||||
const buf = await slice.arrayBuffer();
|
||||
const uint8 = new Uint8Array(buf);
|
||||
yield { index, uint8, size: uint8.length };
|
||||
offset = end;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildChunkIdentifier(baseIdentifier: string, index: number): string {
|
||||
return `${baseIdentifier}__chunk__${String(index).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
export function createChunkedManifest(
|
||||
file: File,
|
||||
chunkSize: number,
|
||||
chunks: FileChunkDescriptor[],
|
||||
encryption?: ChunkedFileManifest['encryption']
|
||||
): ChunkedFileManifest {
|
||||
const mimeType = file.type || 'application/octet-stream';
|
||||
return {
|
||||
version: 1,
|
||||
fileName: file.name,
|
||||
size: file.size,
|
||||
mimeType,
|
||||
chunkSize,
|
||||
chunks,
|
||||
encryption,
|
||||
};
|
||||
}
|
||||
+93
-22
@@ -1,4 +1,5 @@
|
||||
import { Service } from 'qapp-core';
|
||||
import pLimit from 'p-limit';
|
||||
import { qaAnnouncementPrefix, assetNewsGlobalPrefix } from '../constants/qdnConstants';
|
||||
import { NewsSummary } from '../types/newsAndPromos';
|
||||
import { fetchPromotionApprovals } from './promotions';
|
||||
@@ -60,6 +61,7 @@ type FetchNewsOptions = {
|
||||
includeExpired?: boolean;
|
||||
forceFresh?: boolean;
|
||||
allowedGroupIds?: number[]; // membership list to gate private asset news
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const LIST_CACHE_MS = 60_000;
|
||||
@@ -71,6 +73,12 @@ export async function fetchAnnouncements(
|
||||
options?: FetchNewsOptions
|
||||
): Promise<NewsSummary[]> {
|
||||
try {
|
||||
const ensureNotAborted = () => {
|
||||
if (options?.signal?.aborted) {
|
||||
throw new DOMException('AbortError', 'AbortError');
|
||||
}
|
||||
};
|
||||
ensureNotAborted();
|
||||
const includeExpired = options?.includeExpired ?? false;
|
||||
const listKey = `ann:list:${includeExpired}:${limit}`;
|
||||
if (!options?.forceFresh) {
|
||||
@@ -81,8 +89,10 @@ export async function fetchAnnouncements(
|
||||
const expiryDays = Number(await getNewsPromoExpiryDays());
|
||||
const expiryCutoff =
|
||||
Number.isFinite(expiryDays) && expiryDays > 0 ? Date.now() - expiryDays * 86_400_000 : null;
|
||||
ensureNotAborted();
|
||||
const approvalDoc = await loadAnnouncementApprovalDoc();
|
||||
const approvedEntries = approvalDoc.items || [];
|
||||
ensureNotAborted();
|
||||
const items: NewsSummary[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -96,12 +106,14 @@ export async function fetchAnnouncements(
|
||||
createdHint?: number
|
||||
) => {
|
||||
try {
|
||||
ensureNotAborted();
|
||||
const svc = service || ('DOCUMENT' as Service);
|
||||
const cacheKey = `ann:item:${(publisher || '').toLowerCase()}:${svc}:${identifier}`;
|
||||
let payload: { html: string; title?: string; createdAt?: number } | null | undefined =
|
||||
getCached(cacheKey);
|
||||
|
||||
if (!payload) {
|
||||
ensureNotAborted();
|
||||
const res = await qortalRequest({
|
||||
action: 'FETCH_QDN_RESOURCE',
|
||||
name: publisher,
|
||||
@@ -109,10 +121,13 @@ export async function fetchAnnouncements(
|
||||
identifier,
|
||||
encoding: 'base64',
|
||||
});
|
||||
ensureNotAborted();
|
||||
payload = await decodeAnnouncementResource(res?.data64 ?? res);
|
||||
ensureNotAborted();
|
||||
if (payload) setCached(cacheKey, payload, ITEM_CACHE_MS);
|
||||
}
|
||||
|
||||
ensureNotAborted();
|
||||
if (!payload) return false;
|
||||
const html = payload.html;
|
||||
const title = payload.title || extractTitleFromHtml(html, 'Q-Assets Announcement');
|
||||
@@ -147,6 +162,7 @@ export async function fetchAnnouncements(
|
||||
.slice(0, limit * 2);
|
||||
|
||||
for (const entry of ordered) {
|
||||
ensureNotAborted();
|
||||
const dedupeKey = keyFor(entry.publisher, entry.identifier);
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
const createdHint = entry.approvedAt || entry.createdAt || Date.now();
|
||||
@@ -165,9 +181,11 @@ export async function fetchAnnouncements(
|
||||
|
||||
// Also surface admin-published announcements even if not explicitly approved
|
||||
let docHits: Awaited<ReturnType<typeof searchSimpleByIdentifierPrefix>> = [];
|
||||
ensureNotAborted();
|
||||
|
||||
try {
|
||||
const services = await getGroupResourceServices();
|
||||
ensureNotAborted();
|
||||
[docHits] = await Promise.all([
|
||||
searchSimpleByIdentifierPrefix(services, qaAnnouncementPrefix, limit * 2),
|
||||
]);
|
||||
@@ -191,6 +209,7 @@ export async function fetchAnnouncements(
|
||||
if (items.length >= limit * 2) break;
|
||||
}
|
||||
|
||||
ensureNotAborted();
|
||||
const finalList = items.sort((a, b) => b.created - a.created).slice(0, limit);
|
||||
setCached(listKey, finalList, LIST_CACHE_MS);
|
||||
return finalList;
|
||||
@@ -217,7 +236,19 @@ export async function fetchLatestAssetNews(
|
||||
limit = 10,
|
||||
options?: FetchNewsOptions
|
||||
): Promise<NewsSummary[]> {
|
||||
const isAbortError = (error: unknown) =>
|
||||
Boolean(
|
||||
error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError'
|
||||
);
|
||||
|
||||
try {
|
||||
const ensureNotAborted = () => {
|
||||
if (options?.signal?.aborted) {
|
||||
throw new DOMException('AbortError', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
ensureNotAborted();
|
||||
const includeExpired = options?.includeExpired ?? false;
|
||||
const allowedGroupIds = options?.allowedGroupIds ?? [];
|
||||
const listKey = `assetnews:list:${includeExpired}:${limit}:${allowedGroupIds
|
||||
@@ -232,24 +263,38 @@ export async function fetchLatestAssetNews(
|
||||
typeof expiryDays === 'number' && expiryDays > 0
|
||||
? Date.now() - expiryDays * 86_400_000
|
||||
: null;
|
||||
ensureNotAborted();
|
||||
let hits: Awaited<ReturnType<typeof searchSimpleByIdentifierPrefix>> = [];
|
||||
try {
|
||||
const services = await getGroupResourceServices();
|
||||
hits = await searchSimpleByIdentifierPrefix(services, assetNewsGlobalPrefix, limit);
|
||||
ensureNotAborted();
|
||||
[hits] = await Promise.all([
|
||||
searchSimpleByIdentifierPrefix(services, assetNewsGlobalPrefix, limit),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch asset news list', e);
|
||||
return [];
|
||||
}
|
||||
ensureNotAborted();
|
||||
if (!hits.length) return [];
|
||||
|
||||
const items: NewsSummary[] = [];
|
||||
const seen = new Set<string>();
|
||||
const privacyCache = new Map<number, { isPrivate: boolean; groupId?: number }>();
|
||||
const dedupedHits: typeof hits = [];
|
||||
const seenIds = new Set<string>();
|
||||
for (const hit of hits) {
|
||||
ensureNotAborted();
|
||||
const dedupeKey = `${hit.name}::${hit.identifier}`;
|
||||
if (seenIds.has(dedupeKey)) continue;
|
||||
seenIds.add(dedupeKey);
|
||||
dedupedHits.push(hit);
|
||||
}
|
||||
|
||||
const privacyCache = new Map<number, { isPrivate: boolean; groupId?: number }>();
|
||||
const getPrivacy = async (assetId: number) => {
|
||||
ensureNotAborted();
|
||||
if (privacyCache.has(assetId)) return privacyCache.get(assetId)!;
|
||||
try {
|
||||
const { publication } = await resolveAssetPublicationById(assetId);
|
||||
ensureNotAborted();
|
||||
const groupIdRaw = publication?.privateGroupId ?? publication?.primaryGroup?.id;
|
||||
const groupIdNum = groupIdRaw != null ? Number(groupIdRaw) : undefined;
|
||||
const info = {
|
||||
@@ -265,16 +310,17 @@ export async function fetchLatestAssetNews(
|
||||
}
|
||||
};
|
||||
|
||||
for (const hit of hits) {
|
||||
const dedupeKey = `${hit.name}::${hit.identifier}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
const finalService = hit.service ? (hit.service as Service) : ('DOCUMENT' as Service);
|
||||
const limiter = pLimit(3);
|
||||
const tasks = dedupedHits.map((hit) =>
|
||||
limiter(async () => {
|
||||
ensureNotAborted();
|
||||
try {
|
||||
const finalService = hit.service ? (hit.service as Service) : ('DOCUMENT' as Service);
|
||||
const payloadKey = `assetnews:item:${hit.name.toLowerCase()}:${finalService}:${hit.identifier}`;
|
||||
let payload = getCached<{ html: string; title?: string; createdAt?: number }>(payloadKey);
|
||||
|
||||
if (!payload) {
|
||||
ensureNotAborted();
|
||||
const res = await qortalRequest({
|
||||
action: 'FETCH_QDN_RESOURCE',
|
||||
name: hit.name,
|
||||
@@ -282,7 +328,7 @@ export async function fetchLatestAssetNews(
|
||||
identifier: hit.identifier,
|
||||
encoding: 'base64',
|
||||
});
|
||||
|
||||
ensureNotAborted();
|
||||
const raw = res?.data64 ?? res;
|
||||
let html = '';
|
||||
let title: string | undefined;
|
||||
@@ -310,12 +356,15 @@ export async function fetchLatestAssetNews(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!html) console.log('wtfnohtml', html);
|
||||
if (!html) continue;
|
||||
if (!html) {
|
||||
console.log('wtfnohtml', html);
|
||||
return null;
|
||||
}
|
||||
payload = { html, title, createdAt };
|
||||
setCached(payloadKey, payload, ITEM_CACHE_MS);
|
||||
}
|
||||
|
||||
ensureNotAborted();
|
||||
const html = payload.html;
|
||||
let title = payload.title;
|
||||
const createdAt = payload.createdAt;
|
||||
@@ -323,7 +372,6 @@ export async function fetchLatestAssetNews(
|
||||
const text = stripHtml(html);
|
||||
const excerpt = text.slice(0, 220) + (text.length > 220 ? '…' : '');
|
||||
|
||||
// Try to derive assetId from identifier: asset_news_pub__<assetId>__<id6>
|
||||
let assetId: number | undefined;
|
||||
const m = hit.identifier.match(/^asset_news_pub__([0-9]+)__/);
|
||||
if (m && m[1]) {
|
||||
@@ -332,12 +380,12 @@ 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);
|
||||
ensureNotAborted();
|
||||
if (privacy.isPrivate) {
|
||||
if (!privacy.groupId) continue; // cannot authorize, skip
|
||||
if (!allowedGroupIds.includes(privacy.groupId)) continue;
|
||||
if (!privacy.groupId) return null;
|
||||
if (!allowedGroupIds.includes(privacy.groupId)) return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,13 +393,12 @@ export async function fetchLatestAssetNews(
|
||||
html,
|
||||
assetId != null ? `News for ${assetName}` : 'Asset news'
|
||||
);
|
||||
// const finalTitle = typeof title === 'string' && title.trim() ? title : titleExtracted;
|
||||
|
||||
const created = createdAt || hit.updated || hit.created || Date.now();
|
||||
const isExpired = expiryCutoff != null && created < expiryCutoff;
|
||||
if (!includeExpired && isExpired) continue;
|
||||
if (!includeExpired && isExpired) return null;
|
||||
|
||||
items.push({
|
||||
return {
|
||||
type: 'assetNews',
|
||||
identifier: hit.identifier,
|
||||
title,
|
||||
@@ -363,27 +410,50 @@ export async function fetchLatestAssetNews(
|
||||
fullHtml: html,
|
||||
publisherName: hit.name,
|
||||
service: finalService,
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) throw err;
|
||||
console.warn('Failed to fetch asset news item', err);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
const items: NewsSummary[] = [];
|
||||
for (const entry of results) {
|
||||
if (entry) items.push(entry);
|
||||
}
|
||||
|
||||
// Sort latest first and trim to limit
|
||||
ensureNotAborted();
|
||||
const finalList = items.sort((a, b) => b.created - a.created).slice(0, limit);
|
||||
setCached(listKey, finalList, LIST_CACHE_MS);
|
||||
return finalList;
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) {
|
||||
throw e;
|
||||
}
|
||||
console.warn('fetchLatestAssetNews failed', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchActivePromotions(now = Date.now()): Promise<NewsSummary[]> {
|
||||
export async function fetchActivePromotions(
|
||||
now = Date.now(),
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<NewsSummary[]> {
|
||||
const ensureNotAborted = () => {
|
||||
if (options?.signal?.aborted) {
|
||||
throw new DOMException('AbortError', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
ensureNotAborted();
|
||||
const approvals = await fetchPromotionApprovals(120);
|
||||
const promos: NewsSummary[] = [];
|
||||
|
||||
for (const promo of approvals) {
|
||||
ensureNotAborted();
|
||||
if (!promo.contentHtml) continue;
|
||||
if (!promo.isActive) continue;
|
||||
if (now < promo.startsAt || now > promo.endsAt) continue;
|
||||
@@ -406,5 +476,6 @@ export async function fetchActivePromotions(now = Date.now()): Promise<NewsSumma
|
||||
});
|
||||
}
|
||||
|
||||
ensureNotAborted();
|
||||
return promos.sort((a, b) => (b.promotionEndsAt ?? 0) - (a.promotionEndsAt ?? 0));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user