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