Files
zep/src/chat/ChatContainer.svelte
T
2026-04-21 11:16:18 -04:00

511 lines
17 KiB
Svelte

<script lang="ts">
import { useSpacetimeDB } from "spacetimedb/svelte";
import { setContext, onMount, untrack } from "svelte";
import { ChatService } from "./services/chat.svelte";
import { WebRTCService } from "./services/webrtc/webrtc.svelte";
import ServerList from "./components/ServerList.svelte";
import ChannelList from "./components/ChannelList.svelte";
import MessageList from "./components/MessageList.svelte";
import ChatInput from "./components/ChatInput.svelte";
import MemberList from "./components/MemberList.svelte";
import ThreadView from "./components/ThreadView.svelte";
import ServerDiscovery from "./components/ServerDiscovery.svelte";
import VideoGrid from "./components/VideoGrid.svelte";
import Avatar from "./components/Avatar.svelte";
import SettingsPanel from "./components/SettingsPanel.svelte";
import ServerSettingsPanel from "./components/ServerSettingsPanel.svelte";
import ImageViewer from "./components/ImageViewer.svelte";
import ProfileModal from "./components/ProfileModal.svelte";
import UserContextMenu from "./components/UserContextMenu.svelte";
import ConfirmModal from "./components/ConfirmModal.svelte";
let { onShowServerSettings: _onShowServerSettings } = $props<{ onShowServerSettings: () => void }>();
const spacetime = useSpacetimeDB();
// identity is guaranteed to be non-null here because of the guard in SpacetimeProvider
const chat = new ChatService($spacetime.identity!);
const webrtc = new WebRTCService($spacetime.identity, undefined);
$effect(() => {
if ($spacetime.identity) {
chat.identity = $spacetime.identity;
webrtc.identity = $spacetime.identity;
}
// Sync voice channel status
const voiceChan = chat.connectedVoiceChannel;
if (voiceChan) {
webrtc.connectedChannelId = voiceChan.id;
} else {
webrtc.connectedChannelId = undefined;
}
});
setContext("chat", chat);
setContext("webrtc", webrtc);
let showSettings = $state(false);
let showMemberList = $state(true);
let showSidebar = $state(true); // Toggle for mobile
const isMobile = $state({ value: false });
onMount(() => {
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024;
if (isMobile.value) {
showSidebar = false;
showMemberList = false;
} else {
showSidebar = true;
showMemberList = true;
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
});
// Auto-hide sidebar when switching channels on mobile
$effect(() => {
const _ = chat.activeChannelId;
if (isMobile.value) {
untrack(() => {
showSidebar = false;
});
}
});
function closeSidebars() {
if (isMobile.value) {
showSidebar = false;
showMemberList = false;
}
}
</script>
<div class="chat-container">
<!-- 1. Left Sidebar (Servers + Channels) -->
<aside class="left-sidebar-wrapper" class:visible={showSidebar}>
<div class="left-sidebar-top">
<ServerList onShowServerSettings={_onShowServerSettings} />
<ChannelList />
</div>
{#if chat.connectedVoiceChannel}
<div class="voice-status-bar">
<div class="voice-info">
<div class="voice-connected-text">
<i class="fas fa-signal"></i> Voice Connected
</div>
<div class="voice-channel-name">
{chat.connectedVoiceChannel.name} / {chat.connectedVoiceServer?.name || "Server"}
</div>
</div>
<div class="voice-status-actions">
<button
class="icon-btn {webrtc.isSharingScreen ? 'active danger' : ''}"
onclick={() => webrtc.isSharingScreen ? webrtc.stopScreenShare() : webrtc.startScreenShare()}
title={webrtc.isSharingScreen ? "Stop Screen Share" : "Share Screen"}
>
<i class="fas fa-desktop"></i>
</button>
<button
class="icon-btn danger"
onclick={() => chat.handleLeaveVoice()}
title="Disconnect"
>
<i class="fas fa-phone-slash"></i>
</button>
</div>
</div>
{/if}
<div class="user-info-bar">
<div
class="user-info-main"
onclick={(e) => {
if (chat.currentUser) {
chat.userContextMenu = { x: e.clientX, y: e.clientY, user: chat.currentUser };
}
}}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && (chat.userContextMenu = null)}
>
<Avatar user={chat.currentUser} isTalking={webrtc.localMedia.isTalking} />
<div class="user-details">
<div class="user-display-name">{chat.currentUser?.name || "Connecting..."}</div>
<div class="user-status">
<div class="status-dot green"></div>
Online
</div>
</div>
</div>
<div class="user-actions">
<button
class="icon-btn {webrtc.localMedia.isMuted ? 'active danger' : ''}"
onclick={() => webrtc.toggleMute()}
title={webrtc.localMedia.isMuted ? "Unmute" : "Mute"}
>
<i class="fas {webrtc.localMedia.isMuted ? 'fa-microphone-slash' : 'fa-microphone'}"></i>
</button>
<button
class="icon-btn"
onclick={() => webrtc.toggleDeafen()}
title={webrtc.localMedia.isDeafened ? "Undeafen" : "Deafen"}
>
{#if webrtc.localMedia.isDeafened}
<svg viewBox="0 0 512 512" class="deafen-indicator-svg" fill="currentColor">
<path d="M0 256C0 114.6 114.6 0 256 0S512 114.6 512 256V416c0 35.3-28.7 64-64 64H384c-35.3 0-64-28.7-64-64V320c0-35.3 28.7-64 64-64h64V256c0-106-86-192-192-192S64 150 64 256v64h64c35.3 0 64 28.7 64 64v96c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256z"/>
<rect x="-50" y="236" width="612" height="40" transform="rotate(-45 256 256)" fill="currentColor" rx="4" />
</svg>
{:else}
<i class="fas fa-headphones deafen-indicator"></i>
{/if}
</button>
<button
class="icon-btn"
onclick={() => (showSettings = true)}
title="User Settings"
>
<i class="fas fa-cog"></i>
</button>
</div>
</div>
</aside>
<!-- 2. Main Content (Backdrop + Header + Chat/Video) -->
<main class="main-content">
{#if isMobile.value && (showSidebar || showMemberList)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mobile-backdrop" onclick={closeSidebars}></div>
{/if}
<header class="chat-header">
<button
class="icon-btn mobile-toggle"
onclick={() => (showSidebar = !showSidebar)}
title="Toggle Navigation"
>
<i class="fas fa-bars"></i>
</button>
<div class="header-info">
{#if chat.activeServer}
<span class="header-icon"><i class="fas {chat.isActiveChannelVoice ? 'fa-volume-up' : 'fa-hashtag'}"></i></span>
<h2 class="header-title">{chat.activeChannel?.name || "Select a channel"}</h2>
{#if chat.activeChannelId !== undefined}
<button
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'subscribed' : ''}"
onclick={() => chat.toggleChannelNotifications(chat.activeChannelId!)}
title={chat.isChannelNotificationsEnabled(chat.activeChannelId) ? "Mute Channel" : "Unmute Channel"}
aria-label="Toggle notifications"
>
<i class="fas {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'fa-bell' : 'fa-bell-slash'}"></i>
</button>
{/if}
{:else if chat.activeChannelId}
{@const dm = chat.activeDms.find(d => d.channelId === chat.activeChannelId)}
{#if dm}
{@const myIdHex = chat.identity?.toHexString()}
{@const otherIdentity = dm.sender.toHexString() === myIdHex ? dm.recipient : dm.sender}
{@const recipient = chat.users.find(u => u.identity.toHexString() === otherIdentity.toHexString())}
{#if recipient}
<Avatar user={recipient} size="tiny" />
<h2 class="header-title">{recipient.name || "Unknown User"}</h2>
<button
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'subscribed' : ''}"
onclick={() => chat.toggleChannelNotifications(chat.activeChannelId!)}
title={chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? "Mute DM" : "Unmute DM"}
aria-label="Toggle notifications"
>
<i class="fas {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'fa-bell' : 'fa-bell-slash'}"></i>
</button>
{/if}
{/if}
{:else}
<h2 class="header-title">Select a conversation</h2>
{/if}
</div>
<div class="header-actions">
{#if chat.activeServer}
<button
class="icon-btn {showMemberList ? 'active' : ''}"
onclick={() => (showMemberList = !showMemberList)}
title="Toggle Member List"
>
<i class="fas fa-users"></i>
</button>
{/if}
</div>
</header>
<div class="chat-view-container">
{#if chat.isActiveChannelVoice}
<VideoGrid />
{:else}
<MessageList />
<div class="chat-input-container">
<div class="typing-indicator">
{#if chat.typingUsers.length > 0}
<div class="dots"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
<span class="typing-text">
<strong>{chat.typingUsers[0].name || "Someone"}</strong>
{chat.typingUsers.length > 1 ? ` and ${chat.typingUsers.length - 1} more` : ""}
{chat.typingUsers.length === 1 ? "is" : "are"} typing...
</span>
{/if}
</div>
<ChatInput
activeChannelId={chat.activeChannelId}
activeThreadId={null}
isFullyAuthenticated={chat.isFullyAuthenticated}
/>
</div>
{/if}
</div>
</main>
<!-- 3. Right Sidebar (Members or Threads) -->
<aside class="right-sidebar-wrapper" class:visible={(showMemberList && chat.activeServer) || chat.activeThreadId || chat.pendingThreadParentMessageId}>
{#if chat.activeThreadId || chat.pendingThreadParentMessageId}
<ThreadView
activeThreadId={chat.activeThreadId}
setActiveThreadId={(id) => (chat.activeThreadId = id)}
pendingThreadParentMessageId={chat.pendingThreadParentMessageId}
setPendingThreadParentMessageId={(id) => (chat.pendingThreadParentMessageId = id)}
activeChannelId={chat.activeChannelId}
activeServer={chat.activeServer}
isFullyAuthenticated={chat.isFullyAuthenticated}
users={chat.users}
identity={chat.identity}
allThreads={chat.allThreads}
allMessages={chat.allMessages}
allImages={chat.images}
/>
{:else if showMemberList && chat.activeServer}
<MemberList />
{/if}
</aside>
<!-- Overlays & Modals -->
{#if chat.showDiscoveryModal} <ServerDiscovery /> {/if}
{#if showSettings} <SettingsPanel currentUser={chat.currentUser} onClose={() => (showSettings = false)} /> {/if}
{#if chat.showServerSettings} <ServerSettingsPanel onClose={() => (chat.showServerSettings = false)} /> {/if}
{#if chat.viewingImageId}
{@const image = chat.images.find(img => img.id === chat.viewingImageId)}
{#if image} <ImageViewer {image} onClose={() => (chat.viewingImageId = null)} /> {/if}
{/if}
{#if chat.viewingProfileUser}
<ProfileModal user={chat.viewingProfileUser} onClose={() => (chat.viewingProfileUser = null)} />
{/if}
{#if chat.userContextMenu}
<UserContextMenu
{...chat.userContextMenu}
onClose={() => (chat.userContextMenu = null)}
onAction={closeSidebars}
/>
{/if}
{#if chat.confirmModal}
<ConfirmModal {...chat.confirmModal} onConfirm={() => { chat.confirmModal?.onConfirm(); chat.confirmModal = null; }} onCancel={() => { chat.confirmModal?.onCancel?.(); chat.confirmModal = null; }} />
{/if}
</div>
<style>
.chat-container {
display: flex;
height: 100vh;
width: 100vw;
background-color: var(--background-tertiary);
overflow: hidden;
position: relative;
}
/* SIDEBARS */
.left-sidebar-wrapper, .right-sidebar-wrapper {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--background-secondary);
flex-shrink: 0;
overflow: hidden;
}
.left-sidebar-wrapper {
width: calc(var(--server-sidebar-width) + var(--channel-sidebar-width));
border-right: 1px solid rgba(0, 0, 0, 0.2);
}
.right-sidebar-wrapper {
width: 240px;
border-left: 1px solid rgba(0, 0, 0, 0.2);
}
/* Hidden states for desktop */
@media (min-width: 1025px) {
.left-sidebar-wrapper:not(.visible) { display: none; }
.right-sidebar-wrapper:not(.visible) { display: none; }
}
.left-sidebar-top {
display: flex;
flex: 1;
min-height: 0;
}
/* MAIN CONTENT */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--background-primary);
min-width: 0;
position: relative;
height: 100%;
}
.chat-header {
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
z-index: 10;
flex-shrink: 0;
background-color: var(--background-primary);
}
.header-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.header-title {
margin: 0;
font-size: 1rem;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--header-primary);
}
.chat-view-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* CRITICAL for scrolling children */
background-color: var(--background-primary);
}
.chat-input-container {
padding: 0 16px 24px 16px;
flex-shrink: 0;
background-color: var(--background-primary);
}
/* RESPONSIVE MOBILE LOGIC */
.mobile-toggle { display: none; }
.mobile-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 90;
cursor: pointer;
}
@media (max-width: 1024px) {
.mobile-toggle { display: flex; margin-right: 8px; }
.left-sidebar-wrapper, .right-sidebar-wrapper {
position: absolute;
top: 0;
bottom: 0;
z-index: 1000;
transition: transform 0.2s ease;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
.left-sidebar-wrapper {
left: 0;
transform: translateX(-100%);
}
.left-sidebar-wrapper.visible {
transform: translateX(0);
}
.right-sidebar-wrapper {
right: 0;
transform: translateX(100%);
}
.right-sidebar-wrapper.visible {
transform: translateX(0);
}
}
/* SHARED PARTS */
.voice-status-bar {
background-color: var(--background-tertiary);
padding: 8px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.voice-info { display: flex; flex-direction: column; }
.voice-connected-text { color: var(--status-positive); font-size: 0.8rem; font-weight: bold; }
.voice-channel-name { color: var(--text-muted); font-size: 0.75rem; }
.voice-status-actions { display: flex; gap: 4px; }
.user-info-bar {
background-color: var(--background-tertiary);
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.user-info-main { display: flex; align-items: center; gap: 8px; min-width: 0; cursor: pointer; }
.user-details { min-width: 0; }
.user-display-name { font-size: 0.85rem; font-weight: 600; color: var(--header-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-status { font-size: 0.7rem; color: var(--text-muted); display: flex; align-items: center; gap: 4px; }
.user-actions { display: flex; gap: 2px; }
.typing-indicator {
height: 24px;
font-size: 0.75rem;
color: var(--text-normal);
display: flex;
align-items: center;
gap: 8px;
}
.dots { display: flex; gap: 2px; }
.dot { width: 4px; height: 4px; background-color: var(--text-muted); border-radius: 50%; animation: typing-dot 1.4s infinite ease-in-out; }
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-dot {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-4px); }
}
</style>