Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277// 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 statelet zTop = 1000;let cascade = 0;
let selfDid = null; // the signed-in user, set via setSelflet selfActionsP = null; // cached promise of MY recent feed actions
const SYS_HEADER = `<div class="im-sys">— between you two —</div>`;const EMPTY = `<div class="im-loading">no interactions between you two yet.<br>go say hi 👋</div>`;const MAX_LINES = 400; // how many (most-recent) interactions to keep in the DOMconst 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 = ` <div class="titlebar im-tb"> <span class="tb-title">💬 ${esc(name)}</span> <span class="tb-btns"><i class="im-min">_</i><i class="im-x">×</i></span> </div> <div class="im-sub">@${esc(handle)} · <a href="https://bsky.app/profile/${esc(handle)}" target="_blank" rel="noopener">profile ↗</a></div> <div class="im-log"><div class="im-loading"><span class="spin"></span> opening…</div></div> <div class="im-composer"> <input class="im-input" type="text" placeholder="Send a message…" autocomplete="off"> <button class="im-send btn">Send</button> </div>`; 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 ? `<div class="im-deep">${spinning ? '<span class="spin"></span> ' : ''}${esc(msg)}</div>` : ''; 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; };