973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 1 | import { get, writable } from 'svelte/store'; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 2 | import { sendMessage } from './api'; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 3 | import { formatBody } from './markdown'; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 4 | import { timelines } from './store'; |
cf23dbf4e77c | atagen | 2026-10-23 06:36 UTC | 5 | import type { Mention } from './mentions'; |
df3c0f2b3da4 | atagen | 2026-10-22 13:07 UTC | 6 | import type { ReplyReference, TimelineItem } from './types'; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 7 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 8 | export interface PendingMessage { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 9 | id: string; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 10 | body: string; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 11 | formatted: string | null; |
df3c0f2b3da4 | atagen | 2026-10-22 13:07 UTC | 12 | replyTo: ReplyReference | null; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 13 | status: 'sending' | 'failed'; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 14 | error: string | null; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 15 | createdAt: number; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 16 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 17 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 18 | export const pendingByRoom = writable<Record<string, PendingMessage[]>>({}); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 19 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 20 | interface PendingRecord extends PendingMessage { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 21 | roomId: string; |
cf23dbf4e77c | atagen | 2026-10-23 06:36 UTC | 22 | mentions: Mention[]; |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 23 | markdown: boolean; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 24 | knownIds: Set<string>; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 25 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 26 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 27 | const records = new Map<string, PendingRecord>(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 28 | const queues = new Map<string, Promise<void>>(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 29 | let nextSeq = 1; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 30 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 31 | function nextId(): string { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 32 | return `pending-${nextSeq++}`; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 33 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 34 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 35 | function ownIdentities(items: TimelineItem[]): Set<string> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 36 | const out = new Set<string>(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 37 | for (const item of items) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 38 | if (item.kind !== 'event' || !item.event.own) continue; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 39 | const id = item.event.transaction_id ?? item.event.event_id; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 40 | if (id) out.add(id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 41 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 42 | return out; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 43 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 44 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 45 | function toPublic(record: PendingRecord): PendingMessage { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 46 | return { id: record.id, body: record.body, formatted: record.formatted, replyTo: record.replyTo, status: record.status, error: record.error, createdAt: record.createdAt }; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 47 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 48 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 49 | function roomList(roomId: string): PendingMessage[] { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 50 | return get(pendingByRoom)[roomId] ?? []; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 51 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 52 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 53 | function setRoom(roomId: string, list: PendingMessage[]): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 54 | pendingByRoom.update((all) => ({ ...all, [roomId]: list })); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 55 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 56 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 57 | function addRecord(record: PendingRecord): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 58 | records.set(record.id, record); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 59 | setRoom(record.roomId, [...roomList(record.roomId), toPublic(record)]); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 60 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 61 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 62 | function updateRecord(id: string, patch: Partial<Pick<PendingRecord, 'status' | 'error'>>): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 63 | const record = records.get(id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 64 | if (!record) return; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 65 | Object.assign(record, patch); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 66 | setRoom(record.roomId, roomList(record.roomId).map((p) => (p.id === id ? toPublic(record) : p))); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 67 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 68 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 69 | function removeRecord(id: string): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 70 | const record = records.get(id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 71 | if (!record) return; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 72 | records.delete(id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 73 | setRoom(record.roomId, roomList(record.roomId).filter((p) => p.id !== id)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 74 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 75 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 76 | async function attemptSend(record: PendingRecord): Promise<void> { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 77 | if (!records.has(record.id)) return; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 78 | updateRecord(record.id, { status: 'sending', error: null }); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 79 | try { |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 80 | await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions, record.markdown); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 81 | } catch (e) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 82 | updateRecord(record.id, { status: 'failed', error: e instanceof Error ? e.message : 'Message could not be sent' }); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 83 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 84 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 85 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 86 | function enqueue(record: PendingRecord): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 87 | const previous = queues.get(record.roomId) ?? Promise.resolve(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 88 | const next = previous.then(() => attemptSend(record)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 89 | queues.set(record.roomId, next.catch(() => undefined)); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 90 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 91 | |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 92 | export function queueSend( |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 93 | roomId: string, |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 94 | body: string, |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 95 | replyTo: ReplyReference | null = null, |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 96 | mentions: Mention[] = [], |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 97 | markdown = false |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 98 | ): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 99 | const record: PendingRecord = { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 100 | id: nextId(), |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 101 | roomId, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 102 | body, |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 103 | formatted: formatBody(body, mentions, markdown), |
df3c0f2b3da4 | atagen | 2026-10-22 13:07 UTC | 104 | replyTo, |
cf23dbf4e77c | atagen | 2026-10-23 06:36 UTC | 105 | mentions, |
6d6a4c34bdc1 | atagen | 2026-10-24 06:51 UTC | 106 | markdown, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 107 | status: 'sending', |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 108 | error: null, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 109 | createdAt: Date.now(), |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 110 | knownIds: ownIdentities(get(timelines)[roomId] ?? []) |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 111 | }; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 112 | addRecord(record); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 113 | enqueue(record); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 114 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 115 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 116 | export function retryPending(id: string): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 117 | const record = records.get(id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 118 | if (!record) return; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 119 | record.knownIds = ownIdentities(get(timelines)[record.roomId] ?? []); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 120 | enqueue(record); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 121 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 122 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 123 | export function discardPending(id: string): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 124 | removeRecord(id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 125 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 126 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 127 | export function resetPending(): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 128 | records.clear(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 129 | queues.clear(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 130 | pendingByRoom.set({}); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 131 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 132 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 133 | function reconcileRoom(roomId: string, items: TimelineItem[]): void { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 134 | const list = roomList(roomId); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 135 | if (list.length === 0) return; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 136 | const ownItems = items.filter((item) => item.kind === 'event' && item.event.own) as Extract< |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 137 | TimelineItem, |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 138 | { kind: 'event' } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 139 | >[]; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 140 | const claimed = new Set<string>(); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 141 | for (const pending of list) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 142 | const record = records.get(pending.id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 143 | if (!record) continue; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 144 | const match = ownItems.find((item) => { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 145 | if (claimed.has(item.event.item_id)) return false; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 146 | const id = item.event.transaction_id ?? item.event.event_id; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 147 | if (id && record.knownIds.has(id)) return false; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 148 | return item.event.content.kind === 'Message' && item.event.content.message.body === record.body; |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 149 | }); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 150 | if (match) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 151 | claimed.add(match.event.item_id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 152 | removeRecord(record.id); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 153 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 154 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 155 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 156 | |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 157 | timelines.subscribe((all) => { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 158 | for (const roomId of Object.keys(get(pendingByRoom))) { |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 159 | reconcileRoom(roomId, all[roomId] ?? []); |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 160 | } |
973f8fbdaf80 | atagen | 2026-09-28 09:10 UTC | 161 | }); |