ChannelsView.svelte raw
1 <script>
2 import { onMount, tick } from 'svelte';
3 import {
4 channels, joinedChannels, selectedChannel, channelsLoading,
5 markChannelRead, joinChannel, leaveChannel
6 } from './chatStores.js';
7 import { fetchEvents, fetchUserProfile, nostrClient } from './nostr.js';
8
9 export let isLoggedIn = false;
10 export let userPubkey = "";
11 export let userSigner = null;
12
13 let profiles = new Map();
14 let messageInput = "";
15 let messagesEnd;
16 let sending = false;
17 let initialized = false;
18 let showLeaveConfirm = null;
19 let showDiscovery = false;
20 let discoveryChannels = [];
21 let discoveryLoading = false;
22
23 // Channel list: joined channels sorted by most recent message
24 $: channelList = getChannelList($channels, $joinedChannels);
25
26 function getChannelList(chanMap, joined) {
27 const list = [];
28 for (const [id, chan] of chanMap.entries()) {
29 if (!joined.has(id)) continue;
30 const lastMsg = chan.messages?.length > 0 ? chan.messages[chan.messages.length - 1] : null;
31 list.push({
32 id,
33 name: chan.metadata?.name || id.slice(0, 12) + '...',
34 about: chan.metadata?.about || '',
35 picture: chan.metadata?.picture || null,
36 lastMessage: lastMsg,
37 unreadCount: chan.unreadCount || 0,
38 });
39 }
40 list.sort((a, b) => (b.lastMessage?.created_at || 0) - (a.lastMessage?.created_at || 0));
41 return list;
42 }
43
44 // Current channel messages
45 $: currentChannel = $selectedChannel ? $channels.get($selectedChannel) : null;
46 $: currentMessages = currentChannel?.messages || [];
47 $: currentMeta = currentChannel?.metadata || {};
48
49 // Auto-scroll
50 $: if (currentMessages.length > 0 && messagesEnd) {
51 tick().then(() => {
52 if (messagesEnd) messagesEnd.scrollIntoView({ behavior: 'smooth' });
53 });
54 }
55
56 // Mark as read
57 $: if ($selectedChannel) {
58 markChannelRead($selectedChannel);
59 }
60
61 onMount(() => {
62 if (isLoggedIn && !initialized) {
63 initialized = true;
64 loadJoinedChannels();
65 }
66 });
67
68 async function loadJoinedChannels() {
69 if ($channelsLoading) return;
70 channelsLoading.set(true);
71
72 try {
73 const joined = [...$joinedChannels];
74 if (joined.length === 0) {
75 channelsLoading.set(false);
76 return;
77 }
78
79 // Fetch channel metadata (kind 40 = creation, kind 41 = metadata update)
80 const metaEvents = await fetchEvents(
81 [{ kinds: [40], ids: joined, limit: 100 }],
82 { timeout: 10000, useCache: false }
83 );
84
85 // Also fetch kind 41 metadata updates
86 const metaUpdates = await fetchEvents(
87 [{ kinds: [41], "#e": joined, limit: 100 }],
88 { timeout: 10000, useCache: false }
89 );
90
91 const chanMap = new Map($channels);
92 // Process kind 40 (channel creation)
93 for (const ev of (metaEvents || [])) {
94 try {
95 const meta = JSON.parse(ev.content);
96 const existing = chanMap.get(ev.id) || { messages: [], lastRead: 0, unreadCount: 0, joined: true };
97 existing.metadata = meta;
98 existing.metadata._creator = ev.pubkey;
99 existing.joined = true;
100 chanMap.set(ev.id, existing);
101 } catch { /* skip malformed */ }
102 }
103
104 // Process kind 41 (metadata updates, override kind 40)
105 for (const ev of (metaUpdates || [])) {
106 const channelId = ev.tags.find(t => t[0] === "e")?.[1];
107 if (!channelId || !chanMap.has(channelId)) continue;
108 try {
109 const meta = JSON.parse(ev.content);
110 const existing = chanMap.get(channelId);
111 // Only apply if from channel creator
112 if (existing.metadata?._creator === ev.pubkey) {
113 existing.metadata = { ...existing.metadata, ...meta, _creator: ev.pubkey };
114 chanMap.set(channelId, existing);
115 }
116 } catch { /* skip */ }
117 }
118
119 // Fetch recent messages for joined channels (kind 42)
120 const msgEvents = await fetchEvents(
121 [{ kinds: [42], "#e": joined, limit: 200 }],
122 { timeout: 15000, useCache: false }
123 );
124
125 // Sort messages into channels
126 const profilePubkeys = new Set();
127 for (const ev of (msgEvents || [])) {
128 const rootTag = ev.tags.find(t => t[0] === "e" && (t[3] === "root" || !t[3]));
129 const channelId = rootTag?.[1];
130 if (!channelId || !chanMap.has(channelId)) continue;
131
132 const chan = chanMap.get(channelId);
133 if (!chan.messages.find(m => m.id === ev.id)) {
134 chan.messages.push(ev);
135 profilePubkeys.add(ev.pubkey);
136 }
137 }
138
139 // Sort messages by timestamp
140 for (const chan of chanMap.values()) {
141 chan.messages.sort((a, b) => a.created_at - b.created_at);
142 }
143
144 channels.set(chanMap);
145 loadProfiles([...profilePubkeys]);
146 } catch (err) {
147 console.error("[Channels] Error loading channels:", err);
148 } finally {
149 channelsLoading.set(false);
150 }
151 }
152
153 async function discoverChannels() {
154 showDiscovery = true;
155 discoveryLoading = true;
156 discoveryChannels = [];
157
158 try {
159 const events = await fetchEvents(
160 [{ kinds: [40], limit: 50 }],
161 { timeout: 10000, useCache: false }
162 );
163
164 for (const ev of (events || [])) {
165 if ($joinedChannels.has(ev.id)) continue;
166 try {
167 const meta = JSON.parse(ev.content);
168 discoveryChannels.push({
169 id: ev.id,
170 name: meta.name || ev.id.slice(0, 12) + '...',
171 about: meta.about || '',
172 picture: meta.picture || null,
173 creator: ev.pubkey,
174 });
175 } catch { /* skip */ }
176 }
177 } catch (err) {
178 console.error("[Channels] Discovery error:", err);
179 } finally {
180 discoveryLoading = false;
181 }
182 }
183
184 function handleJoinChannel(channelId) {
185 joinChannel(channelId);
186 showDiscovery = false;
187 loadJoinedChannels();
188 }
189
190 function confirmLeave(channelId) {
191 showLeaveConfirm = channelId;
192 }
193
194 function handleLeave() {
195 if (showLeaveConfirm) {
196 leaveChannel(showLeaveConfirm);
197 channels.update(map => {
198 map.delete(showLeaveConfirm);
199 return new Map(map);
200 });
201 showLeaveConfirm = null;
202 }
203 }
204
205 async function loadProfiles(pubkeys) {
206 for (const pk of pubkeys) {
207 if (profiles.has(pk)) continue;
208 try {
209 const profile = await fetchUserProfile(pk);
210 if (profile) {
211 profiles.set(pk, profile);
212 profiles = profiles;
213 }
214 } catch { /* skip */ }
215 }
216 }
217
218 function getDisplayName(pubkey) {
219 const p = profiles.get(pubkey);
220 if (p?.name) return p.name;
221 if (p?.display_name) return p.display_name;
222 return pubkey?.slice(0, 10) + '...';
223 }
224
225 function formatTime(ts) {
226 if (!ts) return '';
227 const d = new Date(ts * 1000);
228 const now = new Date();
229 if (d.toDateString() === now.toDateString()) {
230 return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
231 }
232 return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
233 }
234
235 function selectChannel(id) {
236 selectedChannel.set(id);
237 }
238
239 function backToList() {
240 selectedChannel.set(null);
241 }
242
243 async function sendChannelMessage() {
244 if (!messageInput.trim() || !$selectedChannel || !userSigner || sending) return;
245 sending = true;
246
247 try {
248 const content = messageInput.trim();
249 const channelId = $selectedChannel;
250
251 const event = {
252 kind: 42,
253 created_at: Math.floor(Date.now() / 1000),
254 tags: [
255 ["e", channelId, "", "root"],
256 ],
257 content,
258 };
259
260 const signedEvent = await userSigner.signEvent(event);
261 await nostrClient.publish(signedEvent);
262
263 // Add locally
264 channels.update(map => {
265 const chan = map.get(channelId);
266 if (chan && !chan.messages.find(m => m.id === signedEvent.id)) {
267 chan.messages.push(signedEvent);
268 map.set(channelId, { ...chan });
269 }
270 return new Map(map);
271 });
272
273 messageInput = "";
274 } catch (err) {
275 console.error("[Channels] Send error:", err);
276 } finally {
277 sending = false;
278 }
279 }
280
281 function handleKeydown(e) {
282 if (e.key === 'Enter' && !e.shiftKey) {
283 e.preventDefault();
284 sendChannelMessage();
285 }
286 }
287 </script>
288
289 <div class="channels" class:has-selected={$selectedChannel}>
290 <!-- Channel List -->
291 <div class="channel-list" class:hidden-mobile={$selectedChannel}>
292 <div class="channel-list-header">
293 <span>Channels</span>
294 <button class="discover-btn" on:click={discoverChannels}>+</button>
295 </div>
296
297 {#if !isLoggedIn}
298 <div class="channels-empty">Log in to use channels.</div>
299 {:else if $channelsLoading}
300 <div class="channels-loading"><div class="spinner"></div></div>
301 {:else if channelList.length === 0}
302 <div class="channels-empty">
303 No channels joined.
304 <button class="discover-link" on:click={discoverChannels}>Discover channels</button>
305 </div>
306 {:else}
307 {#each channelList as chan (chan.id)}
308 <!-- svelte-ignore a11y-click-events-have-key-events -->
309 <!-- svelte-ignore a11y-no-static-element-interactions -->
310 <div
311 class="channel-item"
312 class:active={$selectedChannel === chan.id}
313 on:click={() => selectChannel(chan.id)}
314 >
315 <div class="channel-icon">#</div>
316 <div class="channel-info">
317 <span class="channel-name">{chan.name}</span>
318 </div>
319 {#if chan.unreadCount > 0}
320 <span class="channel-badge">{chan.unreadCount}</span>
321 {/if}
322 <button
323 class="channel-leave"
324 on:click|stopPropagation={() => confirmLeave(chan.id)}
325 title="Leave channel"
326 >x</button>
327 </div>
328 {/each}
329 {/if}
330 </div>
331
332 <!-- Channel Thread -->
333 {#if $selectedChannel}
334 <div class="channel-thread">
335 <div class="thread-header">
336 <button class="back-btn" on:click={backToList}>
337 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
338 </button>
339 <div class="thread-channel-info">
340 <span class="thread-channel-name"># {currentMeta.name || $selectedChannel.slice(0, 12)}</span>
341 {#if currentMeta.about}
342 <span class="thread-channel-about">{currentMeta.about}</span>
343 {/if}
344 </div>
345 </div>
346
347 <div class="messages-container">
348 {#each currentMessages as msg (msg.id)}
349 <div class="channel-message">
350 <div class="msg-author">
351 <span class="msg-name">{getDisplayName(msg.pubkey)}</span>
352 <span class="msg-time">{formatTime(msg.created_at)}</span>
353 </div>
354 <div class="msg-content">{msg.content}</div>
355 </div>
356 {/each}
357 <div bind:this={messagesEnd}></div>
358 </div>
359
360 <div class="compose-bar">
361 <textarea
362 bind:value={messageInput}
363 on:keydown={handleKeydown}
364 placeholder="Message #{currentMeta.name || 'channel'}..."
365 rows="1"
366 disabled={sending}
367 ></textarea>
368 <button class="send-btn" on:click={sendChannelMessage} disabled={sending || !messageInput.trim()}>
369 <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>
370 </button>
371 </div>
372 </div>
373 {/if}
374
375 <!-- Discovery Overlay -->
376 {#if showDiscovery}
377 <!-- svelte-ignore a11y-click-events-have-key-events -->
378 <!-- svelte-ignore a11y-no-static-element-interactions -->
379 <div class="discovery-overlay" on:click={() => showDiscovery = false}>
380 <div class="discovery-panel" on:click|stopPropagation>
381 <div class="discovery-header">
382 <h3>Discover Channels</h3>
383 <button class="discovery-close" on:click={() => showDiscovery = false}>x</button>
384 </div>
385 {#if discoveryLoading}
386 <div class="channels-loading"><div class="spinner"></div></div>
387 {:else if discoveryChannels.length === 0}
388 <div class="channels-empty">No new channels found.</div>
389 {:else}
390 <div class="discovery-list">
391 {#each discoveryChannels as chan (chan.id)}
392 <div class="discovery-item">
393 <div class="discovery-info">
394 <span class="discovery-name"># {chan.name}</span>
395 {#if chan.about}
396 <span class="discovery-about">{chan.about}</span>
397 {/if}
398 </div>
399 <button class="join-btn" on:click={() => handleJoinChannel(chan.id)}>Join</button>
400 </div>
401 {/each}
402 </div>
403 {/if}
404 </div>
405 </div>
406 {/if}
407
408 <!-- Leave Confirmation -->
409 {#if showLeaveConfirm}
410 <!-- svelte-ignore a11y-click-events-have-key-events -->
411 <!-- svelte-ignore a11y-no-static-element-interactions -->
412 <div class="discovery-overlay" on:click={() => showLeaveConfirm = null}>
413 <div class="confirm-panel" on:click|stopPropagation>
414 <p>Leave this channel?</p>
415 <div class="confirm-actions">
416 <button class="cancel-btn" on:click={() => showLeaveConfirm = null}>Cancel</button>
417 <button class="leave-btn" on:click={handleLeave}>Leave</button>
418 </div>
419 </div>
420 </div>
421 {/if}
422 </div>
423
424 <style>
425 .channels {
426 display: flex;
427 width: 100%;
428 height: 100%;
429 overflow: hidden;
430 position: relative;
431 }
432
433 .channel-list {
434 width: 280px;
435 flex-shrink: 0;
436 border-right: 1px solid var(--border-color);
437 overflow-y: auto;
438 display: flex;
439 flex-direction: column;
440 }
441
442 .channel-list-header {
443 display: flex;
444 align-items: center;
445 justify-content: space-between;
446 padding: 0.6em 0.8em;
447 border-bottom: 1px solid var(--border-color);
448 font-weight: 600;
449 font-size: 0.85rem;
450 color: var(--text-color);
451 }
452
453 .discover-btn {
454 background: var(--button-bg);
455 border: 1px solid var(--border-color);
456 border-radius: 4px;
457 color: var(--text-color);
458 cursor: pointer;
459 font-size: 1rem;
460 width: 24px;
461 height: 24px;
462 display: flex;
463 align-items: center;
464 justify-content: center;
465 padding: 0;
466 }
467
468 .discover-btn:hover {
469 background: var(--button-hover-bg);
470 }
471
472 .channel-item {
473 display: flex;
474 align-items: center;
475 gap: 0.4em;
476 padding: 0.5em 0.8em;
477 cursor: pointer;
478 transition: background 0.15s;
479 }
480
481 .channel-item:hover {
482 background: var(--primary-bg);
483 }
484
485 .channel-item.active {
486 background: var(--primary-bg);
487 }
488
489 .channel-icon {
490 color: var(--text-muted);
491 font-weight: bold;
492 font-size: 0.9rem;
493 width: 20px;
494 text-align: center;
495 flex-shrink: 0;
496 }
497
498 .channel-info {
499 flex: 1;
500 min-width: 0;
501 }
502
503 .channel-name {
504 font-size: 0.85rem;
505 color: var(--text-color);
506 white-space: nowrap;
507 overflow: hidden;
508 text-overflow: ellipsis;
509 }
510
511 .channel-badge {
512 background: var(--primary);
513 color: #000;
514 font-size: 0.65rem;
515 font-weight: bold;
516 min-width: 16px;
517 height: 16px;
518 border-radius: 8px;
519 display: flex;
520 align-items: center;
521 justify-content: center;
522 padding: 0 3px;
523 flex-shrink: 0;
524 }
525
526 .channel-leave {
527 background: none;
528 border: none;
529 color: var(--text-muted);
530 cursor: pointer;
531 font-size: 0.75rem;
532 padding: 0.1em 0.3em;
533 border-radius: 3px;
534 opacity: 0;
535 transition: opacity 0.15s;
536 }
537
538 .channel-item:hover .channel-leave {
539 opacity: 1;
540 }
541
542 .channel-leave:hover {
543 background: var(--danger, #ef4444);
544 color: #fff;
545 }
546
547 .channel-thread {
548 flex: 1;
549 display: flex;
550 flex-direction: column;
551 min-width: 0;
552 }
553
554 .thread-header {
555 display: flex;
556 align-items: center;
557 gap: 0.5em;
558 padding: 0.6em 0.8em;
559 border-bottom: 1px solid var(--border-color);
560 background: var(--bg-color);
561 }
562
563 .back-btn {
564 display: none;
565 background: none;
566 border: none;
567 color: var(--text-muted);
568 cursor: pointer;
569 padding: 0.2em;
570 }
571
572 .back-btn svg {
573 width: 1.2em;
574 height: 1.2em;
575 }
576
577 .thread-channel-info {
578 display: flex;
579 flex-direction: column;
580 }
581
582 .thread-channel-name {
583 font-weight: 600;
584 font-size: 0.85rem;
585 color: var(--text-color);
586 }
587
588 .thread-channel-about {
589 font-size: 0.7rem;
590 color: var(--text-muted);
591 }
592
593 .messages-container {
594 flex: 1;
595 overflow-y: auto;
596 padding: 0.75em;
597 display: flex;
598 flex-direction: column;
599 gap: 0.5em;
600 }
601
602 .channel-message {
603 padding: 0.3em 0;
604 }
605
606 .msg-author {
607 display: flex;
608 align-items: baseline;
609 gap: 0.4em;
610 margin-bottom: 0.1em;
611 }
612
613 .msg-name {
614 font-weight: 600;
615 font-size: 0.8rem;
616 color: var(--text-color);
617 }
618
619 .msg-time {
620 font-size: 0.65rem;
621 color: var(--text-muted);
622 }
623
624 .msg-content {
625 font-size: 0.85rem;
626 color: var(--text-color);
627 line-height: 1.4;
628 word-break: break-word;
629 }
630
631 .compose-bar {
632 display: flex;
633 align-items: flex-end;
634 gap: 0.4em;
635 padding: 0.5em 0.75em;
636 border-top: 1px solid var(--border-color);
637 background: var(--bg-color);
638 }
639
640 .compose-bar textarea {
641 flex: 1;
642 background: var(--card-bg, #1a1a1a);
643 border: 1px solid var(--border-color);
644 border-radius: 8px;
645 padding: 0.5em 0.75em;
646 color: var(--text-color);
647 font-size: 0.85rem;
648 resize: none;
649 outline: none;
650 max-height: 120px;
651 font-family: inherit;
652 }
653
654 .compose-bar textarea::placeholder {
655 color: var(--text-muted);
656 }
657
658 .send-btn {
659 background: var(--primary);
660 border: none;
661 border-radius: 50%;
662 width: 34px;
663 height: 34px;
664 display: flex;
665 align-items: center;
666 justify-content: center;
667 cursor: pointer;
668 flex-shrink: 0;
669 color: #000;
670 transition: opacity 0.15s;
671 }
672
673 .send-btn:disabled {
674 opacity: 0.4;
675 cursor: not-allowed;
676 }
677
678 .send-btn svg {
679 width: 1em;
680 height: 1em;
681 }
682
683 .channels-empty {
684 text-align: center;
685 padding: 2em 1em;
686 color: var(--text-muted);
687 font-size: 0.85rem;
688 }
689
690 .discover-link {
691 display: block;
692 margin-top: 0.5em;
693 background: none;
694 border: none;
695 color: var(--primary);
696 cursor: pointer;
697 font-size: 0.85rem;
698 }
699
700 .discover-link:hover {
701 text-decoration: underline;
702 }
703
704 .channels-loading {
705 display: flex;
706 justify-content: center;
707 padding: 2em;
708 }
709
710 .spinner {
711 width: 24px;
712 height: 24px;
713 border: 2px solid var(--border-color);
714 border-top-color: var(--primary);
715 border-radius: 50%;
716 animation: spin 0.8s linear infinite;
717 }
718
719 @keyframes spin {
720 to { transform: rotate(360deg); }
721 }
722
723 /* Discovery overlay */
724 .discovery-overlay {
725 position: absolute;
726 inset: 0;
727 background: rgba(0, 0, 0, 0.5);
728 display: flex;
729 align-items: center;
730 justify-content: center;
731 z-index: 10;
732 }
733
734 .discovery-panel {
735 background: var(--card-bg, #1a1a1a);
736 border: 1px solid var(--border-color);
737 border-radius: 10px;
738 width: 90%;
739 max-width: 400px;
740 max-height: 70%;
741 display: flex;
742 flex-direction: column;
743 overflow: hidden;
744 }
745
746 .discovery-header {
747 display: flex;
748 align-items: center;
749 justify-content: space-between;
750 padding: 0.75em 1em;
751 border-bottom: 1px solid var(--border-color);
752 }
753
754 .discovery-header h3 {
755 margin: 0;
756 font-size: 0.9rem;
757 color: var(--text-color);
758 }
759
760 .discovery-close {
761 background: none;
762 border: none;
763 color: var(--text-muted);
764 cursor: pointer;
765 font-size: 1.1rem;
766 }
767
768 .discovery-list {
769 overflow-y: auto;
770 padding: 0.5em;
771 }
772
773 .discovery-item {
774 display: flex;
775 align-items: center;
776 gap: 0.5em;
777 padding: 0.5em;
778 border-radius: 6px;
779 }
780
781 .discovery-item:hover {
782 background: var(--primary-bg);
783 }
784
785 .discovery-info {
786 flex: 1;
787 min-width: 0;
788 }
789
790 .discovery-name {
791 font-size: 0.85rem;
792 font-weight: 600;
793 color: var(--text-color);
794 display: block;
795 }
796
797 .discovery-about {
798 font-size: 0.75rem;
799 color: var(--text-muted);
800 display: block;
801 white-space: nowrap;
802 overflow: hidden;
803 text-overflow: ellipsis;
804 }
805
806 .join-btn {
807 background: var(--primary);
808 border: none;
809 border-radius: 6px;
810 color: #000;
811 font-size: 0.8rem;
812 font-weight: 600;
813 padding: 0.3em 0.8em;
814 cursor: pointer;
815 flex-shrink: 0;
816 }
817
818 .join-btn:hover {
819 opacity: 0.9;
820 }
821
822 /* Confirm modal */
823 .confirm-panel {
824 background: var(--card-bg, #1a1a1a);
825 border: 1px solid var(--border-color);
826 border-radius: 10px;
827 padding: 1.5em;
828 text-align: center;
829 }
830
831 .confirm-panel p {
832 margin: 0 0 1em;
833 color: var(--text-color);
834 font-size: 0.9rem;
835 }
836
837 .confirm-actions {
838 display: flex;
839 gap: 0.5em;
840 justify-content: center;
841 }
842
843 .cancel-btn {
844 background: var(--button-bg);
845 border: 1px solid var(--border-color);
846 border-radius: 6px;
847 color: var(--text-color);
848 padding: 0.4em 1em;
849 cursor: pointer;
850 font-size: 0.85rem;
851 }
852
853 .leave-btn {
854 background: var(--danger, #ef4444);
855 border: none;
856 border-radius: 6px;
857 color: #fff;
858 padding: 0.4em 1em;
859 cursor: pointer;
860 font-size: 0.85rem;
861 }
862
863 /* Mobile */
864 @media (max-width: 640px) {
865 .channel-list {
866 width: 100%;
867 border-right: none;
868 }
869
870 .channel-list.hidden-mobile {
871 display: none;
872 }
873
874 .channels:not(.has-selected) .channel-thread {
875 display: none;
876 }
877
878 .channels.has-selected .channel-list {
879 display: none;
880 }
881
882 .back-btn {
883 display: flex;
884 }
885 }
886 </style>
887