cautpartener / client / src / public / comment-service.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
/**
 * Public profile comments (docs/PROTOCOL.md §3 comment docs, §6 contracts).
 *
 * list(): backward replay of the target's commentsRoom, §3 verification
 * (kind=comment, to=target), then render policy: owner mod doc (hidden cids +
 * blocked authors), commentPolicy, and the §6 render cap of ≤10 comments per
 * author per profile per day. Order (ts, cid) ascending. Budget honesty: a
 * walk that hits the page budget reports `truncated` so the UI can say
 * "history truncated" instead of silently showing a partial list.
 *
 * Moderation is non-retractive (§7): hiding republishes a bumped mod doc and
 * the OWNER simply never re-seeds hidden content (§6) — the bytes other
 * clients already hold stay wherever they are.
 */

import { Session } from "../core/session";
import type { Bytes } from "../lib/crypto";
import { parseEnvelope, publicRoom, ENV_DOC } from "./mailbox-io";
import { sanitizeText, signDoc, verifyDoc, COMMENT_MAX, type CommentDoc } from "./public-doc";
import { commentsRoom } from "./public-rooms";

/** §6 render cap: at most this many comments per author per profile per day. */
export const COMMENTS_PER_AUTHOR_DAY = 10;
/** Owner-side re-seed cap per visit (§6: ≤10 deposits/room/visit). */
const RESEED_MAX = 10;

export interface PublicComment {
  cidStr: string;
  pub: string; // author
  ts: number;
  text: string;
  replyTo: string | null;
  /** The raw envelope bytes — what the owner re-seeds for visible comments. */
  raw: Bytes;
}

export interface CommentList {
  state: "ok" | "net";
  /** Visible comments, (ts, cid) ascending, caps + moderation applied. */
  comments: PublicComment[];
  /** Page budget hit before the room's oldest message — history truncated. */
  truncated: boolean;
}

export interface ModView {
  hidden: Set<string>;
  blocked: Set<string>;
}

/**
 * Replay + verify + moderate the comments on `targetPub`'s profile. `mod`
 * comes from the resolved profile (owner's mod doc); local extra filtering
 * (e.g. the viewer's own blocklist) can be layered by the caller.
 */
export async function listComments(targetPub: string, mod: ModView): Promise<CommentList> {
  let roomId: string;
  try {
    roomId = await commentsRoom(targetPub);
  } catch {
    return { state: "ok", comments: [], truncated: false };
  }
  const room = publicRoom(roomId);
  if (!room) return { state: "net", comments: [], truncated: false };

  const byCid = new Map<string, PublicComment>();
  const walk = await room.walkBackward(async (m) => {
    const env = parseEnvelope(m.payload);
    if (!env || env.type !== ENV_DOC) return;
    if (byCid.has(env.cid.toString())) return;
    const v = await verifyDoc(env.body, { kinds: ["comment"], to: targetPub, cid: env.cid });
    if (!v) return;
    const d = v.doc as CommentDoc;
    byCid.set(v.cidStr, {
      cidStr: v.cidStr,
      pub: v.pub,
      ts: d.ts,
      text: d.text,
      replyTo: d.replyTo ?? null,
      raw: env.raw,
    });
  });

  if (!walk.reached) return { state: "net", comments: [], truncated: false };

  // order (ts, cid) ascending, then apply the per-author-per-day render cap
  const ordered = [...byCid.values()].sort((a, b) => a.ts - b.ts || (a.cidStr < b.cidStr ? -1 : 1));
  const perAuthorDay = new Map<string, number>();
  const visible: PublicComment[] = [];
  for (const c of ordered) {
    if (mod.hidden.has(c.cidStr) || mod.blocked.has(c.pub)) continue;
    const key = `${c.pub}|${Math.floor(c.ts / 86_400_000)}`;
    const n = (perAuthorDay.get(key) ?? 0) + 1;
    perAuthorDay.set(key, n);
    if (n > COMMENTS_PER_AUTHOR_DAY) continue;
    visible.push(c);
  }
  return { state: "ok", comments: visible, truncated: !walk.complete };
}

export type PostOutcome = { ok: true } | { ok: false; reason: "invalid" | "offline" | "deposit" };

/** Sign + deposit one comment on `targetPub`'s profile. */
export async function postComment(targetPub: string, text: string, replyTo?: string): Promise<PostOutcome> {
  const id = Session.identity;
  if (!id) return { ok: false, reason: "offline" };
  const clean = sanitizeText(text, COMMENT_MAX);
  if (!clean) return { ok: false, reason: "invalid" };
  let room;
  try {
    room = publicRoom(await commentsRoom(targetPub));
  } catch {
    return { ok: false, reason: "invalid" };
  }
  if (!room) return { ok: false, reason: "offline" };
  const doc: CommentDoc = { ts: Date.now(), to: targetPub, text: clean, ...(replyTo ? { replyTo } : {}) };
  const signed = await signDoc(id.identity, "comment", doc);
  const r = await room.depositDoc(signed);
  return r.ok ? { ok: true } : { ok: false, reason: "deposit" };
}

/**
 * Owner-side re-seed on app open (§6/§7): re-deposit the raw envelopes of the
 * NEWEST visible (non-hidden, non-blocked) comments on the OWN profile —
 * dedup makes it free, re-arms their TTL, and hidden content is deliberately
 * never re-seeded (moderation via decay, non-retractive). Capped per §6.
 */
export async function reseedVisibleComments(): Promise<void> {
  const id = Session.identity;
  const selfroom = Session.selfroom;
  if (!id || !selfroom) return;
  const myPub = id.identity.publicKeyB64;
  const m = selfroom.state.modSrc;
  const mod: ModView = { hidden: new Set(m?.hidden ?? []), blocked: new Set(m?.blocked ?? []) };
  const list = await listComments(myPub, mod);
  if (list.state !== "ok" || list.comments.length === 0) return;
  const room = publicRoom(await commentsRoom(myPub));
  if (!room) return;
  for (const c of list.comments.slice(-RESEED_MAX)) {
    await room.depositRaw(c.raw).catch(() => {});
  }
}

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