clickclack/apps/web/src/ChatApp.svelte
2026-05-08 08:11:08 +01:00

473 lines
16 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { api } from "./lib/api";
import { markdown, time } from "./lib/format";
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
let user: User | null = null;
let workspaces: Workspace[] = [];
let channels: Channel[] = [];
let directConversations: DirectConversation[] = [];
let messages: Message[] = [];
let replies: Message[] = [];
let selectedWorkspaceID = "";
let selectedChannelID = "";
let selectedDirectID = "";
let selectedThread: Message | null = null;
let selectedThreadState: ThreadState | null = null;
let messageBody = "";
let replyBody = "";
let workspaceName = "";
let channelName = "";
let directMemberID = "";
let searchQuery = "";
let searchResults: SearchResult[] = [];
let pendingUpload: Upload | null = null;
let status = "loading";
let socket: WebSocket | null = null;
let reconnectTimer: number | undefined;
$: selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceID);
$: selectedChannel = channels.find((channel) => channel.id === selectedChannelID);
$: selectedDirect = directConversations.find((conversation) => conversation.id === selectedDirectID);
onMount(() => {
void boot();
});
onDestroy(() => {
socket?.close();
if (reconnectTimer) window.clearTimeout(reconnectTimer);
});
async function boot() {
try {
const me = await api<{ user: User }>("/api/me");
user = me.user;
await loadWorkspaces();
status = "ready";
} catch (error) {
status = error instanceof Error ? error.message : "Could not load ClickClack";
}
}
async function loadWorkspaces() {
const data = await api<{ workspaces: Workspace[] }>("/api/workspaces");
workspaces = data.workspaces;
selectedWorkspaceID = selectedWorkspaceID || workspaces[0]?.id || "";
await loadChannels();
await loadDirectConversations();
connectRealtime();
}
async function createWorkspace() {
if (!workspaceName.trim()) return;
const data = await api<{ workspace: Workspace }>("/api/workspaces", {
method: "POST",
body: JSON.stringify({ name: workspaceName })
});
workspaceName = "";
workspaces = [...workspaces, data.workspace];
selectedWorkspaceID = data.workspace.id;
await loadChannels();
await loadDirectConversations();
connectRealtime();
}
async function loadChannels() {
if (!selectedWorkspaceID) return;
const data = await api<{ channels: Channel[] }>(`/api/workspaces/${selectedWorkspaceID}/channels`);
channels = data.channels;
selectedChannelID = channels.find((channel) => channel.id === selectedChannelID)?.id || channels[0]?.id || "";
selectedThread = null;
replies = [];
await loadMessages();
}
async function createChannel() {
if (!selectedWorkspaceID || !channelName.trim()) return;
const data = await api<{ channel: Channel }>(`/api/workspaces/${selectedWorkspaceID}/channels`, {
method: "POST",
body: JSON.stringify({ name: channelName, kind: "public" })
});
channelName = "";
channels = [...channels, data.channel];
selectedChannelID = data.channel.id;
await loadMessages();
}
async function loadMessages() {
if (selectedDirectID) {
const data = await api<{ messages: Message[] }>(`/api/dms/${selectedDirectID}/messages`);
messages = data.messages;
return;
}
if (!selectedChannelID) {
messages = [];
return;
}
const data = await api<{ messages: Message[] }>(`/api/channels/${selectedChannelID}/messages`);
messages = data.messages;
}
async function sendMessage() {
const body = messageBody.trim();
if (!body || (!selectedChannelID && !selectedDirectID)) return;
messageBody = "";
const path = selectedDirectID ? `/api/dms/${selectedDirectID}/messages` : `/api/channels/${selectedChannelID}/messages`;
const data = await api<{ message: Message }>(path, {
method: "POST",
body: JSON.stringify({ body })
});
if (pendingUpload) {
await api(`/api/messages/${data.message.id}/attachments`, {
method: "POST",
body: JSON.stringify({ upload_id: pendingUpload.id })
});
pendingUpload = null;
}
if (!messages.some((message) => message.id === data.message.id)) {
messages = [...messages, data.message];
}
}
async function openThread(message: Message) {
selectedThread = message;
const data = await api<{ root: Message; replies: Message[]; thread_state: ThreadState }>(`/api/messages/${message.id}/thread`);
selectedThread = data.root;
replies = data.replies;
selectedThreadState = data.thread_state;
}
async function sendReply() {
const body = replyBody.trim();
if (!body || !selectedThread) return;
replyBody = "";
const data = await api<{ message: Message; thread_state: ThreadState }>(`/api/messages/${selectedThread.id}/thread/replies`, {
method: "POST",
body: JSON.stringify({ body })
});
if (!replies.some((reply) => reply.id === data.message.id)) {
replies = [...replies, data.message];
}
selectedThreadState = data.thread_state;
}
async function searchMessages() {
if (!selectedWorkspaceID || !searchQuery.trim()) {
searchResults = [];
return;
}
const data = await api<{ results: SearchResult[] }>(
`/api/search?workspace_id=${encodeURIComponent(selectedWorkspaceID)}&q=${encodeURIComponent(searchQuery.trim())}`
);
searchResults = data.results;
}
async function uploadFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file || !selectedWorkspaceID) return;
const form = new FormData();
form.set("workspace_id", selectedWorkspaceID);
form.set("file", file);
const data = await api<{ upload: Upload }>("/api/uploads", { method: "POST", body: form });
pendingUpload = data.upload;
input.value = "";
}
async function loadDirectConversations() {
if (!selectedWorkspaceID) return;
const data = await api<{ conversations: DirectConversation[] }>(`/api/dms?workspace_id=${selectedWorkspaceID}`);
directConversations = data.conversations;
}
async function createDirectConversation() {
if (!selectedWorkspaceID || !directMemberID.trim()) return;
const data = await api<{ conversation: DirectConversation }>("/api/dms", {
method: "POST",
body: JSON.stringify({ workspace_id: selectedWorkspaceID, member_ids: [directMemberID.trim()] })
});
directMemberID = "";
directConversations = [...directConversations, data.conversation];
selectedDirectID = data.conversation.id;
selectedChannelID = "";
selectedThread = null;
await loadMessages();
}
function connectRealtime() {
socket?.close();
if (!selectedWorkspaceID) return;
const lastCursor = localStorage.getItem(`clickclack:${selectedWorkspaceID}:cursor`) || "";
const url = new URL("/api/realtime/ws", window.location.href);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("workspace_id", selectedWorkspaceID);
if (lastCursor) url.searchParams.set("after_cursor", lastCursor);
socket = new WebSocket(url);
socket.addEventListener("message", (message) => {
const event = JSON.parse(String(message.data)) as RealtimeEvent;
if (event.cursor) localStorage.setItem(`clickclack:${selectedWorkspaceID}:cursor`, event.cursor);
void handleEvent(event);
});
socket.addEventListener("close", () => {
reconnectTimer = window.setTimeout(connectRealtime, 1200);
});
}
async function handleEvent(event: RealtimeEvent) {
if ((event.type === "channel.created" || event.type === "channel.updated") && event.workspace_id === selectedWorkspaceID) {
await loadChannels();
return;
}
if (
(event.channel_id === selectedChannelID || event.payload.direct_conversation_id === selectedDirectID) &&
(event.type === "message.created" || event.type === "message.updated" || event.type === "message.deleted")
) {
await loadMessages();
}
const rootID = event.payload.root_message_id || event.payload.message_id;
if (selectedThread && rootID === selectedThread.id) {
await openThread(selectedThread);
}
}
</script>
<svelte:head>
<meta name="color-scheme" content="light dark" />
</svelte:head>
<div class="shell">
<aside class="sidebar" aria-label="Workspace and channel navigation">
<div class="brand">
<div class="mark">cc</div>
<div>
<strong>ClickClack</strong>
<span>{user?.display_name || "local"}</span>
</div>
</div>
<section>
<div class="section-title">Workspaces</div>
<div class="nav-list">
{#each workspaces as workspace}
<button
class:active={workspace.id === selectedWorkspaceID}
onclick={async () => {
selectedWorkspaceID = workspace.id;
await loadChannels();
connectRealtime();
}}
>
{workspace.name}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createWorkspace();
}}
>
<input bind:value={workspaceName} placeholder="New workspace" aria-label="New workspace name" />
</form>
</section>
<section>
<div class="section-title">Channels</div>
<div class="nav-list channels">
{#each channels as channel}
<button
class:active={channel.id === selectedChannelID}
onclick={async () => {
selectedChannelID = channel.id;
selectedThread = null;
await loadMessages();
}}
>
<span>#</span>{channel.name}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createChannel();
}}
>
<input bind:value={channelName} placeholder="New channel" aria-label="New channel name" />
</form>
</section>
<section>
<div class="section-title">DMs</div>
<div class="nav-list channels">
{#each directConversations as conversation}
<button
class:active={conversation.id === selectedDirectID}
onclick={async () => {
selectedDirectID = conversation.id;
selectedChannelID = "";
selectedThread = null;
await loadMessages();
}}
>
<span>@</span>{conversation.members.map((member) => member.display_name).join(", ")}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createDirectConversation();
}}
>
<input bind:value={directMemberID} placeholder="Member user ID" aria-label="DM member user ID" />
</form>
</section>
</aside>
<main class="timeline">
<header class="topbar">
<div>
<p>{selectedWorkspace?.name || "Workspace"}</p>
<h1>{selectedDirect ? "@" + selectedDirect.members.map((member) => member.display_name).join(", ") : "#" + (selectedChannel?.name || "general")}</h1>
</div>
<form
class="search"
onsubmit={(event) => {
event.preventDefault();
void searchMessages();
}}
>
<input bind:value={searchQuery} placeholder="Search" aria-label="Search messages" />
<button type="submit">Search</button>
</form>
<div class="connection" data-state={socket?.readyState === WebSocket.OPEN ? "live" : "idle"}>
{socket?.readyState === WebSocket.OPEN ? "live" : status}
</div>
</header>
{#if searchResults.length > 0}
<div class="search-results" aria-label="Search results">
{#each searchResults as result (result.message.id)}
<button
onclick={async () => {
searchResults = [];
if (result.message.channel_id) {
selectedChannelID = result.message.channel_id;
selectedDirectID = "";
await loadMessages();
}
if (result.message.direct_conversation_id) {
selectedDirectID = result.message.direct_conversation_id;
selectedChannelID = "";
await loadMessages();
}
}}
>
<strong>{result.message.author?.display_name || "Local User"}</strong>
<span>{result.message.body}</span>
</button>
{/each}
</div>
{/if}
<div class="messages" aria-live="polite">
{#if messages.length === 0}
<div class="empty">
<strong>Quiet tide.</strong>
<span>Start with Markdown. Threads open from any root message.</span>
</div>
{/if}
{#each messages as message (message.id)}
<article class="message" class:selected={selectedThread?.id === message.id}>
<div class="avatar">{message.author?.display_name?.slice(0, 1) || "c"}</div>
<div class="message-body">
<header>
<strong>{message.author?.display_name || "Local User"}</strong>
<time>{time(message.created_at)}</time>
</header>
<div class="markdown">{@html markdown(message.body)}</div>
<button class="thread-button" onclick={() => openThread(message)}>Open thread</button>
</div>
</article>
{/each}
</div>
<form
class="composer"
onsubmit={(event) => {
event.preventDefault();
void sendMessage();
}}
>
<textarea bind:value={messageBody} rows="3" placeholder="Message with Markdown" aria-label="Message body"></textarea>
<div class="composer-actions">
<label class="upload-button">
<input type="file" aria-label="Upload file" onchange={uploadFile} />
Upload
</label>
{#if pendingUpload}
<span class="pending-upload">{pendingUpload.filename}</span>
{/if}
<button type="button" onclick={() => void sendMessage()}>Send</button>
</div>
</form>
</main>
<aside class="thread" class:open={selectedThread} aria-label="Thread pane">
{#if selectedThread}
<header>
<div>
<p>Thread</p>
<strong>{selectedThreadState?.reply_count || replies.length} replies</strong>
</div>
<button
aria-label="Close thread"
onclick={() => {
selectedThread = null;
replies = [];
}}
>
x
</button>
</header>
<article class="thread-root">
<strong>{selectedThread.author?.display_name || "Local User"}</strong>
<div class="markdown">{@html markdown(selectedThread.body)}</div>
</article>
<div class="reply-list">
{#each replies as reply (reply.id)}
<article class="reply">
<header>
<strong>{reply.author?.display_name || "Local User"}</strong>
<time>{time(reply.created_at)}</time>
</header>
<div class="markdown">{@html markdown(reply.body)}</div>
</article>
{/each}
</div>
<form
class="reply-composer"
onsubmit={(event) => {
event.preventDefault();
void sendReply();
}}
>
<textarea bind:value={replyBody} rows="3" placeholder="Reply in thread" aria-label="Reply body"></textarea>
<button type="button" onclick={() => void sendReply()}>Reply</button>
</form>
{:else}
<div class="thread-empty">
<strong>No thread open</strong>
<span>Pick a message to keep the side conversation tidy.</span>
</div>
{/if}
</aside>
</div>