Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
apps/api/internal/webassets/dist/assets/*.js -whitespace
12 changes: 12 additions & 0 deletions apps/api/internal/httpapi/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/go-chi/chi/v5"
"github.com/openclaw/clickclack/apps/api/internal/store"
)

func formInt(r *http.Request, key string) int {
v, err := strconv.Atoi(r.FormValue(key))
if err != nil || v < 0 {
return 0
}
return v
}

func (s *Server) search(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
Expand Down Expand Up @@ -68,6 +77,9 @@ func (s *Server) createUpload(w http.ResponseWriter, r *http.Request) {
Filename: filepath.Base(header.Filename),
ContentType: contentType,
ByteSize: size,
Width: formInt(r, "width"),
Height: formInt(r, "height"),
DurationMS: formInt(r, "duration_ms"),
StoragePath: tmp.Name(),
})
writeResultStatus(w, http.StatusCreated, map[string]any{"upload": upload}, err)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE uploads ADD COLUMN width INTEGER NOT NULL DEFAULT 0;
ALTER TABLE uploads ADD COLUMN height INTEGER NOT NULL DEFAULT 0;
ALTER TABLE uploads ADD COLUMN duration_ms INTEGER NOT NULL DEFAULT 0;
15 changes: 9 additions & 6 deletions apps/api/internal/store/sqlite/uploads.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,22 @@ func (s *Store) CreateUpload(ctx context.Context, input store.CreateUploadInput)
Filename: input.Filename,
ContentType: input.ContentType,
ByteSize: input.ByteSize,
Width: input.Width,
Height: input.Height,
DurationMS: input.DurationMS,
StoragePath: input.StoragePath,
CreatedAt: now(),
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO uploads (id, workspace_id, owner_id, filename, content_type, byte_size, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
upload.ID, upload.WorkspaceID, upload.OwnerID, upload.Filename, upload.ContentType, upload.ByteSize, upload.StoragePath, upload.CreatedAt)
INSERT INTO uploads (id, workspace_id, owner_id, filename, content_type, byte_size, width, height, duration_ms, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
upload.ID, upload.WorkspaceID, upload.OwnerID, upload.Filename, upload.ContentType, upload.ByteSize, upload.Width, upload.Height, upload.DurationMS, upload.StoragePath, upload.CreatedAt)
return upload, err
}

func (s *Store) GetUpload(ctx context.Context, uploadID, userID string) (store.Upload, error) {
upload, err := scanUpload(s.db.QueryRowContext(ctx, `
SELECT id, workspace_id, owner_id, filename, content_type, byte_size, storage_path, created_at
SELECT id, workspace_id, owner_id, filename, content_type, byte_size, width, height, duration_ms, storage_path, created_at
FROM uploads
WHERE id = ?`, uploadID))
if err != nil {
Expand Down Expand Up @@ -71,14 +74,14 @@ func (s *Store) AttachUpload(ctx context.Context, input store.AttachUploadInput)

func scanUpload(row scanner) (store.Upload, error) {
var upload store.Upload
err := row.Scan(&upload.ID, &upload.WorkspaceID, &upload.OwnerID, &upload.Filename, &upload.ContentType, &upload.ByteSize, &upload.StoragePath, &upload.CreatedAt)
err := row.Scan(&upload.ID, &upload.WorkspaceID, &upload.OwnerID, &upload.Filename, &upload.ContentType, &upload.ByteSize, &upload.Width, &upload.Height, &upload.DurationMS, &upload.StoragePath, &upload.CreatedAt)
return upload, err
}

func (s *Store) hydrateAttachments(ctx context.Context, messages []store.Message) ([]store.Message, error) {
for i := range messages {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, u.workspace_id, u.owner_id, u.filename, u.content_type, u.byte_size, u.storage_path, u.created_at
SELECT u.id, u.workspace_id, u.owner_id, u.filename, u.content_type, u.byte_size, u.width, u.height, u.duration_ms, u.storage_path, u.created_at
FROM uploads u
JOIN message_attachments ma ON ma.upload_id = u.id
WHERE ma.message_id = ?
Expand Down
6 changes: 6 additions & 0 deletions apps/api/internal/store/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ type Upload struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ByteSize int64 `json:"byte_size"`
Width int `json:"width"`
Height int `json:"height"`
DurationMS int `json:"duration_ms"`
StoragePath string `json:"storage_path,omitempty"`
CreatedAt string `json:"created_at"`
}
Expand All @@ -165,6 +168,9 @@ type CreateUploadInput struct {
Filename string
ContentType string
ByteSize int64
Width int
Height int
DurationMS int
StoragePath string
}

Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions apps/api/internal/webassets/dist/assets/index-BkjsE07i.css

Large diffs are not rendered by default.

72 changes: 0 additions & 72 deletions apps/api/internal/webassets/dist/assets/index-CJGH_9qR.js

This file was deleted.

72 changes: 72 additions & 0 deletions apps/api/internal/webassets/dist/assets/index-DB4gtESA.js

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion apps/api/internal/webassets/dist/assets/index-DqdK3eqN.css

This file was deleted.

4 changes: 2 additions & 2 deletions apps/api/internal/webassets/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClickClack</title>
<script type="module" crossorigin src="/assets/index-CJGH_9qR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DqdK3eqN.css">
<script type="module" crossorigin src="/assets/index-DB4gtESA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkjsE07i.css">
</head>
<body>
<div id="app"></div>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"dompurify": "^3.3.0",
"marked": "^17.0.1",
"svelte": "^5.45.6",
"virtua": "^0.49.1",
"vite": "^7.2.4"
},
"devDependencies": {
Expand Down
129 changes: 69 additions & 60 deletions apps/web/src/ChatApp.svelte
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
<script lang="ts">
import { onDestroy, onMount, tick } from "svelte";
import { APIError, api } from "./lib/api";
import { probeMediaDimensions } from "./lib/media";
import { gifLibrary } from "./lib/gifs";
import { collectRecentPeople, dmTitle } from "./lib/chat/people";
import { redirectTypingToComposer } from "./lib/chat/typeToFocus";
import { connectRealtime, type RealtimeConnection } from "./lib/realtime.svelte";
import ChatComposer from "./components/composer/ChatComposer.svelte";
import ImageViewer from "./components/media/ImageViewer.svelte";
import MessageList from "./components/messages/MessageList.svelte";
import MessageList, { type MessageListHandle, type MessageListState } from "./components/messages/MessageList.svelte";
import GuildRail from "./components/navigation/GuildRail.svelte";
import Sidebar from "./components/navigation/Sidebar.svelte";
import ProfilePane from "./components/profile/ProfilePane.svelte";
import ProfileSettingsModal from "./components/profile/ProfileSettingsModal.svelte";
import SearchResults from "./components/search/SearchResults.svelte";
import ThreadEmptyState from "./components/thread/ThreadEmptyState.svelte";
import ThreadPanel from "./components/thread/ThreadPanel.svelte";
import Topbar from "./components/topbar/Topbar.svelte";
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
Expand Down Expand Up @@ -47,10 +50,12 @@
let profileStatusError = false;
let status = "loading";
let authRequired = false;
let socket: WebSocket | null = null;
let connected = false;
let reconnectTimer: number | undefined;
let messageList: HTMLElement | null = null;
let socket: RealtimeConnection | null = null;
let messageList: MessageListHandle | null = null;
let scrollMemory = new Map<string, MessageListState>();
let viewKey = "";
let viewRestoreState: MessageListState | undefined = undefined;
let messagesLoading = true;
let showWorkspaceCreate = false;
let sidebarCollapsed = false;
let mobileNavOpen = false;
Expand All @@ -61,6 +66,7 @@
let activeComposerContext: "message" | "thread" = "message";

$: selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceID);
$: connected = socket?.connected ?? false;
$: selectedChannel = channels.find((channel) => channel.id === selectedChannelID);
$: selectedDirect = directConversations.find((conversation) => conversation.id === selectedDirectID);
$: sidePanelOpen = selectedThread !== null || selectedProfile !== null;
Expand All @@ -78,11 +84,8 @@
});

onDestroy(() => {
const current = socket;
socket?.close();
socket = null;
connected = false;
current?.close();
if (reconnectTimer) window.clearTimeout(reconnectTimer);
});

async function boot() {
Expand Down Expand Up @@ -146,7 +149,7 @@
await loadChannels();
await loadDirectConversations();
if (workspaces.length === 0) status = "create a workspace";
connectRealtime();
connectRealtimeSocket();
}

async function createWorkspace() {
Expand All @@ -161,14 +164,14 @@
selectedWorkspaceID = data.workspace.id;
await loadChannels();
await loadDirectConversations();
connectRealtime();
connectRealtimeSocket();
}

async function selectWorkspace(workspaceID: string) {
selectedWorkspaceID = workspaceID;
await loadChannels();
await loadDirectConversations();
connectRealtime();
connectRealtimeSocket();
}

async function loadChannels() {
Expand Down Expand Up @@ -207,24 +210,49 @@
}

async function loadMessages() {
if (selectedDirectID) {
const data = await api<{ messages: Message[] }>(`/api/dms/${selectedDirectID}/messages`);
messages = data.messages;
await scrollMessagesToBottom();
return;
}
if (!selectedChannelID) {
messages = [];
return;
captureScrollMemory();
const targetKey = currentConversationKey();
const isSwitching = targetKey !== viewKey;
if (isSwitching) messagesLoading = true;
try {
if (selectedDirectID) {
const data = await api<{ messages: Message[] }>(`/api/dms/${selectedDirectID}/messages`);
if (currentConversationKey() !== targetKey) return;
commitView(targetKey, data.messages);
return;
}
if (!selectedChannelID) {
commitView("", []);
return;
}
const data = await api<{ messages: Message[] }>(`/api/channels/${selectedChannelID}/messages`);
if (currentConversationKey() !== targetKey) return;
commitView(targetKey, data.messages);
} finally {
if (currentConversationKey() === targetKey) messagesLoading = false;
}
const data = await api<{ messages: Message[] }>(`/api/channels/${selectedChannelID}/messages`);
messages = data.messages;
await scrollMessagesToBottom();
}

function currentConversationKey(): string {
return selectedDirectID || selectedChannelID || "";
}

function captureScrollMemory() {
if (!viewKey || !messageList) return;
const captured = messageList.captureState();
if (captured) scrollMemory.set(viewKey, captured);
}

function commitView(key: string, msgs: Message[]) {
// Update viewKey + messages atomically so MessageList sees the swap as one tick.
viewRestoreState = scrollMemory.get(key);
messages = msgs;
viewKey = key;
}

async function scrollMessagesToBottom() {
await tick();
if (messageList) messageList.scrollTop = messageList.scrollHeight;
messageList?.scrollToBottom();
}

async function sendMessage() {
Expand Down Expand Up @@ -314,10 +342,11 @@
async function jumpToQuotedMessage(message: Message) {
const targetID = message.quoted_message_id;
if (!targetID) return;
const scrolled = messageList?.scrollToMessage(targetID) ?? false;
if (!scrolled) return;
await tick();
const node = document.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(targetID)}"]`);
if (!node) return;
node.scrollIntoView({ behavior: "smooth", block: "center" });
node.classList.add("highlight");
window.setTimeout(() => node.classList.remove("highlight"), 1500);
}
Expand Down Expand Up @@ -356,9 +385,13 @@
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file || !selectedWorkspaceID) return;
const probe = await probeMediaDimensions(file);
const form = new FormData();
form.set("workspace_id", selectedWorkspaceID);
form.set("file", file);
if (probe.width > 0) form.set("width", String(probe.width));
if (probe.height > 0) form.set("height", String(probe.height));
if (probe.durationMS > 0) form.set("duration_ms", String(probe.durationMS));
const data = await api<{ upload: Upload }>("/api/uploads", { method: "POST", body: form });
pendingUpload = data.upload;
input.value = "";
Expand Down Expand Up @@ -423,32 +456,13 @@
await loadMessages();
}

function connectRealtime() {
if (reconnectTimer) window.clearTimeout(reconnectTimer);
const previous = socket;
function connectRealtimeSocket() {
socket?.close();
socket = null;
connected = false;
previous?.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);
const current = new WebSocket(url);
socket = current;
current.addEventListener("open", () => {
if (socket === current) connected = true;
});
current.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);
});
current.addEventListener("close", () => {
if (socket !== current) return;
connected = false;
reconnectTimer = window.setTimeout(connectRealtime, 1200);
socket = connectRealtime({
workspaceID: selectedWorkspaceID,
onEvent: (event) => void handleEvent(event),
});
}

Expand Down Expand Up @@ -672,9 +686,12 @@
{messages}
{selectedDirect}
{selectedChannel}
restoreState={viewRestoreState}
{viewKey}
loading={messagesLoading}
selectedThreadID={selectedThread?.id}
currentUserID={user?.id}
onListRef={(node) => (messageList = node)}
onListRef={(handle) => (messageList = handle)}
onActivateMessageComposer={() => (activeComposerContext = "message")}
onInlineImagePointerUp={handleInlineImagePointerUp}
onOpenProfile={openUserProfile}
Expand Down Expand Up @@ -744,15 +761,7 @@
onSetStatus={() => (status = "status messages are coming soon")}
/>
{:else}
<div class="thread-empty">
<div class="thread-icon">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M21 12a8 8 0 0 1-11.6 7.16L3 21l1.84-6.4A8 8 0 1 1 21 12Z"/>
</svg>
</div>
<strong>No thread open</strong>
<span>Hover any message and tap the bubble to keep side conversations tidy.</span>
</div>
<ThreadEmptyState />
{/if}
</aside>
</div>
Expand Down
Loading
Loading