letagents 0.12.23 → 0.12.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1001 @@
1
+ <template>
2
+ <section
3
+ class="private-messages"
4
+ :class="{ 'chat-open': selectedId || composing }"
5
+ aria-label="Private messages"
6
+ >
7
+ <aside class="conversation-list" aria-label="Conversations">
8
+ <header class="conversation-list-heading">
9
+ <h1>Messages</h1>
10
+ <button
11
+ class="chat-icon-button"
12
+ title="New chat"
13
+ aria-label="New chat"
14
+ @click="newChat()"
15
+ >
16
+ <ChatIcon name="plus" />
17
+ </button>
18
+ </header>
19
+ <label class="conversation-search"
20
+ ><ChatIcon name="search" /><input
21
+ v-model="filter"
22
+ type="search"
23
+ placeholder="Search conversations"
24
+ aria-label="Search conversations"
25
+ /></label>
26
+ <div class="conversation-tabs" aria-label="Show conversations">
27
+ <button
28
+ v-for="tab in tabs"
29
+ :key="tab.id"
30
+ :aria-pressed="section === tab.id"
31
+ @click="section = tab.id"
32
+ >
33
+ {{ tab.label
34
+ }}<span v-if="tab.id === 'requests' && requests">{{ requests }}</span>
35
+ </button>
36
+ </div>
37
+ <p v-if="loading" class="conversation-list-note" role="status">
38
+ Loading conversations…
39
+ </p>
40
+ <div v-else-if="!visibleChats.length" class="conversation-list-empty">
41
+ <ChatIcon name="chat" />
42
+ <p>
43
+ {{
44
+ filter
45
+ ? "No matching conversations"
46
+ : section === "requests"
47
+ ? "No message requests"
48
+ : section === "archived"
49
+ ? "No archived chats"
50
+ : "Your conversations will appear here"
51
+ }}
52
+ </p>
53
+ <button
54
+ v-if="section === 'chats' && !filter"
55
+ class="chat-text-button"
56
+ @click="newChat()"
57
+ >
58
+ Start a chat
59
+ </button>
60
+ </div>
61
+ <div class="conversation-rows">
62
+ <button
63
+ v-for="chat in visibleChats"
64
+ :key="chat.id"
65
+ class="conversation-row"
66
+ :class="{ selected: selectedId === chat.id && !composing }"
67
+ :aria-current="
68
+ selectedId === chat.id && !composing ? 'true' : undefined
69
+ "
70
+ @click="selectChat(chat.id)"
71
+ >
72
+ <span
73
+ class="conversation-avatar"
74
+ :class="{ group: chat.members.length > 2 }"
75
+ ><img
76
+ v-if="avatar(chat)"
77
+ :src="avatar(chat)!"
78
+ alt=""
79
+ referrerpolicy="no-referrer"
80
+ /><ChatIcon v-else-if="chat.members.length > 2" name="users" /><span
81
+ v-else
82
+ >{{ title(chat).slice(0, 1).toUpperCase() }}</span
83
+ ></span
84
+ >
85
+ <span class="conversation-row-content"
86
+ ><span class="conversation-row-title"
87
+ ><strong>{{ title(chat) }}</strong
88
+ ><time v-if="chat.last_message">{{
89
+ timeLabel(chat.last_message.created_at)
90
+ }}</time></span
91
+ ><span class="conversation-row-preview"
92
+ ><span>{{
93
+ chat.last_message
94
+ ? (chat.last_message.sender_account_id === accountId
95
+ ? "You: "
96
+ : "") + chat.last_message.text
97
+ : "Start the conversation"
98
+ }}</span
99
+ ><span
100
+ v-if="chat.unread_count"
101
+ class="conversation-unread"
102
+ :aria-label="`${chat.unread_count} unread messages`"
103
+ >{{ chat.unread_count > 99 ? "99+" : chat.unread_count }}</span
104
+ ></span
105
+ ></span
106
+ >
107
+ </button>
108
+ </div>
109
+ <div v-if="connectionError" class="conversation-connection" role="status">
110
+ {{ connectionError
111
+ }}<button class="chat-text-button" @click="refresh">Retry</button>
112
+ </div>
113
+ </aside>
114
+
115
+ <section
116
+ class="conversation-content"
117
+ :aria-label="
118
+ composing ? 'New chat' : selected ? title(selected) : 'Messages'
119
+ "
120
+ >
121
+ <template v-if="composing">
122
+ <header class="conversation-header">
123
+ <button
124
+ class="chat-icon-button"
125
+ aria-label="Cancel new chat"
126
+ @click="cancelCompose"
127
+ >
128
+ <ChatIcon name="back" />
129
+ </button>
130
+ <h2>{{ addingFrom ? "Add people" : "New chat" }}</h2>
131
+ </header>
132
+ <div class="conversation-compose-people">
133
+ <label for="conversation-people-search">To</label>
134
+ <div class="conversation-recipient-field">
135
+ <span
136
+ v-for="person in picked"
137
+ :key="person.id"
138
+ class="conversation-recipient"
139
+ >{{ person.display_name || person.login
140
+ }}<button
141
+ v-if="!originalIds.includes(person.id)"
142
+ class="chat-icon-button"
143
+ :aria-label="`Remove ${person.display_name || person.login}`"
144
+ @click="picked = picked.filter((item) => item.id !== person.id)"
145
+ >
146
+ <ChatIcon name="close" /></button></span
147
+ ><input
148
+ id="conversation-people-search"
149
+ ref="peopleInput"
150
+ v-model="peopleQuery"
151
+ autocomplete="off"
152
+ placeholder="Name or GitHub username"
153
+ @keydown.esc="cancelCompose"
154
+ />
155
+ </div>
156
+ <div class="conversation-people-results" aria-label="People">
157
+ <p v-if="searching" role="status">Searching…</p>
158
+ <p v-else-if="peopleQuery.trim().length > 1 && !people.length">
159
+ No people found. Try their GitHub username.
160
+ </p>
161
+ <button
162
+ v-for="person in people"
163
+ :key="person.id"
164
+ class="conversation-person"
165
+ @click="pick(person)"
166
+ >
167
+ <span class="conversation-avatar"
168
+ ><img
169
+ v-if="person.avatar_url"
170
+ :src="person.avatar_url"
171
+ alt=""
172
+ referrerpolicy="no-referrer"
173
+ /><span v-else>{{
174
+ person.login.slice(0, 1).toUpperCase()
175
+ }}</span></span
176
+ ><span
177
+ ><strong>{{ person.display_name || person.login }}</strong
178
+ ><small>@{{ person.login }}</small></span
179
+ ><ChatIcon name="plus" />
180
+ </button>
181
+ </div>
182
+ <p class="conversation-compose-hint">
183
+ {{
184
+ addingFrom
185
+ ? "This opens a chat with the people you choose. Earlier messages stay in the original chat."
186
+ : "Choose one person, or bring a few people together."
187
+ }}
188
+ </p>
189
+ <button
190
+ class="chat-primary"
191
+ :disabled="
192
+ busy ||
193
+ !picked.length ||
194
+ Boolean(addingFrom && picked.length === originalIds.length)
195
+ "
196
+ @click="createChat"
197
+ >
198
+ {{ busy ? "Opening…" : "Open chat" }}
199
+ </button>
200
+ <p v-if="actionError" class="conversation-error" role="alert">
201
+ {{ actionError }}
202
+ </p>
203
+ </div>
204
+ </template>
205
+ <template v-else-if="selected">
206
+ <header class="conversation-header">
207
+ <button
208
+ class="chat-icon-button conversation-mobile-back"
209
+ aria-label="Back to conversations"
210
+ @click="selectedId = null"
211
+ >
212
+ <ChatIcon name="back" /></button
213
+ ><button class="conversation-title-button" @click="showMembers">
214
+ <h2>{{ title(selected) }}</h2>
215
+ <span>{{
216
+ selected.members.length === 2
217
+ ? "Private conversation"
218
+ : `${selected.members.length} people`
219
+ }}</span>
220
+ </button>
221
+ <div class="conversation-header-actions">
222
+ <button
223
+ class="chat-icon-button"
224
+ title="Add people"
225
+ aria-label="Add people"
226
+ :disabled="!selected.accepted"
227
+ @click="newChat(selected)"
228
+ >
229
+ <ChatIcon name="plus" />
230
+ </button>
231
+ <details ref="menuDetails" class="conversation-menu">
232
+ <summary class="chat-icon-button" aria-label="Chat options">
233
+ <ChatIcon name="more" />
234
+ </summary>
235
+ <div>
236
+ <button @click="updateSelected({ muted: !selected.muted })">
237
+ {{ selected.muted ? "Unmute" : "Mute" }} chat</button
238
+ ><button
239
+ @click="updateSelected({ archived: !selected.archived })"
240
+ >
241
+ {{ selected.archived ? "Restore" : "Archive" }} chat</button
242
+ ><button @click="showMembers">View people</button>
243
+ </div>
244
+ </details>
245
+ </div>
246
+ </header>
247
+ <div v-if="!selected.accepted" class="conversation-request">
248
+ <div>
249
+ <strong>{{ personName(selected.created_by) }} wants to chat</strong>
250
+ <p>Accept to reply and add people.</p>
251
+ </div>
252
+ <button
253
+ class="chat-primary"
254
+ :disabled="busy"
255
+ @click="updateSelected({ accept: true })"
256
+ >
257
+ Accept</button
258
+ ><button
259
+ class="chat-secondary"
260
+ :disabled="busy"
261
+ @click="block(selected.created_by, true)"
262
+ >
263
+ Block
264
+ </button>
265
+ </div>
266
+ <div
267
+ ref="messageList"
268
+ class="conversation-timeline"
269
+ role="log"
270
+ aria-label="Messages"
271
+ :aria-busy="messagesLoading"
272
+ @scroll="rememberScroll"
273
+ >
274
+ <button
275
+ v-if="hasMore"
276
+ class="chat-text-button conversation-load-more"
277
+ :disabled="messagesLoading"
278
+ @click="loadEarlier"
279
+ >
280
+ Load earlier messages
281
+ </button>
282
+ <div
283
+ v-if="messagesLoading && !messages.length"
284
+ class="conversation-timeline-note"
285
+ role="status"
286
+ >
287
+ Loading messages…
288
+ </div>
289
+ <div v-else-if="!messages.length" class="conversation-timeline-empty">
290
+ <span class="conversation-empty-mark"
291
+ ><ChatIcon name="chat"
292
+ /></span>
293
+ <h3>
294
+ {{
295
+ selected.members.length === 2
296
+ ? `Say hello to ${title(selected)}`
297
+ : "Start the conversation"
298
+ }}
299
+ </h3>
300
+ <p>Only the people in this chat can read its messages.</p>
301
+ </div>
302
+ <template
303
+ v-for="(message, index) in messages"
304
+ :key="message.client_message_id + message.sender_account_id"
305
+ >
306
+ <div
307
+ v-if="
308
+ index === 0 ||
309
+ dayLabel(messages[index - 1].created_at) !==
310
+ dayLabel(message.created_at)
311
+ "
312
+ class="conversation-day"
313
+ >
314
+ <span>{{ dayLabel(message.created_at) }}</span>
315
+ </div>
316
+ <article
317
+ class="conversation-message"
318
+ :class="{ own: message.sender_account_id === accountId }"
319
+ >
320
+ <div class="conversation-message-meta">
321
+ <strong>{{
322
+ message.sender_account_id === accountId
323
+ ? "You"
324
+ : personName(message.sender_account_id)
325
+ }}</strong
326
+ ><time
327
+ :datetime="message.created_at"
328
+ :title="new Date(message.created_at).toLocaleString()"
329
+ >{{ clockLabel(message.created_at) }}</time
330
+ >
331
+ </div>
332
+ <p>{{ message.text }}</p>
333
+ </article>
334
+ </template>
335
+ <article
336
+ v-if="outbox[selectedId!]"
337
+ class="conversation-message own pending"
338
+ >
339
+ <div class="conversation-message-meta">
340
+ <strong>You</strong
341
+ ><span role="status">{{
342
+ outbox[selectedId!].failed
343
+ ? "Not sent"
344
+ : outbox[selectedId!].acknowledgedNumber !== undefined
345
+ ? "Sent"
346
+ : "Sending…"
347
+ }}</span>
348
+ </div>
349
+ <p>{{ outbox[selectedId!].text }}</p>
350
+ <button
351
+ v-if="outbox[selectedId!].failed"
352
+ class="chat-text-button"
353
+ @click="send"
354
+ >
355
+ Retry
356
+ </button>
357
+ </article>
358
+ </div>
359
+ <button
360
+ v-if="newMessagesBelow"
361
+ class="conversation-jump chat-secondary"
362
+ @click="scrollBottom"
363
+ >
364
+ New messages <ChatIcon name="send" />
365
+ </button>
366
+ <div class="conversation-composer-wrap">
367
+ <p v-if="actionError" class="conversation-error" role="alert">
368
+ {{ actionError }}
369
+ <button
370
+ v-if="historyNeedsRetry"
371
+ class="chat-text-button"
372
+ @click="refresh"
373
+ >
374
+ Retry
375
+ </button>
376
+ </p>
377
+ <p
378
+ v-if="!selected.can_send && selected.accepted"
379
+ class="conversation-waiting"
380
+ >
381
+ {{
382
+ selected.members.some((member) => member.blocked)
383
+ ? "Unblock this person to continue the conversation."
384
+ : selected.members.every((member) => member.accepted)
385
+ ? "You can’t send messages in this chat."
386
+ : "You can send another message once everyone has accepted."
387
+ }}
388
+ </p>
389
+ <form class="conversation-composer" @submit.prevent="send">
390
+ <textarea
391
+ ref="composer"
392
+ v-model="draft"
393
+ rows="2"
394
+ maxlength="20000"
395
+ :disabled="!selected.can_send || Boolean(outbox[selectedId!])"
396
+ :placeholder="
397
+ selected.can_send ? 'Write a message…' : 'Waiting to chat…'
398
+ "
399
+ aria-label="Message"
400
+ @keydown="composerKeydown"
401
+ /><button
402
+ class="chat-send"
403
+ type="submit"
404
+ :disabled="
405
+ !selected.can_send ||
406
+ !draft.trim() ||
407
+ Boolean(outbox[selectedId!])
408
+ "
409
+ aria-label="Send message"
410
+ >
411
+ <ChatIcon name="send" />
412
+ </button>
413
+ </form>
414
+ <span class="conversation-composer-hint"
415
+ >Enter to send · Shift + Enter for a new line</span
416
+ >
417
+ </div>
418
+ </template>
419
+ <div v-else class="conversation-welcome">
420
+ <span class="conversation-empty-mark"><ChatIcon name="chat" /></span>
421
+ <h2>A place to talk</h2>
422
+ <p>
423
+ Message someone directly, or start a conversation with a few people.
424
+ </p>
425
+ <button class="chat-primary" @click="newChat()">
426
+ <ChatIcon name="plus" />New chat
427
+ </button>
428
+ </div>
429
+ </section>
430
+ <dialog
431
+ ref="membersDialog"
432
+ aria-label="People in this chat"
433
+ class="conversation-members-dialog"
434
+ @click="
435
+ (event) => event.target === membersDialog && membersDialog?.close()
436
+ "
437
+ >
438
+ <div v-if="selected">
439
+ <header>
440
+ <h2>People in this chat</h2>
441
+ <button
442
+ class="chat-icon-button"
443
+ aria-label="Close people"
444
+ @click="membersDialog?.close()"
445
+ >
446
+ <ChatIcon name="close" />
447
+ </button>
448
+ </header>
449
+ <div
450
+ v-for="member in selected.members"
451
+ :key="member.id"
452
+ class="conversation-member"
453
+ >
454
+ <span class="conversation-avatar"
455
+ ><img
456
+ v-if="member.avatar_url"
457
+ :src="member.avatar_url"
458
+ alt=""
459
+ referrerpolicy="no-referrer"
460
+ /><span v-else>{{
461
+ member.login.slice(0, 1).toUpperCase()
462
+ }}</span></span
463
+ ><span
464
+ ><strong>{{
465
+ member.id === accountId
466
+ ? "You"
467
+ : member.display_name || member.login
468
+ }}</strong
469
+ ><small>{{
470
+ member.accepted ? `@${member.login}` : "Invited"
471
+ }}</small></span
472
+ ><button
473
+ v-if="member.id !== accountId && selected.members.length > 2"
474
+ class="chat-text-button"
475
+ :disabled="busy || member.blocked"
476
+ @click="messagePerson(member.id)"
477
+ >
478
+ Message</button
479
+ ><button
480
+ v-if="member.id !== accountId"
481
+ class="chat-text-button"
482
+ :disabled="busy"
483
+ @click="block(member.id, !member.blocked)"
484
+ >
485
+ {{ member.blocked ? "Unblock" : "Block" }}
486
+ </button>
487
+ </div>
488
+ </div>
489
+ </dialog>
490
+ </section>
491
+ </template>
492
+
493
+ <script setup lang="ts">
494
+ import {
495
+ computed,
496
+ nextTick,
497
+ onBeforeUnmount,
498
+ onMounted,
499
+ ref,
500
+ watch,
501
+ } from "vue";
502
+ import type {
503
+ Conversation,
504
+ ConversationApi,
505
+ ConversationMessage,
506
+ ConversationPerson,
507
+ } from "../conversation-contracts.mjs";
508
+ import ChatIcon from "./ConversationIcon.vue";
509
+
510
+ const props = withDefaults(
511
+ defineProps<{
512
+ api: ConversationApi;
513
+ accountId: string;
514
+ active?: boolean;
515
+ openConversationId?: string | null;
516
+ openConversationNonce?: number;
517
+ }>(),
518
+ { active: true, openConversationId: null, openConversationNonce: 0 },
519
+ );
520
+ const emit = defineEmits<{ unread: [count: number] }>();
521
+ const chats = ref<Conversation[]>([]),
522
+ selectedId = ref<string | null>(null),
523
+ messages = ref<ConversationMessage[]>([]);
524
+ const section = ref("chats"),
525
+ filter = ref(""),
526
+ loading = ref(true),
527
+ messagesLoading = ref(false),
528
+ busy = ref(false);
529
+ const connectionError = ref(""),
530
+ actionError = ref(""),
531
+ historyNeedsRetry = ref(false),
532
+ hasMore = ref(false),
533
+ newMessagesBelow = ref(false);
534
+ const composing = ref(false),
535
+ addingFrom = ref<string | undefined>(),
536
+ originalIds = ref<string[]>([]),
537
+ picked = ref<ConversationPerson[]>([]),
538
+ peopleQuery = ref(""),
539
+ people = ref<ConversationPerson[]>([]),
540
+ searching = ref(false);
541
+ const messageList = ref<HTMLElement>(),
542
+ composer = ref<HTMLTextAreaElement>(),
543
+ peopleInput = ref<HTMLInputElement>(),
544
+ membersDialog = ref<HTMLDialogElement>(),
545
+ menuDetails = ref<HTMLDetailsElement>();
546
+ const drafts = ref<Record<string, string>>({});
547
+ const outbox = ref<
548
+ Record<
549
+ string,
550
+ { text: string; id: string; failed: boolean; acknowledgedNumber?: number }
551
+ >
552
+ >({});
553
+ let alive = true,
554
+ version = "0",
555
+ refreshGeneration = 0,
556
+ messageGeneration = 0,
557
+ searchGeneration = 0;
558
+ let searchTimer: ReturnType<typeof setTimeout> | undefined,
559
+ retryTimer: ReturnType<typeof setTimeout> | undefined;
560
+ let atBottom = true;
561
+ const tabs = [
562
+ { id: "chats", label: "Chats" },
563
+ { id: "requests", label: "Requests" },
564
+ { id: "archived", label: "Archived" },
565
+ ];
566
+ const selected = computed(() =>
567
+ chats.value.find((chat) => chat.id === selectedId.value),
568
+ );
569
+ const requests = computed(
570
+ () => chats.value.filter((chat) => !chat.accepted && !chat.archived).length,
571
+ );
572
+ const visibleChats = computed(() =>
573
+ chats.value.filter(
574
+ (chat) =>
575
+ (section.value === "archived"
576
+ ? chat.archived
577
+ : !chat.archived &&
578
+ (section.value === "requests" ? !chat.accepted : chat.accepted)) &&
579
+ title(chat).toLowerCase().includes(filter.value.toLowerCase()),
580
+ ),
581
+ );
582
+ const draft = computed({
583
+ get: () => drafts.value[selectedId.value || ""] || "",
584
+ set: (value) => {
585
+ if (selectedId.value) drafts.value[selectedId.value] = value;
586
+ },
587
+ });
588
+ const title = (chat: Conversation) =>
589
+ chat.members
590
+ .filter((member) => member.id !== props.accountId)
591
+ .map((member) => member.display_name || member.login)
592
+ .join(", ");
593
+ const avatar = (chat: Conversation) =>
594
+ chat.members.length === 2
595
+ ? chat.members.find((member) => member.id !== props.accountId)?.avatar_url
596
+ : null;
597
+ const personName = (id: string) => {
598
+ const member = selected.value?.members.find((member) => member.id === id);
599
+ return member?.display_name || member?.login || "Member";
600
+ };
601
+ const clockLabel = (at: string) =>
602
+ new Date(at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
603
+ const dayLabel = (at: string) =>
604
+ new Date(at).toLocaleDateString([], {
605
+ month: "short",
606
+ day: "numeric",
607
+ year: "numeric",
608
+ });
609
+ const timeLabel = (at: string) =>
610
+ new Date(at).toDateString() === new Date().toDateString()
611
+ ? clockLabel(at)
612
+ : new Date(at).toLocaleDateString([], { month: "short", day: "numeric" });
613
+ const errorText = (error: unknown) =>
614
+ error instanceof Error
615
+ ? error.message.replace(
616
+ /^Error invoking remote method '[^']+': (?:Error: )?/,
617
+ "",
618
+ )
619
+ : "Something went wrong. Try again.";
620
+
621
+ async function refresh() {
622
+ const generation = ++refreshGeneration;
623
+ try {
624
+ const result = await props.api.list();
625
+ if (!alive || generation !== refreshGeneration) return;
626
+ chats.value = result.conversations;
627
+ version = result.version;
628
+ connectionError.value = "";
629
+ emit(
630
+ "unread",
631
+ chats.value
632
+ .filter((chat) => !chat.muted && !chat.archived)
633
+ .reduce((sum, chat) => sum + chat.unread_count, 0),
634
+ );
635
+ if (selectedId.value && props.active && !composing.value)
636
+ await loadMessages(false);
637
+ } catch (error) {
638
+ if (alive) connectionError.value = errorText(error);
639
+ } finally {
640
+ if (alive) loading.value = false;
641
+ }
642
+ }
643
+ async function watchChanges() {
644
+ let failures = 0;
645
+ while (alive) {
646
+ try {
647
+ const change = await props.api.changes(version);
648
+ if (!alive) return;
649
+ if (change.version !== version || historyNeedsRetry.value)
650
+ await refresh();
651
+ failures = 0;
652
+ } catch {
653
+ if (!alive) return;
654
+ connectionError.value = "Reconnecting to messages…";
655
+ await new Promise<void>((resolve) => {
656
+ retryTimer = setTimeout(
657
+ resolve,
658
+ Math.min(30000, 1000 * 2 ** Math.min(++failures, 5)),
659
+ );
660
+ });
661
+ }
662
+ }
663
+ }
664
+ async function markRead(id: string, number: number) {
665
+ if (
666
+ !props.active ||
667
+ composing.value ||
668
+ !document.hasFocus() ||
669
+ document.visibilityState === "hidden" ||
670
+ id !== selectedId.value ||
671
+ !atBottom
672
+ )
673
+ return;
674
+ const chat = chats.value.find((chat) => chat.id === id);
675
+ if (!chat?.unread_count) return;
676
+ try {
677
+ await props.api.update(id, { last_read_number: number });
678
+ } catch {
679
+ /* next foreground refresh retries the read cursor */
680
+ }
681
+ }
682
+ async function loadMessages(reset: boolean) {
683
+ const id = selectedId.value;
684
+ if (!id) return;
685
+ const generation = ++messageGeneration;
686
+ messagesLoading.value = true;
687
+ try {
688
+ const previous = messages.value[messages.value.length - 1]?.number ?? 0;
689
+ const incremental = !reset && messages.value.length > 0;
690
+ let after = previous;
691
+ do {
692
+ const result = await props.api.messages(
693
+ id,
694
+ incremental ? { after } : undefined,
695
+ );
696
+ if (!alive || id !== selectedId.value || generation !== messageGeneration)
697
+ return;
698
+ if (!incremental) {
699
+ messages.value = result.messages;
700
+ hasMore.value = result.has_more;
701
+ } else {
702
+ const merged = new Map(
703
+ messages.value.map((message) => [message.number, message]),
704
+ );
705
+ result.messages.forEach((message) =>
706
+ merged.set(message.number, message),
707
+ );
708
+ messages.value = [...merged.values()].sort(
709
+ (a, b) => a.number - b.number,
710
+ );
711
+ }
712
+ if (!incremental || !result.has_more || !result.messages.length) break;
713
+ after = result.messages[result.messages.length - 1].number;
714
+ } while (alive);
715
+ const pending = outbox.value[id];
716
+ if (
717
+ pending &&
718
+ ((pending.acknowledgedNumber !== undefined &&
719
+ (messages.value.at(-1)?.number ?? 0) >= pending.acknowledgedNumber) ||
720
+ messages.value.some(
721
+ (message) =>
722
+ message.sender_account_id === props.accountId &&
723
+ message.client_message_id === pending.id,
724
+ ))
725
+ )
726
+ delete outbox.value[id];
727
+ if (historyNeedsRetry.value) actionError.value = "";
728
+ historyNeedsRetry.value = false;
729
+ await nextTick();
730
+ if (reset || atBottom) await scrollBottom();
731
+ else if (
732
+ (messages.value[messages.value.length - 1]?.number ?? 0) > previous
733
+ )
734
+ newMessagesBelow.value = true;
735
+ if (generation === messageGeneration)
736
+ await markRead(
737
+ id,
738
+ messages.value[messages.value.length - 1]?.number ?? 0,
739
+ );
740
+ } catch (error) {
741
+ if (id === selectedId.value && generation === messageGeneration) {
742
+ historyNeedsRetry.value = true;
743
+ actionError.value = errorText(error);
744
+ }
745
+ } finally {
746
+ if (generation === messageGeneration) messagesLoading.value = false;
747
+ }
748
+ }
749
+ async function selectChat(id: string) {
750
+ selectedId.value = id;
751
+ composing.value = false;
752
+ actionError.value = "";
753
+ messages.value = [];
754
+ historyNeedsRetry.value = false;
755
+ atBottom = true;
756
+ newMessagesBelow.value = false;
757
+ await loadMessages(true);
758
+ await nextTick();
759
+ if (alive && id === selectedId.value) composer.value?.focus();
760
+ }
761
+ async function loadEarlier() {
762
+ const id = selectedId.value,
763
+ first = messages.value[0]?.number;
764
+ if (!id || !first || messagesLoading.value) return;
765
+ const generation = ++messageGeneration;
766
+ messagesLoading.value = true;
767
+ const height = messageList.value?.scrollHeight ?? 0;
768
+ try {
769
+ const result = await props.api.messages(id, { before: first });
770
+ if (!alive || id !== selectedId.value || generation !== messageGeneration)
771
+ return;
772
+ messages.value = [...result.messages, ...messages.value];
773
+ hasMore.value = result.has_more;
774
+ await nextTick();
775
+ if (messageList.value)
776
+ messageList.value.scrollTop += messageList.value.scrollHeight - height;
777
+ } catch (error) {
778
+ if (generation === messageGeneration) actionError.value = errorText(error);
779
+ } finally {
780
+ if (generation === messageGeneration) messagesLoading.value = false;
781
+ }
782
+ }
783
+ function rememberScroll() {
784
+ const element = messageList.value;
785
+ if (!element) return;
786
+ atBottom =
787
+ element.scrollHeight - element.scrollTop - element.clientHeight < 60;
788
+ if (atBottom) {
789
+ newMessagesBelow.value = false;
790
+ if (selectedId.value)
791
+ void markRead(
792
+ selectedId.value,
793
+ messages.value[messages.value.length - 1]?.number ?? 0,
794
+ );
795
+ }
796
+ }
797
+ async function scrollBottom() {
798
+ await nextTick();
799
+ if (messageList.value)
800
+ messageList.value.scrollTop = messageList.value.scrollHeight;
801
+ atBottom = true;
802
+ newMessagesBelow.value = false;
803
+ }
804
+ async function newChat(from?: Conversation) {
805
+ actionError.value = "";
806
+ composing.value = true;
807
+ addingFrom.value = from?.id;
808
+ picked.value =
809
+ from?.members.filter((member) => member.id !== props.accountId) ?? [];
810
+ originalIds.value = picked.value.map((person) => person.id);
811
+ peopleQuery.value = "";
812
+ people.value = [];
813
+ await nextTick();
814
+ peopleInput.value?.focus();
815
+ }
816
+ function cancelCompose() {
817
+ composing.value = false;
818
+ actionError.value = "";
819
+ }
820
+ function pick(person: ConversationPerson) {
821
+ if (!picked.value.some((item) => item.id === person.id))
822
+ picked.value.push(person);
823
+ peopleQuery.value = "";
824
+ people.value = [];
825
+ peopleInput.value?.focus();
826
+ }
827
+ watch(peopleQuery, (query) => {
828
+ const generation = ++searchGeneration;
829
+ clearTimeout(searchTimer);
830
+ people.value = [];
831
+ searching.value = false;
832
+ if (query.trim().length < 2) return;
833
+ searching.value = true;
834
+ searchTimer = setTimeout(async () => {
835
+ try {
836
+ const result = await props.api.people(query);
837
+ if (alive && generation === searchGeneration)
838
+ people.value = result.people.filter(
839
+ (person) => !picked.value.some((item) => item.id === person.id),
840
+ );
841
+ } catch (error) {
842
+ if (generation === searchGeneration) actionError.value = errorText(error);
843
+ } finally {
844
+ if (generation === searchGeneration) searching.value = false;
845
+ }
846
+ }, 180);
847
+ });
848
+ async function createChat() {
849
+ if (busy.value) return;
850
+ busy.value = true;
851
+ actionError.value = "";
852
+ try {
853
+ const result = await props.api.create(
854
+ picked.value.map((person) => person.id),
855
+ addingFrom.value,
856
+ );
857
+ await refresh();
858
+ section.value = chats.value.find(
859
+ (chat) => chat.id === result.conversation_id,
860
+ )?.accepted
861
+ ? "chats"
862
+ : "requests";
863
+ await selectChat(result.conversation_id);
864
+ } catch (error) {
865
+ actionError.value = errorText(error);
866
+ } finally {
867
+ busy.value = false;
868
+ }
869
+ }
870
+ async function updateSelected(
871
+ changes: Parameters<ConversationApi["update"]>[1],
872
+ ) {
873
+ const id = selectedId.value;
874
+ if (!id || busy.value) return;
875
+ busy.value = true;
876
+ actionError.value = "";
877
+ try {
878
+ await props.api.update(id, changes);
879
+ if (menuDetails.value) menuDetails.value.open = false;
880
+ if (changes.accept) section.value = "chats";
881
+ if (changes.archived) selectedId.value = null;
882
+ await refresh();
883
+ } catch (error) {
884
+ actionError.value = errorText(error);
885
+ } finally {
886
+ busy.value = false;
887
+ }
888
+ }
889
+ async function block(id: string, blocked: boolean) {
890
+ if (busy.value) return;
891
+ busy.value = true;
892
+ actionError.value = "";
893
+ try {
894
+ await props.api.block(id, blocked);
895
+ await refresh();
896
+ } catch (error) {
897
+ actionError.value = errorText(error);
898
+ } finally {
899
+ busy.value = false;
900
+ }
901
+ }
902
+ async function messagePerson(id: string) {
903
+ if (busy.value) return;
904
+ busy.value = true;
905
+ actionError.value = "";
906
+ try {
907
+ const result = await props.api.create([id]);
908
+ membersDialog.value?.close();
909
+ await refresh();
910
+ section.value = chats.value.find(
911
+ (chat) => chat.id === result.conversation_id,
912
+ )?.accepted
913
+ ? "chats"
914
+ : "requests";
915
+ await selectChat(result.conversation_id);
916
+ } catch (error) {
917
+ actionError.value = errorText(error);
918
+ membersDialog.value?.close();
919
+ } finally {
920
+ busy.value = false;
921
+ }
922
+ }
923
+ async function showMembers() {
924
+ await nextTick();
925
+ membersDialog.value?.showModal();
926
+ }
927
+ function composerKeydown(event: KeyboardEvent) {
928
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
929
+ event.preventDefault();
930
+ void send();
931
+ }
932
+ }
933
+ async function send() {
934
+ const id = selectedId.value;
935
+ if (!id) return;
936
+ let pending = outbox.value[id];
937
+ if (pending && !pending.failed) return;
938
+ if (!pending) {
939
+ if (!draft.value.trim() || !selected.value?.can_send) return;
940
+ pending = {
941
+ text: draft.value.trim(),
942
+ id: crypto.randomUUID(),
943
+ failed: false,
944
+ };
945
+ outbox.value[id] = pending;
946
+ draft.value = "";
947
+ }
948
+ pending.failed = false;
949
+ actionError.value = "";
950
+ await scrollBottom();
951
+ try {
952
+ const acknowledged = await props.api.send(id, pending.text, pending.id);
953
+ if (outbox.value[id]?.id === pending.id)
954
+ outbox.value[id].acknowledgedNumber = acknowledged.number;
955
+ // Only fetched history advances the pagination/read cursor. Another person
956
+ // may have sent a message immediately before this acknowledgement.
957
+ // Keep the optimistic entry until loadMessages observes its client ID.
958
+ await refresh();
959
+ if (alive && id === selectedId.value) {
960
+ await scrollBottom();
961
+ await nextTick();
962
+ composer.value?.focus();
963
+ }
964
+ } catch (error) {
965
+ if (outbox.value[id]?.id === pending.id) outbox.value[id].failed = true;
966
+ if (id === selectedId.value) actionError.value = errorText(error);
967
+ }
968
+ }
969
+ function foreground() {
970
+ if (document.visibilityState === "visible" && props.active) void refresh();
971
+ }
972
+ watch(
973
+ () => props.active,
974
+ (active) => {
975
+ if (active) void refresh();
976
+ },
977
+ );
978
+ watch(
979
+ () => [props.openConversationId, props.openConversationNonce] as const,
980
+ ([id]) => {
981
+ if (id) void selectChat(id);
982
+ },
983
+ );
984
+ onMounted(async () => {
985
+ await refresh();
986
+ if (!alive) return;
987
+ if (props.openConversationId) await selectChat(props.openConversationId);
988
+ if (!alive) return;
989
+ void watchChanges();
990
+ document.addEventListener("visibilitychange", foreground);
991
+ window.addEventListener("focus", foreground);
992
+ });
993
+ onBeforeUnmount(() => {
994
+ alive = false;
995
+ clearTimeout(searchTimer);
996
+ clearTimeout(retryTimer);
997
+ document.removeEventListener("visibilitychange", foreground);
998
+ window.removeEventListener("focus", foreground);
999
+ });
1000
+ </script>
1001
+ <style scoped src="./private-messages.css"></style>