InboxView.svelte raw
1 <script>
2 import { onMount, onDestroy, tick } from 'svelte';
3 import { conversations, selectedConversation, inboxLoading, markConversationRead } from './chatStores.js';
4 import { fetchEvents, fetchUserProfile, nostrClient } from './nostr.js';
5
6 export let isLoggedIn = false;
7 export let userPubkey = "";
8 export let userSigner = null;
9
10 let profiles = new Map();
11 let messageInput = "";
12 let messagesEnd;
13 let sending = false;
14 let initialized = false;
15
16 // Sorted conversation list: most recent first
17 $: conversationList = getConversationList($conversations);
18
19 function getConversationList(convMap) {
20 const list = [];
21 for (const [pubkey, conv] of convMap.entries()) {
22 if (!conv.messages || conv.messages.length === 0) continue;
23 const lastMsg = conv.messages[conv.messages.length - 1];
24 list.push({
25 pubkey,
26 lastMessage: lastMsg,
27 unreadCount: conv.unreadCount || 0,
28 protocol: conv.protocol || "nip04",
29 });
30 }
31 list.sort((a, b) => (b.lastMessage?.created_at || 0) - (a.lastMessage?.created_at || 0));
32 return list;
33 }
34
35 // Current conversation messages
36 $: currentMessages = $selectedConversation
37 ? ($conversations.get($selectedConversation)?.messages || [])
38 : [];
39
40 // Auto-scroll on new messages
41 $: if (currentMessages.length > 0 && messagesEnd) {
42 tick().then(() => {
43 if (messagesEnd) messagesEnd.scrollIntoView({ behavior: 'smooth' });
44 });
45 }
46
47 // Mark as read when selecting conversation
48 $: if ($selectedConversation) {
49 markConversationRead($selectedConversation);
50 }
51
52 onMount(() => {
53 if (isLoggedIn && userPubkey && !initialized) {
54 initialized = true;
55 loadDMs();
56 }
57 });
58
59 async function loadDMs() {
60 if ($inboxLoading || !userPubkey) return;
61 inboxLoading.set(true);
62
63 try {
64 // Fetch NIP-04 DMs (kind 4) where user is author or tagged
65 const [sent, received] = await Promise.all([
66 fetchEvents(
67 [{ kinds: [4], authors: [userPubkey], limit: 200 }],
68 { timeout: 15000, useCache: false }
69 ),
70 fetchEvents(
71 [{ kinds: [4], "#p": [userPubkey], limit: 200 }],
72 { timeout: 15000, useCache: false }
73 ),
74 ]);
75
76 const allDMs = [...(sent || []), ...(received || [])];
77 if (allDMs.length === 0) {
78 inboxLoading.set(false);
79 return;
80 }
81
82 // Group by conversation partner
83 const convMap = new Map();
84 for (const ev of allDMs) {
85 const partner = ev.pubkey === userPubkey
86 ? ev.tags.find(t => t[0] === "p")?.[1]
87 : ev.pubkey;
88 if (!partner) continue;
89
90 if (!convMap.has(partner)) {
91 convMap.set(partner, { messages: [], lastRead: 0, unreadCount: 0, protocol: "nip04" });
92 }
93
94 // Decrypt content
95 let decrypted = ev.content;
96 try {
97 if (userSigner?.nip04Decrypt) {
98 decrypted = await userSigner.nip04Decrypt(partner, ev.content);
99 }
100 } catch {
101 decrypted = "[encrypted]";
102 }
103
104 convMap.get(partner).messages.push({
105 ...ev,
106 decrypted,
107 isMine: ev.pubkey === userPubkey,
108 });
109 }
110
111 // Sort messages within each conversation
112 for (const conv of convMap.values()) {
113 conv.messages.sort((a, b) => a.created_at - b.created_at);
114 }
115
116 conversations.set(convMap);
117
118 // Load profiles for conversation partners
119 const pubkeys = [...convMap.keys()];
120 loadProfiles(pubkeys);
121
122 } catch (err) {
123 console.error("[Inbox] Error loading DMs:", err);
124 } finally {
125 inboxLoading.set(false);
126 }
127 }
128
129 async function loadProfiles(pubkeys) {
130 for (const pk of pubkeys) {
131 if (profiles.has(pk)) continue;
132 try {
133 const profile = await fetchUserProfile(pk);
134 if (profile) {
135 profiles.set(pk, profile);
136 profiles = profiles;
137 }
138 } catch {
139 // skip
140 }
141 }
142 }
143
144 function getDisplayName(pubkey) {
145 const p = profiles.get(pubkey);
146 if (p?.name) return p.name;
147 if (p?.display_name) return p.display_name;
148 return pubkey?.slice(0, 12) + '...';
149 }
150
151 function getAvatar(pubkey) {
152 return profiles.get(pubkey)?.picture || null;
153 }
154
155 function formatTime(ts) {
156 if (!ts) return '';
157 const d = new Date(ts * 1000);
158 const now = new Date();
159 if (d.toDateString() === now.toDateString()) {
160 return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
161 }
162 return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
163 }
164
165 function selectConversation(pubkey) {
166 selectedConversation.set(pubkey);
167 }
168
169 function backToList() {
170 selectedConversation.set(null);
171 }
172
173 async function sendMessage() {
174 if (!messageInput.trim() || !$selectedConversation || !userSigner || sending) return;
175 sending = true;
176
177 try {
178 const plaintext = messageInput.trim();
179 const partner = $selectedConversation;
180
181 // Encrypt with NIP-04
182 let ciphertext;
183 if (userSigner.nip04Encrypt) {
184 ciphertext = await userSigner.nip04Encrypt(partner, plaintext);
185 } else {
186 throw new Error("Signer does not support NIP-04 encryption");
187 }
188
189 const event = {
190 kind: 4,
191 created_at: Math.floor(Date.now() / 1000),
192 tags: [["p", partner]],
193 content: ciphertext,
194 };
195
196 const signedEvent = await userSigner.signEvent(event);
197 await nostrClient.publish(signedEvent);
198
199 // Add to local conversation immediately
200 conversations.update(map => {
201 const conv = map.get(partner) || { messages: [], lastRead: 0, unreadCount: 0, protocol: "nip04" };
202 conv.messages.push({
203 ...signedEvent,
204 decrypted: plaintext,
205 isMine: true,
206 });
207 map.set(partner, conv);
208 return new Map(map);
209 });
210
211 messageInput = "";
212 } catch (err) {
213 console.error("[Inbox] Failed to send message:", err);
214 } finally {
215 sending = false;
216 }
217 }
218
219 function handleKeydown(e) {
220 if (e.key === 'Enter' && !e.shiftKey) {
221 e.preventDefault();
222 sendMessage();
223 }
224 }
225
226 function truncateMessage(text, maxLen = 50) {
227 if (!text || text.length <= maxLen) return text;
228 return text.slice(0, maxLen) + '...';
229 }
230 </script>
231
232 <div class="inbox" class:has-selected={$selectedConversation}>
233 <!-- Conversation List -->
234 <div class="conversation-list" class:hidden-mobile={$selectedConversation}>
235 {#if !isLoggedIn}
236 <div class="inbox-empty">Log in to see your messages.</div>
237 {:else if $inboxLoading}
238 <div class="inbox-loading">
239 <div class="spinner"></div>
240 </div>
241 {:else if conversationList.length === 0}
242 <div class="inbox-empty">No conversations yet.</div>
243 {:else}
244 {#each conversationList as conv (conv.pubkey)}
245 <!-- svelte-ignore a11y-click-events-have-key-events -->
246 <!-- svelte-ignore a11y-no-static-element-interactions -->
247 <div
248 class="conversation-item"
249 class:active={$selectedConversation === conv.pubkey}
250 on:click={() => selectConversation(conv.pubkey)}
251 >
252 {#if getAvatar(conv.pubkey)}
253 <img src={getAvatar(conv.pubkey)} alt="" class="conv-avatar" />
254 {:else}
255 <div class="conv-avatar-placeholder">
256 {getDisplayName(conv.pubkey).charAt(0).toUpperCase()}
257 </div>
258 {/if}
259 <div class="conv-info">
260 <div class="conv-header-row">
261 <span class="conv-name">{getDisplayName(conv.pubkey)}</span>
262 <span class="conv-time">{formatTime(conv.lastMessage?.created_at)}</span>
263 </div>
264 <div class="conv-preview">
265 {truncateMessage(conv.lastMessage?.decrypted || conv.lastMessage?.content || '')}
266 </div>
267 </div>
268 {#if conv.unreadCount > 0}
269 <span class="conv-badge">{conv.unreadCount}</span>
270 {/if}
271 </div>
272 {/each}
273 {/if}
274 </div>
275
276 <!-- Message Thread -->
277 {#if $selectedConversation}
278 <div class="message-thread">
279 <div class="thread-header">
280 <button class="back-btn" on:click={backToList}>
281 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
282 </button>
283 <div class="thread-user">
284 {#if getAvatar($selectedConversation)}
285 <img src={getAvatar($selectedConversation)} alt="" class="thread-avatar" />
286 {:else}
287 <div class="thread-avatar-placeholder">
288 {getDisplayName($selectedConversation).charAt(0).toUpperCase()}
289 </div>
290 {/if}
291 <span class="thread-name">{getDisplayName($selectedConversation)}</span>
292 </div>
293 </div>
294
295 <div class="messages-container">
296 {#each currentMessages as msg (msg.id)}
297 <div class="message" class:mine={msg.isMine} class:theirs={!msg.isMine}>
298 <div class="message-bubble">
299 <div class="message-text">{msg.decrypted || msg.content}</div>
300 <span class="message-time">{formatTime(msg.created_at)}</span>
301 </div>
302 </div>
303 {/each}
304 <div bind:this={messagesEnd}></div>
305 </div>
306
307 <div class="compose-bar">
308 <textarea
309 bind:value={messageInput}
310 on:keydown={handleKeydown}
311 placeholder="Type a message..."
312 rows="1"
313 disabled={sending}
314 ></textarea>
315 <button class="send-btn" on:click={sendMessage} disabled={sending || !messageInput.trim()}>
316 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
317 </button>
318 </div>
319 </div>
320 {/if}
321 </div>
322
323 <style>
324 .inbox {
325 display: flex;
326 width: 100%;
327 height: 100%;
328 overflow: hidden;
329 }
330
331 .conversation-list {
332 width: 320px;
333 flex-shrink: 0;
334 border-right: 1px solid var(--border-color);
335 overflow-y: auto;
336 }
337
338 .conversation-item {
339 display: flex;
340 align-items: center;
341 gap: 0.6em;
342 padding: 0.7em 0.8em;
343 cursor: pointer;
344 transition: background 0.15s;
345 border-bottom: 1px solid var(--border-color);
346 }
347
348 .conversation-item:hover {
349 background: var(--primary-bg);
350 }
351
352 .conversation-item.active {
353 background: var(--primary-bg);
354 }
355
356 .conv-avatar, .conv-avatar-placeholder {
357 width: 40px;
358 height: 40px;
359 border-radius: 50%;
360 flex-shrink: 0;
361 }
362
363 .conv-avatar {
364 object-fit: cover;
365 }
366
367 .conv-avatar-placeholder {
368 background: var(--primary);
369 color: #000;
370 display: flex;
371 align-items: center;
372 justify-content: center;
373 font-weight: bold;
374 font-size: 0.85rem;
375 }
376
377 .conv-info {
378 flex: 1;
379 min-width: 0;
380 }
381
382 .conv-header-row {
383 display: flex;
384 justify-content: space-between;
385 align-items: baseline;
386 gap: 0.5em;
387 }
388
389 .conv-name {
390 font-weight: 600;
391 font-size: 0.85rem;
392 color: var(--text-color);
393 white-space: nowrap;
394 overflow: hidden;
395 text-overflow: ellipsis;
396 }
397
398 .conv-time {
399 font-size: 0.7rem;
400 color: var(--text-muted);
401 flex-shrink: 0;
402 }
403
404 .conv-preview {
405 font-size: 0.78rem;
406 color: var(--text-muted);
407 white-space: nowrap;
408 overflow: hidden;
409 text-overflow: ellipsis;
410 margin-top: 0.15em;
411 }
412
413 .conv-badge {
414 background: var(--primary);
415 color: #000;
416 font-size: 0.7rem;
417 font-weight: bold;
418 min-width: 18px;
419 height: 18px;
420 border-radius: 9px;
421 display: flex;
422 align-items: center;
423 justify-content: center;
424 padding: 0 4px;
425 flex-shrink: 0;
426 }
427
428 .message-thread {
429 flex: 1;
430 display: flex;
431 flex-direction: column;
432 min-width: 0;
433 }
434
435 .thread-header {
436 display: flex;
437 align-items: center;
438 gap: 0.5em;
439 padding: 0.6em 0.8em;
440 border-bottom: 1px solid var(--border-color);
441 background: var(--bg-color);
442 }
443
444 .back-btn {
445 display: none;
446 background: none;
447 border: none;
448 color: var(--text-muted);
449 cursor: pointer;
450 padding: 0.2em;
451 }
452
453 .back-btn svg {
454 width: 1.2em;
455 height: 1.2em;
456 }
457
458 .thread-user {
459 display: flex;
460 align-items: center;
461 gap: 0.5em;
462 }
463
464 .thread-avatar, .thread-avatar-placeholder {
465 width: 28px;
466 height: 28px;
467 border-radius: 50%;
468 }
469
470 .thread-avatar {
471 object-fit: cover;
472 }
473
474 .thread-avatar-placeholder {
475 background: var(--primary);
476 color: #000;
477 display: flex;
478 align-items: center;
479 justify-content: center;
480 font-weight: bold;
481 font-size: 0.7rem;
482 }
483
484 .thread-name {
485 font-weight: 600;
486 font-size: 0.85rem;
487 color: var(--text-color);
488 }
489
490 .messages-container {
491 flex: 1;
492 overflow-y: auto;
493 padding: 0.75em;
494 display: flex;
495 flex-direction: column;
496 gap: 0.3em;
497 }
498
499 .message {
500 display: flex;
501 max-width: 75%;
502 }
503
504 .message.mine {
505 align-self: flex-end;
506 }
507
508 .message.theirs {
509 align-self: flex-start;
510 }
511
512 .message-bubble {
513 padding: 0.5em 0.75em;
514 border-radius: 12px;
515 font-size: 0.85rem;
516 line-height: 1.4;
517 word-break: break-word;
518 }
519
520 .mine .message-bubble {
521 background: var(--primary);
522 color: #000;
523 border-bottom-right-radius: 4px;
524 }
525
526 .theirs .message-bubble {
527 background: var(--card-bg, #1a1a1a);
528 color: var(--text-color);
529 border-bottom-left-radius: 4px;
530 }
531
532 .message-time {
533 display: block;
534 font-size: 0.65rem;
535 opacity: 0.6;
536 margin-top: 0.2em;
537 text-align: right;
538 }
539
540 .compose-bar {
541 display: flex;
542 align-items: flex-end;
543 gap: 0.4em;
544 padding: 0.5em 0.75em;
545 border-top: 1px solid var(--border-color);
546 background: var(--bg-color);
547 }
548
549 .compose-bar textarea {
550 flex: 1;
551 background: var(--card-bg, #1a1a1a);
552 border: 1px solid var(--border-color);
553 border-radius: 8px;
554 padding: 0.5em 0.75em;
555 color: var(--text-color);
556 font-size: 0.85rem;
557 resize: none;
558 outline: none;
559 max-height: 120px;
560 font-family: inherit;
561 }
562
563 .compose-bar textarea::placeholder {
564 color: var(--text-muted);
565 }
566
567 .send-btn {
568 background: var(--primary);
569 border: none;
570 border-radius: 50%;
571 width: 34px;
572 height: 34px;
573 display: flex;
574 align-items: center;
575 justify-content: center;
576 cursor: pointer;
577 flex-shrink: 0;
578 color: #000;
579 transition: opacity 0.15s;
580 }
581
582 .send-btn:disabled {
583 opacity: 0.4;
584 cursor: not-allowed;
585 }
586
587 .send-btn svg {
588 width: 1em;
589 height: 1em;
590 }
591
592 .inbox-empty {
593 text-align: center;
594 padding: 3em 1em;
595 color: var(--text-muted);
596 font-size: 0.85rem;
597 }
598
599 .inbox-loading {
600 display: flex;
601 justify-content: center;
602 padding: 2em;
603 }
604
605 .spinner {
606 width: 24px;
607 height: 24px;
608 border: 2px solid var(--border-color);
609 border-top-color: var(--primary);
610 border-radius: 50%;
611 animation: spin 0.8s linear infinite;
612 }
613
614 @keyframes spin {
615 to { transform: rotate(360deg); }
616 }
617
618 /* Mobile: single-pane */
619 @media (max-width: 640px) {
620 .conversation-list {
621 width: 100%;
622 border-right: none;
623 }
624
625 .conversation-list.hidden-mobile {
626 display: none;
627 }
628
629 .inbox:not(.has-selected) .message-thread {
630 display: none;
631 }
632
633 .inbox.has-selected .conversation-list {
634 display: none;
635 }
636
637 .back-btn {
638 display: flex;
639 }
640 }
641 </style>
642