From 915569db39fd15fe6c61527f94af77e93b154641 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 18:12:15 +0200 Subject: [PATCH] feat(shared): mark_attachment_viewed + view_once on AttachmentHandle --- packages/shared/src/chat/attachments.ts | 11 +++++++ packages/shared/src/chat/index.ts | 1 + .../shared/src/chat/viewOnceAttachments.ts | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 packages/shared/src/chat/viewOnceAttachments.ts diff --git a/packages/shared/src/chat/attachments.ts b/packages/shared/src/chat/attachments.ts index 164334d..50623a7 100644 --- a/packages/shared/src/chat/attachments.ts +++ b/packages/shared/src/chat/attachments.ts @@ -25,6 +25,16 @@ export interface AttachmentHandle { // base64-encoded — only readable via per-device envelope decrypt. keyB64: string; nonceB64: string; + /** View-once flag — set by sender on the encrypted envelope and mirrored + * to `message_attachments.view_once` so the renderer can show a "tap to + * view" tombstone and call the mark-viewed RPC on first open. */ + viewOnce?: boolean; + /** ISO timestamp the first non-sender opened the attachment. Populated by + * the mark-viewed RPC (server-authoritative); undefined until burnt. */ + viewedAt?: string | null; + /** UUID of the first non-sender who viewed. Populated alongside `viewedAt` + * by the mark-viewed RPC so the renderer can render attribution. */ + viewedBy?: string | null; } export type CallEventStatus = 'ended' | 'missed' | 'declined'; @@ -256,6 +266,7 @@ export async function insertAttachmentRow( nonce: blobNonceHex, mime_type: handle.mimeType, size_bytes: handle.sizeBytes, + view_once: handle.viewOnce ?? false, }; if (handle.width !== undefined) row.width = handle.width; if (handle.height !== undefined) row.height = handle.height; diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index 5ca0053..747b55e 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -9,6 +9,7 @@ export * from './types'; export * from './userKeyMigration'; export * from './pinnedMessages'; export * from './mentions'; +export * from './viewOnceAttachments'; // ----- RPC wrappers --------------------------------------------------------- diff --git a/packages/shared/src/chat/viewOnceAttachments.ts b/packages/shared/src/chat/viewOnceAttachments.ts new file mode 100644 index 0000000..085b3f5 --- /dev/null +++ b/packages/shared/src/chat/viewOnceAttachments.ts @@ -0,0 +1,29 @@ +import type { AppSupabaseClient } from '../supabase/client'; + +export interface ViewOnceResult { + viewOnce: boolean; + /** ISO timestamp set the moment the first non-sender viewed. */ + viewedAt?: string | null; + /** True iff the caller is the original sender (they don't burn the view). */ + self?: boolean; + /** True iff someone else already viewed before this call. */ + already?: boolean; +} + +export async function markAttachmentViewed( + client: AppSupabaseClient, + attachmentId: string, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (client as any).rpc('mark_attachment_viewed', { + p_attachment_id: attachmentId, + }); + if (error) throw error; + const d = (data ?? {}) as Record; + return { + viewOnce: Boolean(d.view_once), + viewedAt: typeof d.viewed_at === 'string' ? d.viewed_at : null, + self: Boolean(d.self), + already: Boolean(d.already), + }; +}