Files
zep/src/chat/services/chat.svelte.ts
T
2026-04-22 00:25:38 -04:00

858 lines
24 KiB
TypeScript

import { untrack } from "svelte";
import { Identity } from "spacetimedb";
import { SvelteSet } from "svelte/reactivity";
import * as Types from "../../module_bindings/types";
import { getUsername, formatTime } from "../utils";
import { DatabaseService, type Thread } 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";
import { MediaCacheService } from "./media-cache.svelte";
import { sounds } from "./sound.svelte";
export const Permissions = {
MANAGE_SERVER: 0x01n,
MANAGE_ROLES: 0x02n,
MANAGE_CHANNELS: 0x04n,
CREATE_INVITES: 0x08n,
KICK_MEMBERS: 0x10n,
BAN_MEMBERS: 0x20n,
MODERATE_MESSAGES: 0x40n,
USE_VOICE: 0x80n,
SHARE_SCREEN: 0x100n,
USE_THREADS: 0x200n,
MANAGE_EMOJIS: 0x400n,
DELETE_SERVER: 0x800n,
} as const;
export type PermissionBit = typeof Permissions[keyof typeof Permissions];
export class ChatService {
#db: DatabaseService;
#nav: NavigationService;
#ui: ThemeService = themeService;
#auth: AuthContextService;
#msg: MessagingService;
#voice: VoiceService;
#account: AccountService;
#server: ServerManagementService;
#dm: DirectMessagingService;
#encryption: EncryptionService;
#media: MediaCacheService;
identity = $state<Identity | null>(null);
subscribedChannels = $state(new SvelteSet<string>());
mutedDms = $state(new SvelteSet<string>());
notificationSoundPack = $state("classic");
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.#media = new MediaCacheService(this.#db, () => this.identity);
// Sync active thread with backend for surgical message delivery (Plan D)
$effect(() => {
const threadId = this.activeThreadId;
if (threadId) {
this.#msg.handleOpenThread(threadId);
} else {
this.#msg.handleCloseThread();
}
});
// Global Join Handler: Automatically navigate when we successfully join a server
$effect(() => {
const status = this.reducerStatus;
if (status?.status === "success" && status.reducerName === "join_server" && status.error) {
const serverId = BigInt(status.error);
console.log(`[ChatService] Global join success detected for server ${serverId}. Navigating...`);
untrack(() => {
this.activeServerId = serverId;
});
}
});
// Load notification preferences from localStorage
$effect(() => {
const myId = this.identity?.toHexString();
if (!myId) return;
const storedSubs = localStorage.getItem(`zep_subscribed_channels_${myId}`);
if (storedSubs) {
try {
this.subscribedChannels = new SvelteSet(JSON.parse(storedSubs));
} catch (e) {
console.error("Failed to parse subscribed channels", e);
}
}
const storedMutes = localStorage.getItem(`zep_muted_dms_${myId}`);
if (storedMutes) {
try {
this.mutedDms = new SvelteSet(JSON.parse(storedMutes));
} catch (e) {
console.error("Failed to parse muted DMs", e);
}
}
const storedPack = localStorage.getItem(`zep_notification_pack_${myId}`);
if (storedPack) {
this.notificationSoundPack = storedPack;
}
});
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 };
};
this.#msg.onMessageReceived = ({ channelId, senderIdentity, text, isEncrypted }) => {
// 1. Play sound
this.playMessageSound(channelId, senderIdentity);
// 2. System Notification (only if not from us and notifications are enabled)
if (!senderIdentity.isEqual(this.identity || Identity.zero()) && this.isChannelNotificationsEnabled(channelId)) {
if ("Notification" in window && Notification.permission === "granted") {
const isDm = this.#db.directMessages.some(d => d.channelId === channelId);
const isMention = text.includes(`<@${this.identity?.toHexString()}>`);
if (isDm || isMention) {
const senderName = this.getUsername(senderIdentity);
const title = isDm ? `New message from ${senderName}` : `Mentioned by ${senderName}`;
const body = isEncrypted ? "Encrypted message" : text;
new Notification(title, {
body,
icon: "/zep.svg"
});
}
} else if ("Notification" in window && Notification.permission === "default") {
Notification.requestPermission();
}
}
};
}
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(): Thread | undefined {
return this.#msg.activeThread;
}
get allThreads(): Thread[] {
return this.#db.allThreads as Thread[];
}
get joinedServers() {
return this.#nav.joinedServers;
}
get availableServers() {
return this.#nav.availableServers;
}
get myPermissions() {
const myId = this.identity;
const serverId = this.activeServerId;
if (!myId || !serverId) return 0n;
return this.#db.serverPermissions.find(p =>
p.serverId === serverId && p.identity.isEqual(myId)
)?.permissions || 0n;
}
can(bit: PermissionBit | bigint | undefined | null): boolean {
if (bit === undefined || bit === null) return false;
const b = typeof bit === "bigint" ? bit : BigInt(bit);
return (this.myPermissions & b) !== 0n;
}
// 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 showInviteModal() {
return this.#ui.showInviteModal;
}
set showInviteModal(v) {
this.#ui.showInviteModal = 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 serversById() {
return this.#db.serversById;
}
get channels() {
return this.#db.channels;
}
get channelsById() {
return this.#db.channelsById;
}
get users() {
return this.#db.users;
}
get usersById() {
return this.#db.usersById;
}
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 images() {
return this.#db.images;
}
get imagesMap() {
return this.#db.imagesMap;
}
get customEmojis() {
return this.#db.customEmojis;
}
get userStates() {
return this.#db.userStates;
}
isChannelNotificationsEnabled = (channelId: bigint) => {
const idStr = channelId.toString();
const isDm = this.#db.directMessages.some(d => d.channelId === channelId);
if (isDm) {
// DMs: On by default, check if muted
return !this.mutedDms.has(idStr);
} else {
// Server Channels: Off by default, check if explicitly subscribed
return this.subscribedChannels.has(idStr);
}
};
toggleChannelNotifications = (channelId: bigint) => {
// Resume audio context on user gesture
sounds.getAudioContext();
const idStr = channelId.toString();
const isDm = this.#db.directMessages.some(d => d.channelId === channelId);
const myId = this.identity?.toHexString();
if (isDm) {
if (this.mutedDms.has(idStr)) {
this.mutedDms.delete(idStr);
} else {
this.mutedDms.add(idStr);
}
if (myId) {
localStorage.setItem(`zep_muted_dms_${myId}`, JSON.stringify(Array.from(this.mutedDms)));
}
} else {
if (this.subscribedChannels.has(idStr)) {
this.subscribedChannels.delete(idStr);
} else {
this.subscribedChannels.add(idStr);
}
if (myId) {
localStorage.setItem(`zep_subscribed_channels_${myId}`, JSON.stringify(Array.from(this.subscribedChannels)));
}
}
};
playMessageSound = (channelId: bigint, senderIdentity: Identity) => {
// 1. Don't play if it's our own message
const myId = this.identity || Identity.zero();
if (senderIdentity.isEqual(myId)) {
console.log(`[ChatService] Sound suppressed: message is from self.`);
return;
}
// 2. Don't play if notifications are disabled
if (!this.isChannelNotificationsEnabled(channelId)) {
console.log(`[ChatService] Suppression: notifications disabled for channel ${channelId}.`);
return;
}
// 3. Play the sound
console.log(`[ChatService] Triggering notification sound (${this.notificationSoundPack}) for channel ${channelId}`);
sounds.playNotification(this.notificationSoundPack);
};
setNotificationSoundPack = (pack: string) => {
this.notificationSoundPack = pack;
const myId = this.identity?.toHexString();
if (myId) {
localStorage.setItem(`zep_notification_pack_${myId}`, pack);
}
// Play a preview
sounds.playNotification(pack);
};
get typingActivity() {
return this.#db.typingActivity;
}
get isUsersReady() {
return this.#db.isUsersReady;
}
get isReady() {
return this.#db.isReady;
}
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() {
const channelId = this.activeChannelId;
if (!channelId) return false;
// Helper to extract tag from various possible object structures
const getTag = (kind: any) => {
if (!kind) return null;
if (typeof kind.tag === 'string') return kind.tag.toLowerCase();
// Fallback for raw enum objects if the tag property is missing
return Object.keys(kind)[0]?.toLowerCase();
};
const dbChannel = this.#db.channels.find(c => c.id === channelId);
if (dbChannel && getTag(dbChannel.kind) === "voice") return true;
const server = this.activeServer;
const meta = server?.channels.find(c => c.id === channelId);
if (meta && getTag(meta.kind) === "voice") return true;
return false;
}
get isActiveChannelText() {
const channelId = this.activeChannelId;
if (!channelId) return false;
const getTag = (kind: any) => {
if (!kind) return null;
if (typeof kind.tag === 'string') return kind.tag.toLowerCase();
return Object.keys(kind)[0]?.toLowerCase();
};
const dbChannel = this.#db.channels.find(c => c.id === channelId);
if (dbChannel && getTag(dbChannel.kind) === "text") return true;
const server = this.activeServer;
const meta = server?.channels.find(c => c.id === channelId);
if (meta && getTag(meta.kind) === "text") return true;
return false;
}
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.#db.serverMembersByServerId.get(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) => {
return this.#media.get(user?.avatarId);
};
getBannerUrl = (user: Types.User | null | undefined) => {
return this.#media.get(user?.bannerId);
};
getServerAvatarUrl = (server: Types.Server | null | undefined) => {
return this.#media.get(server?.avatarId);
};
getImageUrl = (imageId: bigint | null | undefined) => {
return this.#media.get(imageId);
};
getImageSize = (imageId: bigint | null | undefined) => {
return this.#media.getSize(imageId);
};
uploadImage = async (data: Uint8Array, mimeType: string, name?: string): Promise<bigint> => {
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.typing;
return isSameChannel && isNotMe && currentlyTyping;
})
.map((ta) => {
// Use O(1) lookup from the new keyed map
const userIdHex = ta.identity.toHexString();
const user = this.#db.usersById.get(userIdHex);
return (
user ||
({
name: `User ${userIdHex.substring(0, 8)}`,
} as any)
);
});
}
handleSetName = (e: Event) => {
e.preventDefault();
this.#account.handleSetName(this.newName);
this.newName = "";
this.showSetNameModal = false;
};
handleJoinServer = (serverId?: bigint, inviteCode?: string) => {
this.#server.handleJoinServer(serverId, inviteCode);
};
handleCreateInvite = (serverId: bigint, maxUses?: number, expiresInHrs?: number) => {
this.#server.handleCreateInvite(serverId, maxUses, expiresInHrs);
};
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, publicFlag: boolean) => {
this.#server.handleSetServerPublic(serverId, publicFlag);
};
get activeDms() {
return this.#nav.activeDms;
}
get directMessages() {
return this.#db.directMessages;
}
get allServerPermissions() {
return this.#db.serverPermissions;
}
get reducerStatus() {
const myId = this.identity;
if (!myId) return null;
return this.#db.reducerStatus.find(s => s.identity.isEqual(myId)) || null;
}
handleDeleteServer = (serverId: bigint) => {
this.#server.handleDeleteServer(serverId);
};
handleSetMemberPermissions = (serverId: bigint, identity: Identity, permissions: bigint) => {
this.#server.handleSetMemberPermissions(serverId, identity, permissions);
};
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);
}