import { Identity } from "spacetimedb"; import { SvelteMap } from "svelte/reactivity"; import * as Types from "../../module_bindings/types"; import { reducers } from "../../module_bindings"; import { getUsername, formatTime } from "../utils"; import { DatabaseService } from "./database.svelte"; import { NavigationService } from "./navigation.svelte"; import { ThemeService, themeService } from "./theme.svelte"; import { AuthContextService } from "./auth-context.svelte"; import { MessagingService } from "./messaging.svelte"; import { VoiceService } from "./voice.svelte"; import { AccountService } from "./account.svelte"; import { ServerManagementService } from "./server-management.svelte"; import { DirectMessagingService } from "./direct-messaging.svelte"; import { EncryptionService } from "./encryption.svelte"; export class ChatService { #db: DatabaseService; #nav: NavigationService; #ui: ThemeService = themeService; #auth: AuthContextService; #msg: MessagingService; #voice: VoiceService; #account: AccountService; #server: ServerManagementService; #dm: DirectMessagingService; #encryption: EncryptionService; identity = $state(null); #blobUrls = new Map(); // These maps are for reactive UI access #avatarUrls = new SvelteMap(); #bannerUrls = new SvelteMap(); #serverAvatarUrls = new SvelteMap(); #messageImageUrls = new SvelteMap(); constructor(initialIdentity: Identity | null) { console.log("ChatService: Initializing with identity:", initialIdentity?.toHexString()); this.identity = initialIdentity; this.#db = new DatabaseService(() => this.identity); this.#nav = new NavigationService(this.#db, () => this.identity); this.#auth = new AuthContextService(this.#db, () => this.identity); this.#msg = new MessagingService(this.#db, this.#nav, () => this.identity); this.#voice = new VoiceService(this.#db, this.#nav, () => this.identity); this.#account = new AccountService(); this.#server = new ServerManagementService(); this.#dm = new DirectMessagingService(this.#db, this.#nav, () => this.identity); this.#encryption = new EncryptionService(this.#db, () => this.identity); this.#msg.onSendMessage = async (text, channelId) => { // Check if user has manually enabled encryption for this message/channel if (!this.#msg.encryptionOptIn.has(channelId.toString())) { return { text, isEncrypted: false }; } // Check if this is a DM channel const dm = this.#db.directMessages.find(d => d.channelId === channelId); if (dm) { const myIdHex = this.identity?.toHexString(); const otherIdentity = dm.sender.toHexString() === myIdHex ? dm.recipient : dm.sender; const recipientPubKey = this.getRecipientPublicKey(otherIdentity); if (recipientPubKey && this.isEncryptionReady) { console.log(`[ChatService] Encrypting message for DM ${channelId}`); try { const encrypted = await this.encrypt(text, recipientPubKey); return { text: encrypted, isEncrypted: true }; } catch (e) { console.error("[ChatService] Encryption failed, sending as plain text", e); } } } return { text, isEncrypted: false }; }; // Session-only image processing: creates Blob URLs directly from Database data. // This ditched the persistent IndexedDB cache to prevent stale data between reloads. $effect(() => { const currentImages = this.#db.images; const currentIds = new Set(currentImages.map(img => img.id.toString())); // 1. Cleanup old Blob URLs no longer in visible_images for (const [idStr, url] of this.#blobUrls.entries()) { if (!currentIds.has(idStr)) { console.log(`[ChatService] Revoking Blob URL for ${idStr}`); URL.revokeObjectURL(url); this.#blobUrls.delete(idStr); this.#avatarUrls.delete(idStr); this.#bannerUrls.delete(idStr); this.#serverAvatarUrls.delete(idStr); this.#messageImageUrls.delete(idStr); } } // 2. Create URLs for new images for (const img of currentImages) { const idStr = img.id.toString(); if (!this.#blobUrls.has(idStr)) { // Use a copy of the data to ensure no buffer sharing issues const dataCopy = img.data.slice(); const blob = new Blob([dataCopy], { type: img.mimeType }); const url = URL.createObjectURL(blob); console.log(`[ChatService] Created Blob URL for ${idStr}: ${url} (size: ${dataCopy.length} bytes)`); this.#blobUrls.set(idStr, url); } } // 3. Update reactive maps for UI // Avatars/Banners for (const user of this.users) { if (user.avatarId) { const idStr = user.avatarId.toString(); const url = this.#blobUrls.get(idStr); if (url && this.#avatarUrls.get(idStr) !== url) { this.#avatarUrls.set(idStr, url); } } if (user.bannerId) { const idStr = user.bannerId.toString(); const url = this.#blobUrls.get(idStr); if (url && this.#bannerUrls.get(idStr) !== url) { this.#bannerUrls.set(idStr, url); } } } // Server avatars for (const server of this.servers) { if (server.avatarId) { const idStr = server.avatarId.toString(); const url = this.#blobUrls.get(idStr); if (url && this.#serverAvatarUrls.get(idStr) !== url) { this.#serverAvatarUrls.set(idStr, url); } } } // Message attachments and others from visible_images for (const img of currentImages) { const idStr = img.id.toString(); const url = this.#blobUrls.get(idStr); if (url && this.#messageImageUrls.get(idStr) !== url) { this.#messageImageUrls.set(idStr, url); } } }); } get activeServerId() { return this.#nav.activeServerId; } set activeServerId(v) { this.#nav.activeServerId = v; } get activeChannelId() { return this.#nav.activeChannelId; } set activeChannelId(v) { this.#nav.activeChannelId = v; } get activeThreadId() { return this.#nav.activeThreadId; } set activeThreadId(v) { this.#nav.activeThreadId = v; } get pendingThreadParentMessageId() { return this.#nav.pendingThreadParentMessageId; } set pendingThreadParentMessageId(v) { this.#nav.pendingThreadParentMessageId = v; if (v !== null) { this.#nav.activeThreadId = null; } } get activeServer() { return this.#nav.activeServer; } get activeChannel() { return this.#nav.activeChannel; } get activeThread() { return this.#msg.activeThread; } get joinedServers() { return this.#nav.joinedServers; } get availableServers() { return this.#nav.availableServers; } // Facade Getters/Setters for UI get showCreateServerModal() { return this.#ui.showCreateServerModal; } set showCreateServerModal(v) { this.#ui.showCreateServerModal = v; } get newServerName() { return this.#ui.newServerName; } set newServerName(v) { this.#ui.newServerName = v; } get showCreateChannelModal() { return this.#ui.showCreateChannelModal; } set showCreateChannelModal(v) { this.#ui.showCreateChannelModal = v; } get newChannelName() { return this.#ui.newChannelName; } set newChannelName(v) { this.#ui.newChannelName = v; } get isVoiceChannel() { return this.#ui.isVoiceChannel; } set isVoiceChannel(v) { this.#ui.isVoiceChannel = v; } get showSetNameModal() { return this.#ui.showSetNameModal; } set showSetNameModal(v) { this.#ui.showSetNameModal = v; } get newName() { return this.#ui.newName; } set newName(v) { this.#ui.newName = v; } get messageText() { return this.#ui.messageText; } set messageText(v) { this.#ui.messageText = v; } get threadMessageText() { return this.#ui.threadMessageText; } set threadMessageText(v) { this.#ui.threadMessageText = v; } get showDiscoveryModal() { return this.#ui.showDiscoveryModal; } 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; } set authError(v) { this.#ui.authError = v; } get viewingImageId() { return this.#ui.viewingImageId; } set viewingImageId(v) { this.#ui.viewingImageId = v; } get viewingProfileUser() { return this.#ui.viewingProfileUser; } set viewingProfileUser(v) { this.#ui.viewingProfileUser = v; } get userContextMenu() { return this.#ui.userContextMenu; } set userContextMenu(v) { this.#ui.userContextMenu = v; } get confirmModal() { return this.#ui.confirmModal; } set confirmModal(v) { this.#ui.confirmModal = v; } get editingMessageId() { return this.#ui.editingMessageId; } set editingMessageId(v) { this.#ui.editingMessageId = v; } get isEditingInlineId() { return this.#ui.isEditingInlineId; } set isEditingInlineId(v) { this.#ui.isEditingInlineId = v; } // Facade Getters for Data get ui() { return this.#ui; } get account() { return this.#account; } get servers() { return this.#db.servers; } get channels() { return this.#db.channels; } get users() { return this.#db.users; } get serverMembers() { return this.#db.serverMembers; } get allMessages() { return this.#msg.allMessages; } get synchronizedMessages() { return this.#msg.synchronizedMessages; } getMessageImages = (messageId: bigint) => this.#msg.getMessageImages(messageId); getMessageReactions = (messageId: bigint) => this.#msg.getMessageReactions(messageId); get allThreads() { return this.#db.allThreads; } get images() { return this.#db.images; } get imagesMap() { return this.#db.imagesMap; } get customEmojis() { return this.#db.customEmojis; } get voiceStates() { return this.#db.voiceStates; } get voiceActivity() { return this.#db.voiceActivity; } get typingActivity() { return this.#db.typingActivity; } get watching() { return this.#db.watching; } get isUsersReady() { return this.#db.isUsersReady; } get isReady() { return this.#db.isReady && this.#msg.isGlobalSyncDone; } get isMessagesReady() { return this.#msg.isMessagesReady; } get isEncryptionReady() { return this.#encryption.isKeyReady; } isEncryptionEnabledForChannel = (channelId: bigint) => this.#msg.encryptionOptIn.has(channelId.toString()); toggleEncryptionForChannel = (channelId: bigint) => { const chanStr = channelId.toString(); if (this.#msg.encryptionOptIn.has(chanStr)) { this.#msg.encryptionOptIn.delete(chanStr); } else { this.#msg.encryptionOptIn.add(chanStr); } }; get myPublicKey() { return this.#encryption.publicKey; } generateEncryptionKey = (name: string, email: string) => this.#encryption.generateKeypair(name, email); getRecipientPublicKey = (recipientIdentity: Identity) => this.#encryption.getRecipientPublicKey(recipientIdentity); encrypt = (text: string, pubKey: string) => this.#encryption.encrypt(text, pubKey); decrypt = (text: string) => this.#encryption.decrypt(text); get maxMessageLength() { const config = this.#db.systemConfiguration.find(c => c.key === "max_message_length"); return config ? parseInt(config.value) : 262144; } // Auth Context get currentUser() { return this.#auth.currentUser; } get isFullyAuthenticated() { return this.#auth.isFullyAuthenticated; } // Messaging get channelMessages() { return this.#msg.channelMessages; } get threadMessages() { return this.#msg.threadMessages; } get hasMoreMessages() { return this.#msg.hasMoreMessages; } // Voice get currentVoiceState() { return this.#voice.currentVoiceState; } get connectedVoiceChannel() { return this.#voice.connectedVoiceChannel; } get connectedVoiceServer() { const channel = this.connectedVoiceChannel; return channel ? this.servers.find((s) => s.id === channel.serverId) : undefined; } // Derived Helpers get isActiveChannelVoice() { return this.activeChannel?.kind.tag === "Voice"; } get isActiveChannelText() { return this.activeChannel?.kind.tag === "Text"; } get textChannels() { const server = this.activeServer; if (!server) return []; return server.channels.filter(c => c.kind.tag === "Text"); } get voiceChannels() { const server = this.activeServer; if (!server) return []; return server.channels.filter(c => c.kind.tag === "Voice"); } get activeServerMembers() { if (!this.activeServerId) return []; return this.serverMembers.filter((m) => m.serverId === this.activeServerId); } get onlineUsers() { return this.users.filter( (u) => u.online || this.identity?.isEqual(u.identity), ); } // Actions handleCreateServer = (e: Event) => { e.preventDefault(); this.#server.handleCreateServer(this.newServerName); this.newServerName = ""; this.showCreateServerModal = false; }; handleCreateChannel = (e: Event) => { e.preventDefault(); if (this.activeServerId) { this.#server.handleCreateChannel( this.activeServerId, this.newChannelName, this.isVoiceChannel, ); this.newChannelName = ""; this.showCreateChannelModal = false; } }; handleStartThread = (msg: Types.Message) => { this.#msg.handleStartThread(msg); }; handleSendMessage = ( text: string, threadId?: bigint, imageIds: bigint[] = [], ) => { this.#msg.handleSendMessage(text, threadId, imageIds); }; handleLoadMoreMessages = () => { this.#msg.handleLoadMoreMessages(); }; handleToggleReaction = ( messageId: bigint, emoji?: string, customEmojiId?: bigint, ) => { this.#msg.toggleReaction(messageId, emoji, customEmojiId); }; handleSetAvatar = (avatarId?: bigint) => { this.#account.handleSetAvatar(avatarId); }; handleUploadAvatar = async (data: Uint8Array, mimeType: string) => { this.#account.uploadAvatar(data, mimeType); }; handleSetBanner = (bannerId?: bigint) => { this.#account.handleSetBanner(bannerId); }; handleUploadBanner = async (data: Uint8Array, mimeType: string) => { this.#account.uploadBanner(data, mimeType); }; handleSetBiography = (biography?: string) => { this.#account.handleSetBiography(biography); }; handleSetStatus = (status?: string) => { this.#account.handleSetStatus(status); }; getAvatarUrl = (user: { avatarId?: bigint | null } | null | undefined) => { if (!user || !user.avatarId) return null; return this.#avatarUrls.get(user.avatarId.toString()) || null; }; getBannerUrl = (user: Types.User | null | undefined) => { if (!user || !user.bannerId) return null; 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; }; getImageUrl = (imageId: bigint | null | undefined) => { if (!imageId) return null; return this.#messageImageUrls.get(imageId.toString()) || null; }; uploadImage = async (data: Uint8Array, mimeType: string, name?: string): Promise => { return this.#msg.uploadImage(data, mimeType, name); }; uploadCustomEmoji = async ( name: string, category: string, data: Uint8Array, ) => { this.#msg.uploadCustomEmoji(name, category, data); }; handleSendThreadMessage = (e: Event) => { e.preventDefault(); this.#msg.handleSendMessage( this.threadMessageText, this.activeThreadId || undefined, ); this.threadMessageText = ""; }; handleJoinVoice = (channelId: bigint) => { this.#voice.handleJoinVoice(channelId); }; handleLeaveVoice = () => { this.#voice.handleLeaveVoice(); }; handleSetTyping = (typing: boolean, channelId: bigint | null = null) => { const targetChannelId = channelId || this.activeChannelId; if (targetChannelId) { this.#msg.setTyping(targetChannelId, typing); } }; get typingUsers() { if (!this.activeChannelId) return []; const myId = this.identity; const activeChannelId = this.activeChannelId; return this.typingActivity .filter((ta) => { const isSameChannel = ta.channelId === activeChannelId; const isNotMe = myId ? !ta.identity.isEqual(myId) : true; const currentlyTyping = ta.isTyping; return isSameChannel && isNotMe && currentlyTyping; }) .map((ta) => { const member = this.serverMembers.find((m) => m.identity.isEqual(ta.identity)); return ( member || ({ name: `User ${ta.identity.toHexString().substring(0, 8)}`, } as any) ); }); } handleSetName = (e: Event) => { e.preventDefault(); this.#account.handleSetName(this.newName); this.newName = ""; this.showSetNameModal = false; }; handleJoinServer = (serverId: bigint) => { this.#server.handleJoinServer(serverId); }; handleLeaveServer = (serverId: bigint) => { 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); }; handleSetServerPublic = (serverId: bigint, isPublic: boolean) => { this.#server.handleSetServerPublic(serverId, isPublic); }; get activeDms() { return this.#nav.activeDms; } get directMessages() { return this.#db.directMessages; } handleDeleteServer = (serverId: bigint) => { this.#server.handleDeleteServer(serverId); }; handleOpenDirectMessage = (recipient: Identity) => { this.#dm.handleOpenDirectMessage(recipient); this.activeServerId = null; }; handleEditMessage = (messageId: bigint, newText: string) => { this.#msg.handleEditMessage(messageId, newText); }; handleDeleteMessage = (messageId: bigint) => { this.#msg.handleDeleteMessage(messageId); }; handleEditMessageTrigger = (id: bigint, text: string) => { this.#ui.editingMessage = { id, text }; }; handleCloseDirectMessage = (channelId: bigint) => { this.#dm.handleCloseDirectMessage(channelId); if (this.activeChannelId === channelId) { this.activeChannelId = null; } }; // Helper functions getUsername = (userIdentity: Identity | null) => getUsername(userIdentity, this.users); formatTime = (ts: any) => formatTime(ts); }