cautpartener / client / src / core / chatlog.ts
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
/**
 * Chat messages over a RoomLog (M3, pair + group rooms alike).
 *
 * Message ops:
 *   { t:"msg",   ts, body }                          text ≤ 4000 UTF-16 units
 *   { t:"img",   ts, thumb, full, w, h, mime }       image: two encrypted
 *       unixfs blobs (lib log.putImage CIDs) — thumb inline, full on tap
 *   { t:"voice", ts, blob, dur, mime }               voice: one encrypted blob
 *       (same putImage path — it seals arbitrary bytes), dur in seconds ≤ 60
 *   { t:"call",  ts, kind:"missed"|"ended", dur? }   P4 call marker, appended
 *       by the CALLER — renders as a system line in the timeline (an offline
 *       peer sees a missed call via normal mailbox replay)
 * Blob bytes ride the lib RoomLog mechanism end to end: putImage seals +
 * chunks them at imageChunkBytes, onImageStored feeds the mailbox queue and
 * bitswap serves live peers; receivers fetch via log.getImage(cid). Received
 * media roots are pinned once local (pinImageIfLocal) — a mailbox-delivered
 * blob may have no online source left — and unpinned again (lib pruneImages,
 * batched) when their message falls out of the MAX_MESSAGES fold window.
 *
 * Author identity comes from the lib entry binding (LogEntryMeta.from — the entry's
 * signing key must match its referenced identity record, lib/log.ts
 * #authorOf + the hardened access controller), same approach as the selfroom
 * fold but accepting ALL room members' entries:
 *   - pair rooms:  either of the two identity pubs;
 *   - group rooms: any pub in the chatreg member set.
 * Entries whose verified author is not in the allowed set (or "" = failed
 * binding) are DROPPED from the fold — possession of the room secret alone
 * does not get a message rendered.
 *
 * Ordering: (ts, entry-hash) — deterministic on every replica; ts clamped to
 * [0, now + 2 min] so a bogus timestamp can't pin a message forever. Day
 * separators are a render-time concern (app/views/chat-view.ts).
 *
 * Delivery/read receipts: OUT of scope v1. Deletion: LOCAL HIDE only —
 * honest v1 semantics (an append-only replicated log has no retraction; we
 * only stop showing it on this device).
 *
 * Conversation-list metadata (previews / last-seen / unread) persists in
 * localStorage per conversation id, so the list renders without opening any
 * room. Plaintext previews on device-local storage match the app's threat
 * model (the identity seed lives beside them).
 *
 * "Has new mail" probe for rooms that are NOT open: opening an OrbitDB store
 * per conversation just to check for news would blow the room budget, so the
 * probe asks the room's MAILBOX for its latest seq (one replay page,
 * limit=1 — the page header carries latest_seq) and compares it against
 *   (a) the room's persisted replay cursor  (nsKey mbx-cursor:<roomId>) and
 *   (b) this device's last accepted deposit (nsKey mbx-lastdep:<roomId>,
 *       recorded by TruthfulMailboxClient in core/hub.ts);
 * newer than both ⇒ someone ELSE deposited since we last synced ⇒ unread.
 * Honest limitation: a peer deposit sandwiched between two of ours (while
 * this room was open and replaying anyway) can read as "seen" — the full
 * replay on room open corrects it.
 */

import { APP_SALT, nsKey } from "../config";
import { RoomCrypto } from "../lib/crypto";
import type { MailboxConfig } from "../lib/descriptor";
import type { LogEntryMeta } from "../lib/log";
import { MailboxClient } from "../lib/mailbox";
import { appMailboxAuth } from "./app-mailbox-auth";
import { seqGt, type RoomHandle } from "./hub";
import { VOICE_MAX_SEC, type EncodedImage, type VoiceClip } from "./media";

export const MSG_MAX = 4000;
const PREVIEW_MAX = 120;
const MAX_MESSAGES = 1000;
const CHAT_TAIL = 500; // local-log tail replayed when a chat room opens

export interface MsgOp {
  t: "msg";
  ts: number;
  body: string;
}

export interface ImgOp {
  t: "img";
  ts: number;
  thumb: string; // lib log blob CID (encrypted unixfs root)
  full: string;
  w: number; // display pixel size — the bubble reserves the aspect box
  h: number;
  mime: string;
}

export interface VoiceOp {
  t: "voice";
  ts: number;
  blob: string; // lib log blob CID
  dur: number; // seconds, 1..60
  mime: string;
}

export interface ChatImage {
  thumb: string;
  full: string;
  w: number;
  h: number;
  mime: string;
}

export interface ChatVoice {
  blob: string;
  dur: number;
  mime: string;
}

export interface ChatCall {
  kind: "missed" | "ended";
  /** Seconds; 0 = unknown/none (missed calls, ended-before-connect). */
  dur: number;
}

export interface ChatMessage {
  /** Log entry hash — stable id on every replica. */
  id: string;
  /** Verified author (b64url Ed25519 pub). Never "" — those are dropped. */
  from: string;
  ts: number;
  kind: "text" | "img" | "voice" | "call";
  /** Text body; "" for media messages. */
  body: string;
  img?: ChatImage;
  voice?: ChatVoice;
  call?: ChatCall;
  mine: boolean;
}

export interface ConvRef {
  kind: "pair" | "group" | "match";
  /** Pair: the peer's pub. Group: the chatreg id. Match: "m~<peerPub>" (P3). */
  id: string;
}

// ---- per-conversation localStorage metadata ---------------------------------

export interface ConvPreview {
  ts: number;
  body: string;
  from: string;
}

const previewKey = (convId: string): string => nsKey(`chat-preview:${convId}`);
const seenKey = (convId: string): string => nsKey(`chat-seen:${convId}`);
const hiddenKey = (convId: string): string => nsKey(`chat-hidden:${convId}`);

export function getPreview(convId: string): ConvPreview | null {
  try {
    const raw = localStorage.getItem(previewKey(convId));
    if (!raw) return null;
    const p = JSON.parse(raw) as Partial<ConvPreview>;
    if (typeof p.ts !== "number" || typeof p.body !== "string" || typeof p.from !== "string") return null;
    return { ts: p.ts, body: p.body, from: p.from };
  } catch {
    return null;
  }
}

function setPreviewIfNewer(convId: string, p: ConvPreview): void {
  const prev = getPreview(convId);
  if (prev && prev.ts >= p.ts) return;
  try {
    localStorage.setItem(previewKey(convId), JSON.stringify(p));
  } catch {}
}

export function getSeenTs(convId: string): number {
  try {
    const n = Number(localStorage.getItem(seenKey(convId)) ?? 0);
    return Number.isFinite(n) ? n : 0;
  } catch {
    return 0;
  }
}

export function markSeen(convId: string, ts: number): void {
  if (ts <= getSeenTs(convId)) return;
  try {
    localStorage.setItem(seenKey(convId), String(ts));
  } catch {}
}

function loadHidden(convId: string): string[] {
  try {
    const list = JSON.parse(localStorage.getItem(hiddenKey(convId)) ?? "[]") as unknown;
    return Array.isArray(list) ? list.filter((x): x is string => typeof x === "string").slice(0, 500) : [];
  } catch {
    return [];
  }
}

// ---- the fold ----------------------------------------------------------------

/**
 * Timestamps come from other members and order the list — clamp to
 * [0, now + 2 min of skew] so a bogus value can't pin a message forever.
 */
function clampTs(ts: unknown): number {
  const now = Date.now();
  if (typeof ts !== "number" || !Number.isFinite(ts)) return now;
  return Math.min(Math.max(ts, 0), now + 120_000);
}

/** CIDv1 base32 / CIDv0 base58 shape — enough to refuse junk before parse. */
const isCidStr = (s: unknown): s is string =>
  typeof s === "string" && s.length >= 32 && s.length <= 128 && /^[A-Za-z0-9]+$/.test(s);

const clampDim = (n: unknown): number => (typeof n === "number" && n > 0 && n <= 8192 ? Math.round(n) : 1);

const IMG_MIMES = new Set(["image/webp", "image/jpeg"]);

/** audio/<subtype>, short, no parameters — receivers feed it to Blob/Audio. */
const safeVoiceMime = (m: unknown): string =>
  typeof m === "string" && /^audio\/[a-z0-9.+-]{1,30}$/.test(m) ? m : "audio/webm";

/** Conversation-list preview line for a message (language-neutral glyphs). */
const previewOf = (m: ChatMessage): string =>
  m.kind === "img" ? "📷" : m.kind === "voice" ? "🎙️" : m.kind === "call" ? "📞" : m.body.slice(0, PREVIEW_MAX);

export class ChatRoom {
  readonly conv: ConvRef;
  messages: ChatMessage[] = [];
  #byId = new Map<string, ChatMessage>();
  #hidden: Set<string>;
  #myPub: string;
  #authorized: ReadonlySet<string>;
  #handle: RoomHandle | null = null;
  #pendingPins: string[] = []; // media roots folded before the handle attached
  #pendingPrune = new Set<string>(); // media roots of messages aged out of the window
  #pruneTimer: ReturnType<typeof setTimeout> | null = null;

  /** UI re-render hook (fires after any fold change). */
  onChange: () => void = () => {};
  /** Non-msg ops (pairwise control channel: ginvite/grant…) — §5. */
  onControl: (e: LogEntryMeta) => void = () => {};

  constructor(conv: ConvRef, myPub: string, authorized: ReadonlySet<string>) {
    this.conv = conv;
    this.#myPub = myPub;
    this.#authorized = authorized;
    this.#hidden = new Set(loadHidden(conv.id));
  }

  /** The hub handle, once open (ChatService wires it right after openRoom). */
  get handle(): RoomHandle | null {
    return this.#handle;
  }

  async attach(handle: RoomHandle): Promise<void> {
    this.#handle = handle;
    await handle.loadLocal(CHAT_TAIL);
    for (const cid of this.#pendingPins.splice(0)) this.#pin(cid);
    if (this.#pendingPrune.size > 0) this.#flushPrune(); // aged out before attach
    handle.startSync();
  }

  /**
   * Keep a received media blob alive: pin once its DAG is fully local (a
   * mailbox-delivered blob may have NO online source left; without the pin a
   * future gc would eat in-window blocks). No-op while blocks are remote —
   * bitswap-fetched copies can always be re-fetched from members.
   */
  #pin(cid: string): void {
    if (!this.#handle) {
      this.#pendingPins.push(cid);
      return;
    }
    void this.#handle.log.pinImageIfLocal(cid).catch(() => {});
  }

  /**
   * The counterpart of #pin: media whose message aged out of the
   * MAX_MESSAGES fold window must not stay pinned forever. Batched (one
   * debounced lib pruneImages call — it runs a GC sweep) with a keep-set of
   * every root still referenced by the in-window messages, so a blob shared
   * with a live message is never unpinned.
   */
  #unpinLater(cids: string[]): void {
    for (const c of cids) this.#pendingPrune.add(c);
    this.#pruneTimer ??= setTimeout(() => {
      this.#pruneTimer = null;
      this.#flushPrune();
    }, 2000);
  }

  #flushPrune(): void {
    if (!this.#handle || this.#pendingPrune.size === 0) return; // retried on attach
    const keep = new Set<string>();
    for (const m of this.messages) {
      if (m.img) {
        keep.add(m.img.thumb);
        keep.add(m.img.full);
      }
      if (m.voice) keep.add(m.voice.blob);
    }
    const dropped = [...this.#pendingPrune].filter((c) => !keep.has(c));
    this.#pendingPrune.clear();
    if (dropped.length === 0) return;
    void this.#handle.log.pruneImages(keep, dropped).catch(() => {});
  }

  /** Visible (non-hidden) messages in (ts, hash) order. */
  get visible(): ChatMessage[] {
    return this.#hidden.size === 0 ? this.messages : this.messages.filter((m) => !this.#hidden.has(m.id));
  }

  get newest(): ChatMessage | null {
    const v = this.visible;
    return v.length > 0 ? v[v.length - 1] : null;
  }

  /** Fold one verified log entry (wired as the room's onEntry hook). */
  applyEntry(e: LogEntryMeta): void {
    // multi-author authorization: verified author must be a room member
    if (!e.from || !this.#authorized.has(e.from)) return;
    const op = e.op as unknown as { t?: string };
    const msg = this.#toMessage(e, op);
    if (msg === null) {
      if (typeof op.t === "string" && op.t !== "msg" && op.t !== "img" && op.t !== "voice") this.onControl(e);
      return;
    }
    if (this.#byId.has(e.hash)) return;
    this.#byId.set(msg.id, msg);
    // stable insert by (ts, id) — deterministic on every replica
    let i = this.messages.length;
    while (i > 0) {
      const prev = this.messages[i - 1];
      if (prev.ts < msg.ts || (prev.ts === msg.ts && prev.id < msg.id)) break;
      i--;
    }
    this.messages.splice(i, 0, msg);
    if (this.messages.length > MAX_MESSAGES) {
      const dropped = this.messages.shift()!;
      this.#byId.delete(dropped.id);
      // unpin the aged-out message's media (batched; keep-set guards sharing)
      if (dropped.img) this.#unpinLater([dropped.img.thumb, dropped.img.full]);
      if (dropped.voice) this.#unpinLater([dropped.voice.blob]);
    }
    // keep received media alive on this device (see #pin)
    if (msg.img) {
      this.#pin(msg.img.thumb);
      this.#pin(msg.img.full);
    }
    if (msg.voice) this.#pin(msg.voice.blob);
    // conversation-list preview: newest visible message wins
    const newest = this.newest;
    if (newest) setPreviewIfNewer(this.conv.id, { ts: newest.ts, body: previewOf(newest), from: newest.from });
    try {
      this.onChange();
    } catch {}
  }

  /** Validate + clamp one wire op into a ChatMessage; null = not a message. */
  #toMessage(e: LogEntryMeta, op: { t?: string }): ChatMessage | null {
    const base = { id: e.hash, from: e.from, ts: 0, body: "", mine: e.from === this.#myPub };
    if (op.t === "msg") {
      const m = op as Partial<MsgOp>;
      if (typeof m.body !== "string" || m.body.length === 0) return null;
      return { ...base, kind: "text", ts: clampTs(m.ts), body: m.body.slice(0, MSG_MAX) };
    }
    if (op.t === "img") {
      const m = op as Partial<ImgOp>;
      if (!isCidStr(m.thumb) || !isCidStr(m.full)) return null;
      const mime = IMG_MIMES.has(m.mime as string) ? (m.mime as string) : "image/jpeg";
      return {
        ...base,
        kind: "img",
        ts: clampTs(m.ts),
        img: { thumb: m.thumb, full: m.full, w: clampDim(m.w), h: clampDim(m.h), mime },
      };
    }
    if (op.t === "voice") {
      const m = op as Partial<VoiceOp>;
      if (!isCidStr(m.blob)) return null;
      const dur =
        typeof m.dur === "number" && Number.isFinite(m.dur)
          ? Math.min(Math.max(Math.round(m.dur), 1), VOICE_MAX_SEC)
          : 1;
      return { ...base, kind: "voice", ts: clampTs(m.ts), voice: { blob: m.blob, dur, mime: safeVoiceMime(m.mime) } };
    }
    if (op.t === "call") {
      const m = op as Partial<{ t: "call"; ts: number; kind: string; dur: number }>;
      if (m.kind !== "missed" && m.kind !== "ended") return null;
      const dur =
        typeof m.dur === "number" && Number.isFinite(m.dur) ? Math.min(Math.max(Math.round(m.dur), 0), 86_400) : 0;
      return { ...base, kind: "call", ts: clampTs(m.ts), call: { kind: m.kind, dur } };
    }
    return null;
  }

  /** Append + (via the hub wiring) deposit a text message. */
  async send(text: string): Promise<void> {
    const body = text.trim().slice(0, MSG_MAX);
    if (!body || !this.#handle) return;
    const op: MsgOp = { t: "msg", ts: Date.now(), body };
    await this.#handle.append(op);
  }

  /**
   * Store the encoded pair as sealed blobs (lib putImage: encrypt, chunk to
   * mailbox-frame size, enqueue for deposit) and append the img op. The CIDs
   * in the op are all receivers need — getImage() fetches local/bitswap and
   * the mailbox path ships the raw blocks alongside the entry.
   */
  async sendImage(enc: EncodedImage): Promise<void> {
    if (!this.#handle) return;
    const thumb = await this.#handle.log.putImage(enc.thumb);
    const full = await this.#handle.log.putImage(enc.display);
    const op: ImgOp = { t: "img", ts: Date.now(), thumb: thumb.cid, full: full.cid, w: enc.w, h: enc.h, mime: enc.mime };
    await this.#handle.append(op);
  }

  /** Voice clip: one sealed blob through the same lib path + the voice op. */
  async sendVoice(clip: VoiceClip): Promise<void> {
    if (!this.#handle) return;
    const blob = await this.#handle.log.putImage(clip.bytes);
    const op: VoiceOp = { t: "voice", ts: Date.now(), blob: blob.cid, dur: clip.dur, mime: clip.mime };
    await this.#handle.append(op);
  }

  /** LOCAL hide (honest v1 deletion — this device only). */
  hideLocally(id: string): void {
    if (!this.#byId.has(id) || this.#hidden.has(id)) return;
    this.#hidden.add(id);
    try {
      localStorage.setItem(hiddenKey(this.conv.id), JSON.stringify([...this.#hidden]));
    } catch {}
    const newest = this.newest;
    if (newest) setPreviewIfNewer(this.conv.id, { ts: newest.ts, body: previewOf(newest), from: newest.from });
    try {
      this.onChange();
    } catch {}
  }

  /** Mark everything currently folded as read (list unread-dot bookkeeping). */
  markAllSeen(): void {
    markSeen(this.conv.id, Math.max(Date.now(), this.newest?.ts ?? 0));
  }
}

// ---- cheap "has new mail" probe (no OrbitDB store) ---------------------------

/**
 * Reusable probe for ONE room: the expensive HKDF room-id derivation happens
 * in create() (the bearer is the app-scoped token shared app-wide —
 * core/app-mailbox-auth.ts); check() is then a single replay(limit=1)
 * round-trip. ChatService caches these across its 30 s probe passes instead
 * of re-deriving for every closed conversation every cycle.
 */
export class MailboxProbe {
  #client: MailboxClient;
  #cursorKey: string;
  #lastDepKey: string;

  private constructor(client: MailboxClient, cursorKey: string, lastDepKey: string) {
    this.#client = client;
    this.#cursorKey = cursorKey;
    this.#lastDepKey = lastDepKey;
  }

  static async create(secret: string, mailbox: MailboxConfig): Promise<MailboxProbe> {
    const rc = await RoomCrypto.create(secret, APP_SALT);
    const mbxRoomId = await rc.mailboxRoomId();
    const client = new MailboxClient(mailbox.baseUrl, mbxRoomId, appMailboxAuth(mailbox.credsUrl));
    return new MailboxProbe(client, nsKey(`mbx-cursor:${rc.roomId}`), nsKey(`mbx-lastdep:${rc.roomId}`));
  }

  /** See the module docstring for the comparison contract. Best-effort: any
   * failure (offline, auth) reports false — the dot lights on the next probe. */
  async check(): Promise<boolean> {
    try {
      let cursor: string | null = null;
      let lastDep: string | null = null;
      try {
        cursor = localStorage.getItem(this.#cursorKey);
        lastDep = localStorage.getItem(this.#lastDepKey);
      } catch {}
      const r = await this.#client.replay(cursor, 1);
      if (!r.ok) return false;
      const latest = r.page.latestSeq;
      if (!latest) return false; // empty room
      return seqGt(latest, cursor) && seqGt(latest, lastDep);
    } catch {
      return false;
    }
  }
}

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git