684 lines
19 KiB
TypeScript
684 lines
19 KiB
TypeScript
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<Identity | null>(null);
|
|
#blobUrls = new Map<string, string>();
|
|
|
|
// These maps are for reactive UI access
|
|
#avatarUrls = new SvelteMap<string, string>();
|
|
#bannerUrls = new SvelteMap<string, string>();
|
|
#serverAvatarUrls = new SvelteMap<string, string>();
|
|
#messageImageUrls = new SvelteMap<string, string>();
|
|
|
|
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<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.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);
|
|
}
|