511 lines
17 KiB
Svelte
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>
|