atagen

malice

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