diff --git a/frontend/app/src/components/home/CurrentChat.svelte b/frontend/app/src/components/home/CurrentChat.svelte index d21bc53418..852d13aa1c 100644 --- a/frontend/app/src/components/home/CurrentChat.svelte +++ b/frontend/app/src/components/home/CurrentChat.svelte @@ -69,7 +69,7 @@ $: currentChatPinnedMessages = client.currentChatPinnedMessages; $: currentChatAttachment = client.currentChatAttachment; $: currentChatEditingEvent = client.currentChatEditingEvent; - $: currentChatDraftMessage = client.currentChatDraftMessage; + $: draftMessagesStore = client.draftMessagesStore; $: lastCryptoSent = client.lastCryptoSent; $: messagesRead = client.messagesRead; $: directlyBlockedUsers = client.blockedUsers; @@ -151,7 +151,7 @@ } function fileSelected(ev: CustomEvent) { - currentChatDraftMessage.setAttachment(chat.id, ev.detail); + draftMessagesStore.setAttachment({ chatId: chat.id }, ev.detail); } function attachGif(ev: CustomEvent) { @@ -170,7 +170,7 @@ function replyTo(ev: CustomEvent) { showSearchHeader = false; - currentChatDraftMessage.setReplyingTo(chat.id, ev.detail); + draftMessagesStore.setReplyingTo({ chatId: chat.id }, ev.detail); } function searchChat(ev: CustomEvent) { @@ -226,7 +226,7 @@ } function setTextContent(ev: CustomEvent): void { - currentChatDraftMessage.setTextContent(chat.id, ev.detail); + draftMessagesStore.setTextContent({ chatId: chat.id }, ev.detail); } function isBlocked(chatSummary: ChatSummary, blockedUsers: Set): boolean { @@ -337,9 +337,9 @@ {blocked} on:joinGroup on:upgrade - on:cancelReply={() => currentChatDraftMessage.setReplyingTo(chat.id, undefined)} - on:clearAttachment={() => currentChatDraftMessage.setAttachment(chat.id, undefined)} - on:cancelEditEvent={() => currentChatDraftMessage.clear(chat.id)} + on:cancelReply={() => draftMessagesStore.setReplyingTo({ chatId: chat.id }, undefined)} + on:clearAttachment={() => draftMessagesStore.setAttachment({ chatId: chat.id }, undefined)} + on:cancelEditEvent={() => draftMessagesStore.delete({ chatId: chat.id })} on:setTextContent={setTextContent} on:startTyping={() => client.startTyping(chat, $user.userId)} on:stopTyping={() => client.stopTyping(chat, $user.userId)} diff --git a/frontend/app/src/components/home/CurrentChatMessages.svelte b/frontend/app/src/components/home/CurrentChatMessages.svelte index 4ead0cb07d..87b4b2207e 100644 --- a/frontend/app/src/components/home/CurrentChatMessages.svelte +++ b/frontend/app/src/components/home/CurrentChatMessages.svelte @@ -55,7 +55,7 @@ $: unconfirmed = client.unconfirmed; $: failedMessagesStore = client.failedMessagesStore; $: userGroupKeys = client.userGroupKeys; - $: currentChatDraftMessage = client.currentChatDraftMessage; + $: draftMessagesStore = client.draftMessagesStore; $: focusMessageIndex = client.focusMessageIndex; $: chatStateStore = client.chatStateStore; $: chatListScope = client.chatListScope; @@ -90,7 +90,7 @@ } function onEditEvent(ev: CustomEvent>) { - currentChatDraftMessage.setEditing(chat.id, ev.detail); + draftMessagesStore.setEditing({ chatId: chat.id }, ev.detail); } function eventKey(e: EventWrapper): string { diff --git a/frontend/app/src/components/home/Home.svelte b/frontend/app/src/components/home/Home.svelte index 1afd973f73..d34276432c 100644 --- a/frontend/app/src/components/home/Home.svelte +++ b/frontend/app/src/components/home/Home.svelte @@ -180,7 +180,7 @@ $: selectedChatStore = client.selectedChatStore; $: selectedChatId = client.selectedChatId; $: chatsInitialised = client.chatsInitialised; - $: currentChatDraftMessage = client.currentChatDraftMessage; + $: draftMessagesStore = client.draftMessagesStore; $: chatStateStore = client.chatStateStore; $: confirmMessage = getConfirmMessage(confirmActionEvent); $: chatListScope = client.chatListScope; @@ -681,8 +681,8 @@ }); const chatId = chat?.id ?? { kind: "direct_chat", userId: ev.detail.sender.userId }; - currentChatDraftMessage.setTextContent(chatId, ""); - currentChatDraftMessage.setReplyingTo(chatId, ev.detail); + draftMessagesStore.setTextContent({ chatId }, ""); + draftMessagesStore.setReplyingTo({ chatId }, ev.detail); if (chat) { page(routeForChatIdentifier($chatListScope.kind, chatId)); } else { @@ -872,7 +872,7 @@ text += shareUrl; } - currentChatDraftMessage.setTextContent(chatId, text); + draftMessagesStore.setTextContent({ chatId }, text); } function groupCreated( diff --git a/frontend/app/src/components/home/thread/Thread.svelte b/frontend/app/src/components/home/thread/Thread.svelte index 11834a7b66..bbc7780ff4 100644 --- a/frontend/app/src/components/home/thread/Thread.svelte +++ b/frontend/app/src/components/home/thread/Thread.svelte @@ -49,7 +49,7 @@ $: user = client.user; $: focusMessageIndex = client.focusThreadMessageIndex; $: lastCryptoSent = client.lastCryptoSent; - $: draftThreadMessages = client.draftThreadMessages; + $: draftMessagesStore = client.draftMessagesStore; $: unconfirmed = client.unconfirmed; $: messagesRead = client.messagesRead; $: currentChatBlockedUsers = client.currentChatBlockedUsers; @@ -59,8 +59,8 @@ $: messageContext = { chatId: chat.id, threadRootMessageIndex }; $: threadRootMessage = rootEvent.event; $: blocked = chat.kind === "direct_chat" && $currentChatBlockedUsers.has(chat.them.userId); - $: draftMessage = readable(draftThreadMessages.get(threadRootMessageIndex), (set) => - draftThreadMessages.subscribe((d) => set(d[threadRootMessageIndex] ?? {})), + $: draftMessage = readable(draftMessagesStore.get(messageContext), (set) => + draftMessagesStore.subscribe((d) => set(d.get(messageContext) ?? {})) ); $: textContent = derived(draftMessage, (d) => d.textContent); $: replyingTo = derived(draftMessage, (d) => d.replyingTo); @@ -112,11 +112,10 @@ } else { sendMessageWithAttachment(text, $attachment, mentioned); } - draftThreadMessages.delete(threadRootMessageIndex); } function editEvent(ev: EventWrapper): void { - draftThreadMessages.setEditing(threadRootMessageIndex, ev); + draftMessagesStore.setEditing(messageContext, ev); } function sendMessageWithAttachment( @@ -128,19 +127,19 @@ } function cancelReply() { - draftThreadMessages.setReplyingTo(threadRootMessageIndex, undefined); + draftMessagesStore.setReplyingTo(messageContext, undefined); } function clearAttachment() { - draftThreadMessages.setAttachment(threadRootMessageIndex, undefined); + draftMessagesStore.setAttachment(messageContext, undefined); } function cancelEditEvent() { - draftThreadMessages.delete(threadRootMessageIndex); + draftMessagesStore.delete(messageContext); } function setTextContent(ev: CustomEvent) { - draftThreadMessages.setTextContent(threadRootMessageIndex, ev.detail); + draftMessagesStore.setTextContent(messageContext, ev.detail); } function onStartTyping() { @@ -152,7 +151,7 @@ } function fileSelected(ev: CustomEvent) { - draftThreadMessages.setAttachment(threadRootMessageIndex, ev.detail); + draftMessagesStore.setAttachment(messageContext, ev.detail); } function tokenTransfer(ev: CustomEvent<{ ledger: string; amount: bigint } | undefined>) { @@ -186,7 +185,7 @@ } function replyTo(ev: CustomEvent) { - draftThreadMessages.setReplyingTo(threadRootMessageIndex, ev.detail); + draftMessagesStore.setReplyingTo(messageContext, ev.detail); } function defaultCryptoTransferReceiver(): string | undefined { diff --git a/frontend/openchat-client/src/liveState.ts b/frontend/openchat-client/src/liveState.ts index 1a75316593..1c803f56c1 100644 --- a/frontend/openchat-client/src/liveState.ts +++ b/frontend/openchat-client/src/liveState.ts @@ -45,7 +45,6 @@ import { selectedMessageContext, allChats, currentChatMembers, - currentChatDraftMessage, currentChatRules, } from "./stores/chat"; import { remainingStorage } from "./stores/storage"; @@ -62,9 +61,8 @@ import { currentCommunityRules, } from "./stores/community"; import { type GlobalState, chatListScopeStore, globalStateStore } from "./stores/global"; -import type { DraftMessage, DraftMessagesByThread } from "./stores/draftMessageFactory"; -import { draftThreadMessages } from "./stores/draftThreadMessages"; import { offlineStore } from "./stores/network"; +import { type DraftMessages, draftMessagesStore } from "./stores/draftMessages"; /** * Any stores that we reference inside the OpenChat client can be added here so that we always have the up to date current value @@ -110,8 +108,7 @@ export class LiveState { allChats!: ChatMap; selectedCommunity!: CommunitySummary | undefined; currentCommunityMembers!: Map; - currentChatDraftMessage!: DraftMessage | undefined; - draftThreadMessages!: DraftMessagesByThread; + draftMessages!: DraftMessages; currentCommunityRules!: VersionedRules | undefined; user!: CreatedUser; anonUser!: boolean; @@ -168,8 +165,7 @@ export class LiveState { allChats.subscribe((data) => (this.allChats = data)); selectedCommunity.subscribe((data) => (this.selectedCommunity = data)); currentCommunityMembers.subscribe((data) => (this.currentCommunityMembers = data)); - currentChatDraftMessage.subscribe((data) => (this.currentChatDraftMessage = data)); - draftThreadMessages.subscribe((data) => (this.draftThreadMessages = data)); + draftMessagesStore.subscribe((data) => (this.draftMessages = data)); currentCommunityRules.subscribe((data) => (this.currentCommunityRules = data)); } } diff --git a/frontend/openchat-client/src/openchat.ts b/frontend/openchat-client/src/openchat.ts index 59239df5e4..68de8d94d5 100644 --- a/frontend/openchat-client/src/openchat.ts +++ b/frontend/openchat-client/src/openchat.ts @@ -144,7 +144,6 @@ import { lastCryptoSent, nervousSystemLookup, } from "./stores/crypto"; -import { draftThreadMessages } from "./stores/draftThreadMessages"; import { disableAllProposalFilters, enableAllProposalFilters, @@ -438,10 +437,10 @@ import { localCommunitySummaryUpdates } from "./stores/localCommunitySummaryUpda import { hasFlag, moderationFlags } from "./stores/flagStore"; import { hasOwnerRights } from "./utils/permissions"; import { isDisplayNameValid, isUsernameValid } from "./utils/validation"; -import type { DraftMessage } from "./stores/draftMessageFactory"; import { verifyCredential } from "./utils/credentials"; import { offlineStore } from "./stores/network"; import { messageFiltersStore, type MessageFilter } from "./stores/messageFilters"; +import { draftMessagesStore } from "./stores/draftMessages"; const UPGRADE_POLL_INTERVAL = 1000; const MARK_ONLINE_INTERVAL = 61 * 1000; @@ -3247,13 +3246,6 @@ export class OpenChat extends OpenChatAgentWorker { return this._liveState.threadEvents; } - private draftMessageForMessageContext({ - threadRootMessageIndex, - }: MessageContext): DraftMessage | undefined { - if (threadRootMessageIndex === undefined) return this._liveState.currentChatDraftMessage; - return this._liveState.draftThreadMessages[threadRootMessageIndex]; - } - eventExpiry(chat: ChatSummary, timestamp: number): number | undefined { if (chat.kind === "group_chat" || chat.kind === "channel") { if (chat.eventsTTL !== undefined) { @@ -3277,7 +3269,7 @@ export class OpenChat extends OpenChatAgentWorker { return; } - const draftMessage = this.draftMessageForMessageContext(messageContext); + const draftMessage = this._liveState.draftMessages.get(messageContext); const currentEvents = this.eventsForMessageContext(messageContext); const [nextEventIndex, nextMessageIndex] = threadRootMessageIndex !== undefined @@ -3445,9 +3437,7 @@ export class OpenChat extends OpenChatAgentWorker { messagesRead.markReadUpTo(context, messageEvent.event.messageIndex - 1); } - if (threadRootMessageIndex === undefined) { - currentChatDraftMessage.clear(chat.id); - } + draftMessagesStore.delete(context); this.sendMessageWebRtc(chat, messageEvent, threadRootMessageIndex).then(() => { this.dispatchEvent(new SentMessage(context, messageEvent)); @@ -3507,8 +3497,6 @@ export class OpenChat extends OpenChatAgentWorker { return Promise.resolve(false); } - const { chatId, threadRootMessageIndex } = messageContext; - if (textContent || attachment) { const msg = { ...editingEvent.event, @@ -3516,16 +3504,13 @@ export class OpenChat extends OpenChatAgentWorker { content: this.getMessageContent(textContent ?? undefined, attachment), }; localMessageUpdates.markContentEdited(msg.messageId, msg.content); - - if (threadRootMessageIndex === undefined) { - currentChatDraftMessage.clear(chatId); - } + draftMessagesStore.delete(messageContext); return this.sendRequest({ kind: "editMessage", chatId: chat.id, msg, - threadRootMessageIndex, + threadRootMessageIndex: messageContext.threadRootMessageIndex, }) .then((resp) => { if (resp !== "success") { @@ -5969,7 +5954,7 @@ export class OpenChat extends OpenChatAgentWorker { nervousSystemLookup = nervousSystemLookup; exchangeRatesLookupStore = exchangeRatesLookupStore; lastCryptoSent = lastCryptoSent; - draftThreadMessages = draftThreadMessages; + draftMessagesStore = draftMessagesStore; translationStore = translationStore; eventsStore = eventsStore; selectedChatStore = selectedChatStore; diff --git a/frontend/openchat-client/src/stores/chat.ts b/frontend/openchat-client/src/stores/chat.ts index f1550b365e..4cd11878f2 100644 --- a/frontend/openchat-client/src/stores/chat.ts +++ b/frontend/openchat-client/src/stores/chat.ts @@ -4,15 +4,12 @@ import type { ChatSpecificState, ChatSummary, DirectChatSummary, - EnhancedReplyContext, EventWrapper, - Message, ThreadSyncDetails, ChatIdentifier, DirectChatIdentifier, MultiUserChat, ChatListScope, - AttachmentContent, ExpiredEventsRange, MessageContext, } from "openchat-shared"; @@ -23,7 +20,6 @@ import { ChatMap, nullMembership, chatIdentifiersEqual, - isAttachmentContent, messageContextsEqual, } from "openchat-shared"; import { unconfirmed } from "./unconfirmed"; @@ -37,13 +33,12 @@ import { mergeChatMetrics, mergeLocalSummaryUpdates, } from "../utils/chat"; -import { currentUser, currentUserIdStore, suspendedUsers, userStore } from "./user"; +import { currentUser, currentUserIdStore, suspendedUsers } from "./user"; import DRange from "drange"; import { snsFunctions } from "./snsFunctions"; import { filteredProposalsStore, resetFilteredProposalsStore } from "./filteredProposals"; import { createChatSpecificObjectStore } from "./dataByChatFactory"; import { localMessageUpdates } from "./localMessageUpdates"; -import type { DraftMessage } from "./draftMessageFactory"; import { localChatSummaryUpdates } from "./localChatSummaryUpdates"; import { setsAreEqual } from "../utils/set"; import { failedMessagesStore } from "./failedMessages"; @@ -62,6 +57,7 @@ import { safeWritable } from "./safeWritable"; import { communityPreviewsStore, currentCommunityBlockedUsers } from "./community"; import { translationStore } from "./translation"; import { messageFiltersStore } from "./messageFilters"; +import { draftMessagesStore } from "./draftMessages"; let currentScope: ChatListScope = { kind: "direct_chat" }; chatListScopeStore.subscribe((s) => (currentScope = s)); @@ -733,40 +729,13 @@ export function clearServerEvents(id: ChatIdentifier): void { chatStateStore.setProp(id, "expiredEventRanges", new DRange()); } -/** - * You might think that this belongs in the chatStateStore, but this needs to persist across chat selection boundary - * so it has a different scope. - */ -const draftMessages = createChatSpecificObjectStore(selectedChatId, () => ({})); - -export const currentChatDraftMessage = { - ...draftMessages, - setTextContent: (id: ChatIdentifier, textContent: string | undefined): void => - draftMessages.setProp(id, "textContent", textContent), - setAttachment: (id: ChatIdentifier, attachment: AttachmentContent | undefined): void => - draftMessages.setProp(id, "attachment", attachment), - setReplyingTo: (id: ChatIdentifier, replyingTo: EnhancedReplyContext | undefined): void => - draftMessages.setProp(id, "replyingTo", replyingTo), - setEditing: (id: ChatIdentifier, editingEvent: EventWrapper): void => { - const users = get(userStore); - const updated = { - editingEvent, - attachment: isAttachmentContent(editingEvent.event.content) - ? editingEvent.event.content - : undefined, - replyingTo: - editingEvent.event.repliesTo && - editingEvent.event.repliesTo.kind === "rehydrated_reply_context" - ? { - ...editingEvent.event.repliesTo, - content: editingEvent.event.content, - sender: users[editingEvent.event.sender], - } - : undefined, - }; - draftMessages.update(id, (d) => ({ ...d, ...updated })); +export const currentChatDraftMessage = derived( + [draftMessagesStore, selectedChatId], + ([draftMessages, chatId]) => { + return chatId !== undefined ? draftMessages.get({ chatId }) ?? {} : {}; }, -}; +); + export const currentChatTextContent = createDerivedPropStore( currentChatDraftMessage, "textContent", diff --git a/frontend/openchat-client/src/stores/dataByMessageContextFactory.ts b/frontend/openchat-client/src/stores/dataByMessageContextFactory.ts new file mode 100644 index 0000000000..a8cc9d775e --- /dev/null +++ b/frontend/openchat-client/src/stores/dataByMessageContextFactory.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { writable, type Writable } from "svelte/store"; +import { type MessageContext, MessageContextMap } from "openchat-shared"; + +function setDataForMessageContext( + store: Writable>, + context: MessageContext, + data: T, +): void { + store.update((s) => { + s.set(context, data); + return s; + }); +} + +function updateDataForMessageContext( + store: Writable>, + context: MessageContext, + fn: (updateFn: T) => T, + empty: T, +): void { + store.update((s) => { + s.set(context, fn(s.get(context) ?? empty)); + return s; + }); +} + +export function createMessageContextSpecificObjectStore(init: () => T) { + const store = writable(new MessageContextMap()); + let storeValue = new MessageContextMap(); + store.subscribe((v) => (storeValue = v)); + + return { + subscribe: store.subscribe, + get: (context: MessageContext): T => storeValue.get(context) ?? init(), + update: (context: MessageContext, fn: (data: T) => T) => + updateDataForMessageContext(store, context, fn, init()), + set: (context: MessageContext, data: T) => setDataForMessageContext(store, context, data), + delete: (context: MessageContext) => { + if (storeValue.has(context)) { + store.update((state) => { + state.delete(context); + return state; + }); + return true; + } + return false; + }, + clear: (): void => store.set(new MessageContextMap()), + }; +} diff --git a/frontend/openchat-client/src/stores/draftMessageFactory.ts b/frontend/openchat-client/src/stores/draftMessageFactory.ts deleted file mode 100644 index f95dfe2889..0000000000 --- a/frontend/openchat-client/src/stores/draftMessageFactory.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - -import { get, writable } from "svelte/store"; -import { - isAttachmentContent, - type AttachmentContent, - type EnhancedReplyContext, - type EventWrapper, - type Message, -} from "openchat-shared"; -import { userStore } from "./user"; - -export type DraftMessagesByThread = Record; - -export type DraftMessage = { - textContent?: string | undefined; - attachment?: AttachmentContent | undefined; - editingEvent?: EventWrapper | undefined; - replyingTo?: EnhancedReplyContext | undefined; -}; - -export function createDraftMessages() { - const store = writable({} as DraftMessagesByThread); - - function set(id: number, draftMessage: DraftMessage): void { - store.update((draftMessages) => { - const current = draftMessages[id]; - return { - ...draftMessages, - [id]: { - ...current, - ...draftMessage, - }, - }; - }); - } - - return { - subscribe: store.subscribe, - get: (id: number): DraftMessage => { - return get(store)[id] ?? {}; - }, - setTextContent: (id: number, textContent: string | undefined): void => - set(id, { textContent }), - setAttachment: (id: number, attachment: AttachmentContent | undefined): void => - set(id, { attachment }), - setEditing: (id: number, editingEvent: EventWrapper): void => { - const users = get(userStore); - set(id, { - editingEvent, - attachment: isAttachmentContent(editingEvent.event.content) - ? editingEvent.event.content - : undefined, - replyingTo: - editingEvent.event.repliesTo && - editingEvent.event.repliesTo.kind === "rehydrated_reply_context" - ? { - ...editingEvent.event.repliesTo, - content: editingEvent.event.content, - sender: users[editingEvent.event.sender], - } - : undefined, - }); - }, - setReplyingTo: (id: number, replyingTo: EnhancedReplyContext | undefined): void => - set(id, { replyingTo }), - delete: (id: number): void => - store.update((draftMessages) => { - delete draftMessages[id]; - return draftMessages; - }), - }; -} diff --git a/frontend/openchat-client/src/stores/draftMessages.ts b/frontend/openchat-client/src/stores/draftMessages.ts new file mode 100644 index 0000000000..ff1297df60 --- /dev/null +++ b/frontend/openchat-client/src/stores/draftMessages.ts @@ -0,0 +1,58 @@ +import { get } from "svelte/store"; +import { + isAttachmentContent, + type AttachmentContent, + type EnhancedReplyContext, + type EventWrapper, + type Message, + type MessageContext, + type MessageContextMap, +} from "openchat-shared"; +import { createMessageContextSpecificObjectStore } from "./dataByMessageContextFactory"; +import { userStore } from "./user"; + +export type DraftMessages = MessageContextMap; + +export type DraftMessage = { + textContent?: string | undefined; + attachment?: AttachmentContent | undefined; + editingEvent?: EventWrapper | undefined; + replyingTo?: EnhancedReplyContext | undefined; +}; + +function createDraftMessages() { + const store = createMessageContextSpecificObjectStore(() => ({}) as DraftMessage); + + return { + ...store, + setTextContent: (context: MessageContext, textContent: string | undefined): void => + store.set(context, { textContent }), + setAttachment: (context: MessageContext, attachment: AttachmentContent | undefined): void => + store.set(context, { attachment }), + setEditing: (context: MessageContext, editingEvent: EventWrapper): void => { + const users = get(userStore); + store.update(context, (m) => ({ + ...m, + editingEvent, + attachment: isAttachmentContent(editingEvent.event.content) + ? editingEvent.event.content + : undefined, + replyingTo: + editingEvent.event.repliesTo && + editingEvent.event.repliesTo.kind === "rehydrated_reply_context" + ? { + ...editingEvent.event.repliesTo, + content: editingEvent.event.content, + sender: users[editingEvent.event.sender], + } + : undefined, + })); + }, + setReplyingTo: ( + context: MessageContext, + replyingTo: EnhancedReplyContext | undefined, + ): void => store.set(context, { replyingTo }), + }; +} + +export const draftMessagesStore = createDraftMessages(); diff --git a/frontend/openchat-client/src/stores/draftThreadMessages.ts b/frontend/openchat-client/src/stores/draftThreadMessages.ts deleted file mode 100644 index 6c198ac2d3..0000000000 --- a/frontend/openchat-client/src/stores/draftThreadMessages.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { createDraftMessages } from "./draftMessageFactory"; -export const draftThreadMessages = createDraftMessages();