/** * Forward a feed post into a direct chat as a "post reference" message. * * What the receiver sees * ---------------------- * A special chat bubble rendering a compact card: * [avatar] @author "post excerpt…" [📷 if has image] * * Tapping the card opens the full post detail. Design is modelled on * VK/Twitter's "shared post" embed: visually distinct from a plain * message so the user sees at a glance that this came from the feed, * not from the sender directly. * * Transport * --------- * Same encrypted envelope as a normal chat message. The payload is * plaintext JSON with a discriminator: * * { "kind": "post_ref", "post_id": "...", "author": "...", * "excerpt": "first 120 chars of body", "has_image": true } * * The receiver's useMessages / useGlobalInbox hooks detect the JSON * shape after decryption and assign it to Message.postRef for rendering. * Plain-text messages stay wrapped in the same envelope format — the * only difference is whether the decrypted body parses as our JSON * schema. */ import { encryptMessage } from './crypto'; import { sendEnvelope } from './api'; import { appendMessage } from './storage'; import { useStore } from './store'; import { randomId } from './utils'; import type { Contact, Message } from './types'; import type { FeedPostItem } from './feed'; const POST_REF_MARKER = 'dchain-post-ref'; const EXCERPT_MAX = 120; export interface PostRefPayload { kind: typeof POST_REF_MARKER; post_id: string; author: string; excerpt: string; has_image: boolean; } /** Serialise a post ref for the wire. */ export function encodePostRef(post: FeedPostItem): string { const payload: PostRefPayload = { kind: POST_REF_MARKER, post_id: post.post_id, author: post.author, excerpt: truncate(post.content, EXCERPT_MAX), has_image: !!post.has_attachment, }; return JSON.stringify(payload); } /** * Try to parse an incoming plaintext message as a post reference. * Returns null if the payload isn't the expected shape — the caller * then treats it as a normal text message. */ export function tryParsePostRef(plaintext: string): PostRefPayload | null { const trimmed = plaintext.trim(); if (!trimmed.startsWith('{')) return null; try { const parsed = JSON.parse(trimmed) as Partial; if (parsed.kind !== POST_REF_MARKER) return null; if (!parsed.post_id || !parsed.author) return null; return { kind: POST_REF_MARKER, post_id: String(parsed.post_id), author: String(parsed.author), excerpt: String(parsed.excerpt ?? ''), has_image: !!parsed.has_image, }; } catch { return null; } } /** * Forward `post` to each of the given contacts as a post-ref message. * Creates a fresh envelope per recipient (can't fan-out a single * ciphertext — each recipient's x25519 key seals differently) and * drops a mirrored Message into our local chat history so the user * sees the share in their own view too. * * Contacts without an x25519 public key are skipped with a warning * instead of failing the whole batch. */ export async function forwardPostToContacts(params: { post: FeedPostItem; contacts: Contact[]; keyFile: { pub_key: string; priv_key: string; x25519_pub: string; x25519_priv: string }; }): Promise<{ ok: number; failed: number }> { const { post, contacts, keyFile } = params; const appendMsg = useStore.getState().appendMessage; const body = encodePostRef(post); let ok = 0, failed = 0; for (const c of contacts) { if (!c.x25519Pub) { failed++; continue; } try { const { nonce, ciphertext } = encryptMessage( body, keyFile.x25519_priv, c.x25519Pub, ); await sendEnvelope({ senderPub: keyFile.x25519_pub, recipientPub: c.x25519Pub, senderEd25519Pub: keyFile.pub_key, nonce, ciphertext, }); // Mirror into local history so the sender sees "you shared this". const mirrored: Message = { id: randomId(), from: keyFile.x25519_pub, text: '', // postRef carries all the content timestamp: Math.floor(Date.now() / 1000), mine: true, postRef: { postID: post.post_id, author: post.author, excerpt: truncate(post.content, EXCERPT_MAX), hasImage: !!post.has_attachment, }, }; appendMsg(c.address, mirrored); await appendMessage(c.address, mirrored); ok++; } catch { failed++; } } return { ok, failed }; } function truncate(s: string, n: number): string { if (!s) return ''; if (s.length <= n) return s; return s.slice(0, n).trimEnd() + '…'; }