858 lines
24 KiB
TypeScript
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);
|
|
}
|