// AIM-style "Instant Message" windows. Click a buddy and one opens, showing the // activity BETWEEN YOU AND THEM — their replies/likes/reposts/quotes/mentions of // your stuff, and yours of theirs — as a live chat log, oldest -> newest, // appending in real time. (Global activity still drives presence/last-seen; the // window is scoped to the relationship.) // // Two passes: // 1. fast — their getAuthorFeed + live buffer + my recent feed, filtered to // interactions between us, rendered immediately. // 2. deep — download BOTH full repos (getRepo CAR) in the background and merge // in older interactions, incl. likes across all history (and my own likes, // which are otherwise auth-gated). See deep-history.js. // Like/repost targets are resolved with getPosts; follow targets with getProfiles. import { getAuthorFeed, getPosts, getProfiles } from './api.js'; import { esc } from './util.js'; import { entryFromFeedItem, entryFromBuffer, involves, needsPostResolve, needsProfileResolve, } from './chat-actions.js'; import { lineHtml } from './chat-render.js'; import { makeDraggable } from './drag.js'; import { loadRepoInteractions } from './deep-history.js'; const open = new Map(); // did -> window state let zTop = 1000; let cascade = 0; let selfDid = null; // the signed-in user, set via setSelf let selfActionsP = null; // cached promise of MY recent feed actions const SYS_HEADER = `
— between you two —
`; const EMPTY = `
no interactions between you two yet.
go say hi 👋
`; const MAX_LINES = 400; // how many (most-recent) interactions to keep in the DOM const FLUSH_MS = 150; // coalesce a burst of live events into one update export const isOpen = (did) => open.has(did); // Called at sign-on (and reset to null on sign-off). Invalidates the cached feed. export function setSelf(did) { selfDid = did; selfActionsP = null; } // My recent actions, normalized once and reused across all chat windows. function getSelfActions() { if (!selfActionsP) { selfActionsP = (async () => { if (!selfDid) return []; try { const feed = await getAuthorFeed(selfDid, { limit: 60 }); return feed.map(entryFromFeedItem).filter(Boolean); } catch { return []; } })(); } return selfActionsP; } // ---- window lifecycle ------------------------------------------------------ export async function openChat(buddy) { if (!buddy) return; const did = buddy.did; if (open.has(did)) { focusWin(did); return; } const handle = buddy.profile?.handle || did; const name = buddy.profile?.displayName?.trim() || handle; const el = document.createElement('div'); el.className = 'imwin'; const off = (cascade++ % 7) * 26; el.style.left = `${70 + off}px`; el.style.top = `${60 + off}px`; el.style.zIndex = ++zTop; el.innerHTML = `
💬 ${esc(name)} _×
@${esc(handle)} · profile ↗
opening…
`; document.body.appendChild(el); const win = { el, log: el.querySelector('.im-log'), buddy, entries: new Map(), posts: {}, profiles: {}, pending: [], flushTimer: null, deepNote: '', }; open.set(did, win); wireChrome(win, did); wireComposer(win, handle); await loadHistory(did); } function wireChrome(win, did) { const { el } = win; el.addEventListener('mousedown', () => { el.style.zIndex = ++zTop; }, true); el.querySelector('.im-x').addEventListener('click', () => closeChat(did)); el.querySelector('.im-min').addEventListener('click', () => el.classList.toggle('min')); makeDraggable(el, el.querySelector('.im-tb')); } // Composer bridges to a real Bluesky compose intent — you CAN reply for real. function wireComposer(win, handle) { const input = win.el.querySelector('.im-input'); const send = () => { const v = input.value.trim(); if (!v) return; const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(`@${handle} ${v}`)}`; window.open(url, '_blank', 'noopener'); input.value = ''; }; win.el.querySelector('.im-send').addEventListener('click', send); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); }); } // ---- entry store + render -------------------------------------------------- // Add entries to the window's map (deduped by key), returning the newly added. function addEntries(win, arr) { const added = []; for (const e of arr) { if (e?.timeMs && !win.entries.has(e.key)) { win.entries.set(e.key, e); added.push(e); } } return added; } // Rebuild the log from the entry map (oldest -> newest, capped to MAX_LINES). function renderLog(win, toBottom) { const stick = toBottom || isNearBottom(win.log); const list = [...win.entries.values()].sort((a, b) => a.timeMs - b.timeMs).slice(-MAX_LINES); const body = list.length ? SYS_HEADER + list.map((e) => lineHtml(win.buddy, e, win)).join('') : EMPTY; win.log.innerHTML = win.deepNote + body; if (stick) scrollDown(win); } function setDeepNote(win, msg, spinning = true) { win.deepNote = msg ? `
${spinning ? ' ' : ''}${esc(msg)}
` : ''; renderLog(win, false); } // ---- pass 1: fast load ----------------------------------------------------- async function loadHistory(did) { const win = open.get(did); if (!win) return; const [theirFeed, mine] = await Promise.all([ getAuthorFeed(did, { limit: 40 }).catch(() => []), getSelfActions(), ]); if (!open.has(did)) return; // closed while loading const add = []; for (const item of theirFeed) { const e = entryFromFeedItem(item); if (e && involves(e, selfDid)) add.push({ ...e, dir: 'them' }); } for (const ev of win.buddy.activity || []) { const e = entryFromBuffer(ev); if (e && involves(e, selfDid)) add.push({ ...e, dir: 'them' }); } for (const e of mine) { if (involves(e, did)) add.push({ ...e, dir: 'me' }); } await resolveTargets(win, addEntries(win, add)); if (!open.has(did)) return; renderLog(win, true); deepen(did); // pass 2, in the background } // ---- pass 2: deep load (full repos) ---------------------------------------- async function deepen(did) { const win = open.get(did); if (!win) return; setDeepNote(win, 'loading full history…'); let theirs, mine; try { [theirs, mine] = await Promise.all([ loadRepoInteractions(did, { onProgress: (p) => { if (open.get(did) === win) setDeepNote(win, deepLabel(p)); } }), selfDid ? loadRepoInteractions(selfDid) : Promise.resolve([]), ]); } catch { if (open.has(did)) setDeepNote(win, "couldn't load full history", false); return; } if (!open.has(did)) return; const add = []; for (const e of theirs) if (involves(e, selfDid)) add.push({ ...e, dir: 'them' }); for (const e of mine) if (involves(e, did)) add.push({ ...e, dir: 'me' }); const added = addEntries(win, add); await resolveTargets(win, added); if (!open.has(did)) return; win.deepNote = ''; renderLog(win, true); } function deepLabel(p) { if (p.phase === 'parsing') return `decoding ${(p.bytes / 1048576).toFixed(1)} MB…`; if (p.phase === 'parsed') return 'sorting full history…'; return 'loading full history…'; } // ---- live updates ---------------------------------------------------------- // Queue a live event and flush the whole burst together (one resolve + render). export function notify(did, ev) { const win = open.get(did); if (!win) return; win.pending.push(ev); if (!win.flushTimer) { win.flushTimer = setTimeout(() => { win.flushTimer = null; flush(did); }, FLUSH_MS); } } async function flush(did) { const win = open.get(did); if (!win) return; const batch = win.pending; win.pending = []; const fresh = []; for (const ev of batch) { const e = entryFromBuffer(ev); if (e && involves(e, selfDid)) fresh.push({ ...e, dir: 'them' }); // their action aimed at me } const added = addEntries(win, fresh); if (added.length) { await resolveTargets(win, added); if (!open.has(did)) return; renderLog(win, false); // flash the freshly-added (newest) lines const lines = win.log.querySelectorAll('.im-line'); for (let i = Math.max(0, lines.length - added.length); i < lines.length; i++) { lines[i].classList.add('im-new'); } } if (win.pending.length && !win.flushTimer) { win.flushTimer = setTimeout(() => { win.flushTimer = null; flush(did); }, FLUSH_MS); } } const isNearBottom = (log) => log.scrollHeight - log.scrollTop - log.clientHeight < 60; // Fill win.posts / win.profiles for any entries that need a network resolve. async function resolveTargets(win, list) { const uris = list.filter(needsPostResolve).map((e) => e.targetUri); const dids = list.filter(needsProfileResolve).map((e) => e.targetDid); const [posts, profiles] = await Promise.all([ uris.length ? getPosts(uris).catch(() => ({})) : {}, dids.length ? getProfiles(dids).catch(() => ({})) : {}, ]); Object.assign(win.posts, posts); Object.assign(win.profiles, profiles); } export function closeChat(did) { const win = open.get(did); if (!win) return; if (win.flushTimer) clearTimeout(win.flushTimer); win.el.remove(); open.delete(did); } // Close every open IM window (used on sign-off). export function closeAll() { for (const did of [...open.keys()]) closeChat(did); } const scrollDown = (win) => { win.log.scrollTop = win.log.scrollHeight; }; const focusWin = (did) => { const w = open.get(did); if (w) w.el.style.zIndex = ++zTop; };