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.
- package/dist/mcp/local-state/local-chat.js +24 -371
- package/dist/mcp/local-state/storage.js +4 -48
- package/dist/mcp/server/supervised-tool-facade.js +3 -1
- package/package.json +1 -1
- package/shared/claude-tool-operation.d.mts +1 -0
- package/shared/claude-tool-operation.mjs +8 -0
- package/shared/conversation-contracts.d.mts +62 -0
- package/shared/local-board-owner.d.mts +9 -0
- package/shared/local-board-owner.mjs +80 -0
- package/shared/local-task-revisions.d.mts +4 -0
- package/shared/local-task-revisions.mjs +97 -0
- package/shared/local-task-store.d.mts +43 -0
- package/shared/local-task-store.mjs +374 -0
- package/shared/local-worker-state-fence.d.mts +2 -0
- package/shared/local-worker-state-fence.mjs +69 -0
- package/shared/sqlite-thread-routing.mjs +2 -4
- package/shared/ui/ConversationIcon.vue +28 -0
- package/shared/ui/PrivateMessages.vue +1001 -0
- package/shared/ui/private-messages.css +817 -0
|
@@ -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>
|