diff --git a/spacetime.json b/spacetime.json index 331e3d0..dd97335 100644 --- a/spacetime.json +++ b/spacetime.json @@ -3,6 +3,6 @@ "run": "pnpm run dev" }, "server": "maincloud", - "database": "ditchcord", + "database": "zep", "module-path": "./spacetimedb" } diff --git a/spacetime.local.json b/spacetime.local.json index f400d80..3d82af8 100644 --- a/spacetime.local.json +++ b/spacetime.local.json @@ -1,3 +1,3 @@ { - "database": "ditchcord" + "database": "zep" } diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts index ba4e4df..c53c7e5 100644 --- a/spacetimedb/src/index.ts +++ b/spacetimedb/src/index.ts @@ -29,6 +29,7 @@ const server = table( id: t.u64().primaryKey().autoInc(), name: t.string(), owner: t.identity().optional(), + avatar_id: t.u64().optional(), }, ); @@ -545,6 +546,76 @@ export const upload_banner = spacetimedb.reducer( }, ); +export const upload_server_avatar = spacetimedb.reducer( + { serverId: t.u64(), data: t.byteArray(), mimeType: t.string() }, + (ctx, { serverId, data, mimeType }) => { + if (data.length > 1024 * 1024) { + throw new SenderError("Avatar exceeds 1MB limit"); + } + const s = ctx.db.server.id.find(serverId); + if (!s) throw new SenderError("Server not found"); + /* owner check temporarily disabled */ + /* if (s.owner?.toHexString() !== ctx.sender.toHexString()) { + throw new SenderError("Only the server owner can change the avatar"); + } */ + + const img = ctx.db.image.insert({ + id: 0n, + data, + mime_type: mimeType, + name: "server_avatar", + }); + ctx.db.server.id.update({ ...s, avatar_id: img.id }); + }, +); + +export const update_server_name = spacetimedb.reducer( + { serverId: t.u64(), name: t.string() }, + (ctx, { serverId, name }) => { + validateName(name); + const s = ctx.db.server.id.find(serverId); + if (!s) throw new SenderError("Server not found"); + /* owner check temporarily disabled */ + /* if (s.owner?.toHexString() !== ctx.sender.toHexString()) { + throw new SenderError("Only the server owner can rename the server"); + } */ + ctx.db.server.id.update({ ...s, name }); + }, +); + +export const delete_server = spacetimedb.reducer( + { serverId: t.u64() }, + (ctx, { serverId }) => { + const s = ctx.db.server.id.find(serverId); + if (!s) throw new SenderError("Server not found"); + + /* owner check temporarily disabled */ + /* if (s.owner?.toHexString() !== ctx.sender.toHexString()) { + throw new SenderError("Only the server owner can delete the server"); + } */ + + // 1. Delete all channels in this server + for (const c of ctx.db.channel.by_server_id.filter(serverId)) { + // Cleanup messages in channel + for (const m of ctx.db.message.by_channel_id.filter(c.id)) { + ctx.db.message.id.delete(m.id); + } + for (const rm of ctx.db.recent_message.by_channel.filter(c.id)) { + ctx.db.recent_message.id.delete(rm.id); + } + ctx.db.channel.id.delete(c.id); + } + + // 2. Delete all memberships + for (const m of ctx.db.server_member.by_server_id.filter(serverId)) { + ctx.db.server_member.id.delete(m.id); + } + + // 3. Delete the server itself + ctx.db.server.id.delete(serverId); + }, +); + export const toggle_reaction = spacetimedb.reducer( { messageId: t.u64(), @@ -651,7 +722,7 @@ export const create_server = spacetimedb.reducer( "You must be logged in via OIDC to create a server", ); } - const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender }); + const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender, avatar_id: undefined }); ctx.db.server_member.insert({ id: 0n, identity: ctx.sender, @@ -1227,6 +1298,7 @@ export const init = spacetimedb.init((ctx) => { id: 0n, name: "Zep", owner: undefined, + avatar_id: undefined, }); ctx.db.channel.insert({ id: 0n, diff --git a/src/chat/ChatContainer.svelte b/src/chat/ChatContainer.svelte index cb2246d..9823e11 100644 --- a/src/chat/ChatContainer.svelte +++ b/src/chat/ChatContainer.svelte @@ -13,6 +13,7 @@ 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"; @@ -215,6 +216,12 @@ /> {/if} + {#if chat.showServerSettings} + (chat.showServerSettings = false)} + /> + {/if} + {#if chat.viewingImageId} {@const image = chat.images.find(img => img.id === chat.viewingImageId)} {#if image} diff --git a/src/chat/components/MessageList.svelte b/src/chat/components/MessageList.svelte index 9a6afff..d875abe 100644 --- a/src/chat/components/MessageList.svelte +++ b/src/chat/components/MessageList.svelte @@ -63,25 +63,26 @@ const heightDiff = newHeight - lastHeight; if (heightDiff !== 0) { - const messages = chat.channelMessages; - const lastMsg = messages[messages.length - 1]; + const messagesList = chat.channelMessages; + const lastMsg = messagesList[messagesList.length - 1]; const myIdHex = chat.identity?.toHexString(); const lastMsgSenderHex = lastMsg?.sender.toHexString(); const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; - // If the user is NOT at the bottom, adjust scroll to keep position - if (!isAtBottom && !isPrepending && !sentByMe) { - scrollContainer!.scrollTop += heightDiff; - } - // If we ARE at the bottom, OR we just sent a message, keep us pinned - else if (isAtBottom || sentByMe) { + // If we ARE at the bottom, OR we just sent a message, keep us pinned to the NEW bottom + if (isAtBottom || sentByMe) { scrollContainer!.scrollTop = target.scrollHeight; + } + // If the user is NOT at the bottom, and they aren't loading more history, + // we only adjust scroll if something above them expanded. + // For simplicity, we assume expansion happens at the point of interaction. + else if (!isPrepending) { + // No-op for expansion at/below viewport to let browser maintain focus on the item } lastHeight = newHeight; } } }); - observer.observe(scrollContainer); return () => observer.disconnect(); } @@ -94,6 +95,7 @@ const lastMsgSenderHex = lastMsg?.sender.toHexString(); const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; + // Only force scroll to bottom if we were already anchored or we just sent the message if (isAtBottom || sentByMe) { scrollToBottom(false); } diff --git a/src/chat/components/RichText.svelte b/src/chat/components/RichText.svelte index e89da87..bdaed89 100644 --- a/src/chat/components/RichText.svelte +++ b/src/chat/components/RichText.svelte @@ -137,14 +137,8 @@ } function onImageLoad(e: Event) { - const img = e.currentTarget as HTMLImageElement; - const list = img.closest(".message-list"); - if (list) { - if (onLoad) { - onLoad(); - } else { - list.scrollTop = list.scrollHeight; - } + if (onLoad) { + onLoad(); } } diff --git a/src/chat/components/ServerList.svelte b/src/chat/components/ServerList.svelte index 9135d92..36ad2e5 100644 --- a/src/chat/components/ServerList.svelte +++ b/src/chat/components/ServerList.svelte @@ -9,12 +9,16 @@
{#each chat.joinedServers as server (server.id.toString())} + {@const avatarUrl = chat.getServerAvatarUrl(server)} {/each} diff --git a/src/chat/components/ServerSettingsPanel.svelte b/src/chat/components/ServerSettingsPanel.svelte new file mode 100644 index 0000000..2e085a3 --- /dev/null +++ b/src/chat/components/ServerSettingsPanel.svelte @@ -0,0 +1,540 @@ + + + + + +{#if showDeleteConfirm} + +{/if} + + diff --git a/src/chat/components/channels/ServerHeader.svelte b/src/chat/components/channels/ServerHeader.svelte index 547fe3d..939392c 100644 --- a/src/chat/components/channels/ServerHeader.svelte +++ b/src/chat/components/channels/ServerHeader.svelte @@ -5,39 +5,65 @@ const chat = getContext("chat"); let showServerDropdown = $state(false); + let fileInput = $state(null); + + const isOwner = true; // temporarily enabled for everyone + + function handleOpenSettings() { + chat.showServerSettings = true; + showServerDropdown = false; + } - - -{#if showServerDropdown} -
- -
-{/if} + {chat.activeServer?.name || "Select a Server"} + + + + + + + {#if showServerDropdown} +
+ {#if isOwner} + + + {/if} + +
+ {/if} +
diff --git a/src/chat/services/chat.svelte.ts b/src/chat/services/chat.svelte.ts index c60c93f..bee88f4 100644 --- a/src/chat/services/chat.svelte.ts +++ b/src/chat/services/chat.svelte.ts @@ -25,6 +25,7 @@ export class ChatService { identity = $state(null); #avatarUrls = new SvelteMap(); #bannerUrls = new SvelteMap(); + #serverAvatarUrls = new SvelteMap(); constructor(initialIdentity: Identity | null) { this.identity = initialIdentity; @@ -87,6 +88,30 @@ export class ChatService { }); } }); + + // Background effect to populate server avatar cache + $effect(() => { + const serversWithAvatars = this.servers.filter((s) => s.avatarId); + for (const server of serversWithAvatars) { + const avatarId = server.avatarId!; + const idStr = avatarId.toString(); + + if (this.#serverAvatarUrls.has(idStr)) continue; + + imageCache.get(avatarId).then((cachedUrl) => { + if (cachedUrl) { + this.#serverAvatarUrls.set(idStr, cachedUrl); + } else { + const img = this.#db.images.find((i) => i.id === avatarId); + if (img) { + imageCache.set(img.id, img.data, img.mimeType).then((url) => { + this.#serverAvatarUrls.set(idStr, url); + }); + } + } + }); + } + }); } get activeServerId() { @@ -194,6 +219,12 @@ export class ChatService { set showDiscoveryModal(v) { this.#ui.showDiscoveryModal = v; } + get showServerSettings() { + return this.#ui.showServerSettings; + } + set showServerSettings(v) { + this.#ui.showServerSettings = v; + } get authError() { return this.#ui.authError; } @@ -425,6 +456,11 @@ export class ChatService { return this.#bannerUrls.get(user.bannerId.toString()) || null; }; + getServerAvatarUrl = (server: Types.Server | null | undefined) => { + if (!server || !server.avatarId) return null; + return this.#serverAvatarUrls.get(server.avatarId.toString()) || null; + }; + uploadImage = async (data: Uint8Array, mimeType: string, name?: string) => { this.#msg.uploadImage(data, mimeType, name); }; @@ -500,6 +536,18 @@ export class ChatService { this.#server.handleLeaveServer(serverId); }; + handleUploadServerAvatar = (serverId: bigint, data: Uint8Array, mimeType: string) => { + this.#server.handleUploadServerAvatar(serverId, data, mimeType); + }; + + handleUpdateServerName = (serverId: bigint, name: string) => { + this.#server.handleUpdateServerName(serverId, name); + }; + + handleDeleteServer = (serverId: bigint) => { + this.#server.handleDeleteServer(serverId); + }; + // Helper functions getUsername = (userIdentity: Identity | null) => getUsername(userIdentity, this.users); diff --git a/src/chat/services/server-management.svelte.ts b/src/chat/services/server-management.svelte.ts index e84c598..b6a57ff 100644 --- a/src/chat/services/server-management.svelte.ts +++ b/src/chat/services/server-management.svelte.ts @@ -6,6 +6,9 @@ export class ServerManagementService { #createChannelReducer = useReducer(reducers.createChannel); #joinServerReducer = useReducer(reducers.joinServer); #leaveServerReducer = useReducer(reducers.leaveServer); + #uploadServerAvatarReducer = useReducer(reducers.uploadServerAvatar); + #updateServerNameReducer = useReducer(reducers.updateServerName); + #deleteServerReducer = useReducer(reducers.deleteServer); handleCreateServer = (name: string) => { if (name.trim()) { @@ -30,4 +33,18 @@ export class ServerManagementService { handleLeaveServer = (serverId: bigint) => { this.#leaveServerReducer({ serverId }); }; + + handleUploadServerAvatar = (serverId: bigint, data: Uint8Array, mimeType: string) => { + this.#uploadServerAvatarReducer({ serverId, data, mimeType }); + }; + + handleUpdateServerName = (serverId: bigint, name: string) => { + if (name.trim()) { + this.#updateServerNameReducer({ serverId, name }); + } + }; + + handleDeleteServer = (serverId: bigint) => { + this.#deleteServerReducer({ serverId }); + }; } diff --git a/src/chat/services/theme.svelte.ts b/src/chat/services/theme.svelte.ts index 9d1db58..eb34f88 100644 --- a/src/chat/services/theme.svelte.ts +++ b/src/chat/services/theme.svelte.ts @@ -11,6 +11,7 @@ export class ThemeService { messageText = $state(""); threadMessageText = $state(""); showDiscoveryModal = $state(false); + showServerSettings = $state(false); authError = $state(""); viewingImageId = $state(null); viewingProfileUser = $state(null);