| 1 | // The inviter-name hint (#1436): a person-invite's encrypted payload carries |
| 2 | // the inviter's display name (`from`, beside the DM threadKey — the server |
| 3 | // can't open either), so the joiner's first paint can label their inviter |
| 4 | // instead of showing "Someone" while the instance-key grant is still in |
| 5 | // flight. The hint is provisional by construction: it only ever fills an |
| 6 | // entry whose real label hasn't decrypted, and it dies the moment one has. |
| 7 | // Pure decisions only — join.js/shell.js own the DOM and storage |
| 8 | // (Node-tested in test/invitehint.test.mjs). |
| 9 | |
| 10 | export const INVITER_HINT_KEY = "lchat_inviter_hint"; |
| 11 | |
| 12 | // A hint outliving a week is a stale artifact of an abandoned join, not a |
| 13 | // first paint — and the real label has almost certainly decrypted by then. |
| 14 | const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; |
| 15 | |
| 16 | export function inviterHintStash({ thread, name, now }) { |
| 17 | return JSON.stringify({ |
| 18 | v: 1, |
| 19 | thread: String(thread || ""), |
| 20 | name: String(name || "").trim().slice(0, 64), |
| 21 | at: now, |
| 22 | }); |
| 23 | } |
| 24 | |
| 25 | export function readInviterHint(storedJSON, now) { |
| 26 | let h = null; |
| 27 | try { h = JSON.parse(storedJSON || "null"); } catch { return null; } |
| 28 | if (!h || typeof h.thread !== "string" || typeof h.name !== "string") return null; |
| 29 | if (!h.thread || !h.name.trim()) return null; |
| 30 | if (typeof h.at !== "number" || now < h.at || now - h.at > MAX_AGE_MS) return null; |
| 31 | return { thread: h.thread, name: h.name.trim() }; |
| 32 | } |
| 33 | |
| 34 | // inviterHintTarget: who (if anyone) the hint should provisionally label. |
| 35 | // { userId, name } — fill this entry's missing label |
| 36 | // { resolved: true } — the real label decrypted; drop the stash |
| 37 | // null — nothing to do (thread/member not loaded yet, |
| 38 | // or the hint doesn't apply): keep the stash, |
| 39 | // a later pass may have more data. |
| 40 | // Scoped hard to the hinted DM's other member — a hint must never rename |
| 41 | // anyone else, and never a bot (agents carry plaintext labels, never blanks). |
| 42 | export function inviterHintTarget(hint, threads, roster, myUserId) { |
| 43 | if (!hint) return null; |
| 44 | const t = (threads || []).find((x) => x && x.id === hint.thread); |
| 45 | if (!t) return null; |
| 46 | const otherId = (t.members || []).find((uid) => uid && uid !== myUserId); |
| 47 | if (!otherId) return null; |
| 48 | const e = (roster || []).find((x) => x && x.user_id === otherId); |
| 49 | if (!e || e.is_agent) return null; |
| 50 | if (e.label) return { resolved: true }; |
| 51 | return { userId: otherId, name: hint.name }; |
| 52 | } |
| 53 | |