From 4bfbcec0f2cc4a813dfbc423fe117563f7d89b46 Mon Sep 17 00:00:00 2001 From: Jonathan Rainville Date: Mon, 10 Aug 2026 13:43:21 -0400 Subject: [PATCH] feat(threads): implement threads feature POC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #21257 Implements threaded messages in community channels with UI integration, gated behind a feature flag ### Features - **Create thread** — right-click any message → "Create Thread" opens a thread rooted at that message as a sub-chat - **Open thread** — if a thread already exists the action shows "Open Thread" instead - **Auto-display threads** — threads are automatically listed in chat when loaded, positioned directly below their parent chat - **Parallel to replies** — threads are independent of the existing reply feature ### Architecture - Reuses `sendMessage` with a `threadId` parameter; no duplicate send variants - Thread messages are lazy-loaded per chat on first open and cached in memory - `hasThread` model role derived from the cache drives the Open/Create label - `threadId == parentMessageId` (validated against status-go behaviour), so no separate ID mapping is needed - Unified `MessageByChatID` API across all layers (Go → Nim → RPC) with optional `threadId` parameter - Thread message routing fixed: base chat only shows messages with `threadId IS NULL OR threadId = ''` - `isThread` property on ChatItem enables proper list positioning and future UI styling ### Implementation Details - Go layer: `MessageByChatID(chatID, threadID, ...)` handles both base and thread message fetching - Nim layer: Single `fetchMessages` function with optional threadId parameter - UI layer: Threads auto-populate in chat list via `SIGNAL_CHAT_THREADS_LOADED` signal - Threading: No separate reactions fetch for thread messages (only base chat reactions) --- src/app/global/feature_flags.nim | 10 + .../chat_section/chat_content/controller.nim | 6 + .../chat_content/input_area/controller.nim | 12 +- .../chat_content/input_area/io_interface.nim | 11 +- .../chat_content/input_area/module.nim | 35 ++- .../chat_content/input_area/view.nim | 8 + .../chat_content/io_interface.nim | 7 + .../chat_content/messages/controller.nim | 50 +++- .../chat_content/messages/io_interface.nim | 25 +- .../chat_content/messages/module.nim | 97 ++++++- .../chat_content/messages/view.nim | 28 ++ .../main/chat_section/chat_content/module.nim | 34 ++- .../modules/main/chat_section/controller.nim | 5 +- .../main/chat_section/io_interface.nim | 6 + src/app/modules/main/chat_section/item.nim | 6 + src/app/modules/main/chat_section/model.nim | 15 + src/app/modules/main/chat_section/module.nim | 89 +++++- src/app/modules/main/gifs/controller.nim | 2 +- src/app/modules/main/gifs/io_interface.nim | 2 +- src/app/modules/main/gifs/module.nim | 2 +- .../modules/shared_models/message_item.nim | 12 +- .../modules/shared_models/message_model.nim | 19 ++ src/app_service/service/chat/async_tasks.nim | 4 + src/app_service/service/chat/service.nim | 8 +- .../service/message/async_tasks.nim | 79 +++++- .../service/message/dto/message.nim | 2 + .../service/message/dto/thread.nim | 16 ++ src/app_service/service/message/service.nim | 268 +++++++++++++++++- .../service/stickers/async_tasks.nim | 1 + src/backend/chat.nim | 12 + src/backend/messages.nim | 6 +- ui/app/AppLayouts/Chat/ChatLayout.qml | 2 + .../AppLayouts/Chat/stores/MessageStore.qml | 7 + ui/app/AppLayouts/Chat/stores/RootStore.qml | 5 +- .../AppLayouts/Chat/views/ChatColumnView.qml | 13 +- .../AppLayouts/Chat/views/ChatContentView.qml | 6 + .../Chat/views/ChatMessagesView.qml | 5 + ui/app/AppLayouts/Chat/views/ChatView.qml | 2 + .../AppLayouts/stores/FeatureFlagsStore.qml | 1 + ui/app/mainui/sectionLoaders/ChatLoader.qml | 1 + .../sectionLoaders/CommunityChatLoader.qml | 1 + ui/i18n/qml_base_en.ts | 8 + ui/i18n/qml_cs.ts | 8 + ui/i18n/qml_es.ts | 8 + ui/i18n/qml_fr.ts | 16 +- ui/i18n/qml_ko.ts | 8 + ui/i18n/qml_uk.ts | 20 ++ .../views/chat/MessageContextMenuView.qml | 15 + ui/imports/shared/views/chat/MessageView.qml | 8 + ui/main.qml | 1 + vendor/status-go | 2 +- 51 files changed, 944 insertions(+), 70 deletions(-) create mode 100644 src/app_service/service/message/dto/thread.nim diff --git a/src/app/global/feature_flags.nim b/src/app/global/feature_flags.nim index 39543476f4..4f3a2f15f6 100644 --- a/src/app/global/feature_flags.nim +++ b/src/app/global/feature_flags.nim @@ -24,6 +24,7 @@ const DEFAULT_FLAG_LOCAL_BACKUP_ENABLED = true const DEFAULT_FLAG_PRIVACY_MODE_FEATURE_ENABLED = true const DEFAULT_FLAG_MESSAGE_LINK_SHARING_ENABLED = true const DEFAULT_FLAG_STATUS_SUPPORT_BOT_ENABLED = true +const DEFAULT_FLAG_THREADS_ENABLED = false # Compile time feature flags const DEFAULT_FLAG_DAPPS_ENABLED = true @@ -44,6 +45,7 @@ featureFlag("LOCAL_BACKUP_ENABLED", DEFAULT_FLAG_LOCAL_BACKUP_ENABLED) featureFlag("PRIVACY_MODE_FEATURE_ENABLED", DEFAULT_FLAG_PRIVACY_MODE_FEATURE_ENABLED) featureFlag("MESSAGE_LINK_SHARING_ENABLED", DEFAULT_FLAG_MESSAGE_LINK_SHARING_ENABLED) featureFlag("STATUS_SUPPORT_BOT_ENABLED", DEFAULT_FLAG_STATUS_SUPPORT_BOT_ENABLED) +featureFlag("THREADS_ENABLED", DEFAULT_FLAG_THREADS_ENABLED) featureFlag("DAPPS_ENABLED", DEFAULT_FLAG_DAPPS_ENABLED, true) featureFlag("BROWSER_ENABLED", DEFAULT_FLAG_BROWSER_ENABLED, true) @@ -100,6 +102,7 @@ QtObject: messageLinkSharingEnabled: bool statusSupportBotEnabled: bool buyEnabled: bool + threadsEnabled: bool proc setup(self: FeatureFlags) = self.QObject.setup() @@ -117,6 +120,7 @@ QtObject: self.messageLinkSharingEnabled = MESSAGE_LINK_SHARING_ENABLED self.statusSupportBotEnabled = STATUS_SUPPORT_BOT_ENABLED self.buyEnabled = BUY_ENABLED + self.threadsEnabled = THREADS_ENABLED proc newFeatureFlags*(): FeatureFlags = new(result) @@ -208,3 +212,9 @@ QtObject: proc getBuyEnabled*(self: FeatureFlags): bool {.slot.} = return self.buyEnabled + + QtProperty[bool] threadsEnabled: + read = getThreadsEnabled + + proc getThreadsEnabled*(self: FeatureFlags): bool {.slot.} = + return self.threadsEnabled diff --git a/src/app/modules/main/chat_section/chat_content/controller.nim b/src/app/modules/main/chat_section/chat_content/controller.nim index afdc51a687..8b0df5a64e 100644 --- a/src/app/modules/main/chat_section/chat_content/controller.nim +++ b/src/app/modules/main/chat_section/chat_content/controller.nim @@ -198,6 +198,12 @@ proc init*(self: Controller) = return self.delegate.onMessageEdited(args.message) + self.events.on(SIGNAL_CHAT_THREADS_LOADED) do(e: Args): + let args = ChatThreadsLoadedArgs(e) + if self.chatId != args.chatId: + return + self.delegate.onChatThreadsLoaded(args.threads) + proc getMyChatId*(self: Controller): string = return self.chatId diff --git a/src/app/modules/main/chat_section/chat_content/input_area/controller.nim b/src/app/modules/main/chat_section/chat_content/input_area/controller.nim index 90ce2849fd..7d0a9115a0 100644 --- a/src/app/modules/main/chat_section/chat_content/input_area/controller.nim +++ b/src/app/modules/main/chat_section/chat_content/input_area/controller.nim @@ -131,7 +131,8 @@ proc sendImages*(self: Controller, replyTo: string, preferredUsername: string = "", linkPreviews: seq[LinkPreview], - paymentRequests: seq[PaymentRequest]) = + paymentRequests: seq[PaymentRequest], + threadId: string = "") = self.resetLinkPreviews() self.chatService.asyncSendImages( self.chatId, @@ -140,7 +141,8 @@ proc sendImages*(self: Controller, replyTo, preferredUsername, linkPreviews, - paymentRequests + paymentRequests, + threadId ) proc sendChatMessage*(self: Controller, @@ -149,7 +151,8 @@ proc sendChatMessage*(self: Controller, contentType: int, preferredUsername: string = "", linkPreviews: seq[LinkPreview], - paymentRequests: seq[PaymentRequest]) = + paymentRequests: seq[PaymentRequest], + threadId: string) = self.resetLinkPreviews() self.chatService.asyncSendChatMessage(self.chatId, msg, @@ -157,7 +160,8 @@ proc sendChatMessage*(self: Controller, contentType, preferredUsername, linkPreviews, - paymentRequests + paymentRequests, + threadId = threadId ) proc getLinkPreviewEnabled*(self: Controller): bool = diff --git a/src/app/modules/main/chat_section/chat_content/input_area/io_interface.nim b/src/app/modules/main/chat_section/chat_content/input_area/io_interface.nim index 512afb4f9f..4b30b9c8f2 100644 --- a/src/app/modules/main/chat_section/chat_content/input_area/io_interface.nim +++ b/src/app/modules/main/chat_section/chat_content/input_area/io_interface.nim @@ -20,10 +20,15 @@ method isLoaded*(self: AccessInterface): bool {.base.} = method getModuleAsVariant*(self: AccessInterface): QVariant {.base.} = raise newException(ValueError, "No implementation available") -method sendChatMessage*(self: AccessInterface, msg: string, replyTo: string, contentType: int, linkPreviews: seq[LinkPreview], paymentRequests: seq[PaymentRequest]) {.base.} = +method sendChatMessage*(self: AccessInterface, msg: string, replyTo: string, contentType: int, + linkPreviews: seq[LinkPreview], paymentRequests: seq[PaymentRequest]) {.base.} = raise newException(ValueError, "No implementation available") -method sendImages*(self: AccessInterface, imagePathsJson: string, msg: string, replyTo: string, linkPreviews: seq[LinkPreview], paymentRequests: seq[PaymentRequest]) {.base.} = +method sendImages*(self: AccessInterface, imagePathsJson: string, msg: string, replyTo: string, + linkPreviews: seq[LinkPreview], paymentRequests: seq[PaymentRequest]) {.base.} = + raise newException(ValueError, "No implementation available") + +method getThreadId*(self: AccessInterface): string {.base.} = raise newException(ValueError, "No implementation available") method searchGifs*(self: AccessInterface, query: string) {.base.} = @@ -56,7 +61,7 @@ method searchGifsStarted*(self: AccessInterface) {.base.} = method searchGifsError*(self: AccessInterface) {.base.} = raise newException(ValueError, "No implementation available") -method serachGifsDone*(self: AccessInterface, gifs: seq[GifDto]) {.base.} = +method searchGifsDone*(self: AccessInterface, gifs: seq[GifDto]) {.base.} = raise newException(ValueError, "No implementation available") method getFavoritesGifs*(self: AccessInterface): seq[GifDto] {.base.} = diff --git a/src/app/modules/main/chat_section/chat_content/input_area/module.nim b/src/app/modules/main/chat_section/chat_content/input_area/module.nim index ebc280c946..f4a9cd247a 100644 --- a/src/app/modules/main/chat_section/chat_content/input_area/module.nim +++ b/src/app/modules/main/chat_section/chat_content/input_area/module.nim @@ -21,6 +21,7 @@ type viewVariant: QVariant controller: Controller moduleLoaded: bool + threadId: string proc newModule*( delegate: delegate_interface.AccessInterface, @@ -32,7 +33,8 @@ proc newModule*( communityService: community_service.Service, contactService: contact_service.Service, messageService: message_service.Service, - settingsService: settings_service.Service + settingsService: settings_service.Service, + threadId: string = "" ): Module = result = Module() @@ -41,6 +43,7 @@ proc newModule*( result.viewVariant = newQVariant(result.view) result.controller = controller.newController(result, events, sectionId, chatId, belongsToCommunity, chatService, communityService, contactService, messageService, settingsService) result.moduleLoaded = false + result.threadId = threadId method delete*(self: Module) = self.view.delete @@ -66,8 +69,17 @@ method getModuleAsVariant*(self: Module): QVariant = proc getChatId*(self: Module): string = return self.controller.getChatId() -method sendImages*(self: Module, imagePathsJson: string, msg: string, replyTo: string, linkPreviews: seq[LinkPreview], paymentRequests: seq[PaymentRequest]) = - self.controller.sendImages(imagePathsJson, msg, replyTo, singletonInstance.userProfile.getPreferredName(), linkPreviews, paymentRequests) +method sendImages*(self: Module, imagePathsJson: string, msg: string, replyTo: string, + linkPreviews: seq[LinkPreview], paymentRequests: seq[PaymentRequest]) = + self.controller.sendImages( + imagePathsJson = imagePathsJson, + msg = msg, + replyTo = replyTo, + preferredUsername = singletonInstance.userProfile.getPreferredName(), + linkPreviews = linkPreviews, + paymentRequests = paymentRequests, + self.threadId, + ) method sendChatMessage*( self: Module, @@ -75,9 +87,20 @@ method sendChatMessage*( replyTo: string, contentType: int, linkPreviews: seq[LinkPreview], - paymentRequests: seq[PaymentRequest]) = - self.controller.sendChatMessage(msg, replyTo, contentType, - singletonInstance.userProfile.getPreferredName(), linkPreviews, paymentRequests) + paymentRequests: seq[PaymentRequest], + ) = + self.controller.sendChatMessage( + msg = msg, + replyTo = replyTo, + contentType = contentType, + preferredUsername = singletonInstance.userProfile.getPreferredName(), + linkPreviews, + paymentRequests, + self.threadId, + ) + +method getThreadId*(self: Module): string = + return self.threadId method setText*(self: Module, text: string, unfurlNewUrls: bool) = self.controller.setText(text, unfurlNewUrls) diff --git a/src/app/modules/main/chat_section/chat_content/input_area/view.nim b/src/app/modules/main/chat_section/chat_content/input_area/view.nim index aaf226d481..22b6d02561 100644 --- a/src/app/modules/main/chat_section/chat_content/input_area/view.nim +++ b/src/app/modules/main/chat_section/chat_content/input_area/view.nim @@ -20,6 +20,7 @@ QtObject: urlsModelVariant: QVariant sendingInProgress: bool askToEnableLinkPreview: bool + threadId: string proc setSendingInProgress*(self: View, value: bool) @@ -37,6 +38,7 @@ QtObject: result.urlsModel = newUrlsModel() result.urlsModelVariant = newQVariant(result.urlsModel) result.askToEnableLinkPreview = false + result.threadId = "" proc load*(self: View) = self.delegate.viewDidLoad() @@ -57,6 +59,12 @@ QtObject: self.delegate.setText(msg, false) self.delegate.sendImages(imagePathsJson, msg, replyTo, self.linkPreviewModel.getUnfuledLinkPreviews(), self.payment_request_model.getPaymentRequests()) + proc getThreadId(self: View): string {.slot.} = + return self.delegate.getThreadId() + + QtProperty[string] threadId: + read = getThreadId + proc getPreservedProperties(self: View): QVariant {.slot.} = return self.preservedPropertiesVariant diff --git a/src/app/modules/main/chat_section/chat_content/io_interface.nim b/src/app/modules/main/chat_section/chat_content/io_interface.nim index 685233822d..0ccbfde9ee 100644 --- a/src/app/modules/main/chat_section/chat_content/io_interface.nim +++ b/src/app/modules/main/chat_section/chat_content/io_interface.nim @@ -5,6 +5,7 @@ import app_service/service/message/dto/pinned_message import app_service/service/chat/dto/chat import app_service/service/message/dto/message import app_service/service/message/dto/reaction +import app_service/service/message/dto/thread type AccessInterface* {.pure inheritable.} = ref object of RootObj @@ -27,6 +28,12 @@ method getModuleAsVariant*(self: AccessInterface): QVariant {.base.} = method onNotificationsUpdated*(self: AccessInterface, hasUnreadMessages: bool, notificationCount: int) {.base.} = raise newException(ValueError, "No implementation available") +method openThreadAsChat*(self: AccessInterface, threadId: string, threadName: string, parentMessageId: string) {.base.} = + raise newException(ValueError, "No implementation available") + +method onChatThreadsLoaded*(self: AccessInterface, threads: seq[ThreadDto]) {.base.} = + raise newException(ValueError, "No implementation available") + method newPinnedMessagesLoaded*(self: AccessInterface, pinnedMessages: seq[PinnedMessageDto], reactions: seq[ReactionDto]) {.base.} = raise newException(ValueError, "No implementation available") diff --git a/src/app/modules/main/chat_section/chat_content/messages/controller.nim b/src/app/modules/main/chat_section/chat_content/messages/controller.nim index fcf6d382d9..e3a621ea10 100644 --- a/src/app/modules/main/chat_section/chat_content/messages/controller.nim +++ b/src/app/modules/main/chat_section/chat_content/messages/controller.nim @@ -22,6 +22,7 @@ type events: UniqueUUIDEventEmitter sectionId: string chatId: string + threadId: string belongsToCommunity: bool searchedMessageId: string loadingMessagesPerPageFactor: int @@ -35,12 +36,14 @@ type proc newController*(delegate: io_interface.AccessInterface, events: EventEmitter, sectionId: string, chatId: string, belongsToCommunity: bool, contactService: contact_service.Service, communityService: community_service.Service, chatService: chat_service.Service, messageService: message_service.Service, - mailserversService: mailservers_service.Service, sharedUrlsService: shared_urls_service.Service): Controller = + mailserversService: mailservers_service.Service, sharedUrlsService: shared_urls_service.Service, + threadId: string = ""): Controller = result = Controller() result.delegate = delegate result.events = initUniqueUUIDEventEmitter(events) result.sectionId = sectionId result.chatId = chatId + result.threadId = threadId result.loadingMessagesPerPageFactor = 1 result.belongsToCommunity = belongsToCommunity result.contactService = contactService @@ -72,7 +75,7 @@ proc init*(self: Controller) = self.events.on(SIGNAL_SENDING_SUCCESS) do(e:Args): let args = MessageSendingSuccess(e) - if self.chatId != args.chat.id: + if self.chatId != args.chat.id or args.message.threadId != self.threadId: return self.delegate.onSendingMessageSuccess(args.message) @@ -235,12 +238,52 @@ proc init*(self: Controller) = let args = GetMessageResult(e) self.delegate.onGetMessageById(args.requestId, args.messageId, args.message, args.error) + self.events.on(SIGNAL_THREAD_CREATED) do(e: Args): + let args = ThreadCreatedArgs(e) + if self.chatId != args.chatId: + return + self.delegate.onThreadCreated(args.parentMessageId, args.threads) + + self.events.on(SIGNAL_THREAD_MESSAGES_LOADED) do(e: Args): + let args = ThreadMessagesLoadedArgs(e) + if self.chatId != args.chatId: + return + if self.threadId != args.threadId: + return + self.delegate.newMessagesLoaded(args.messages, @[]) + + self.events.on(SIGNAL_CHAT_THREADS_LOADED) do(e: Args): + let args = ChatThreadsLoadedArgs(e) + if self.chatId != args.chatId: + return + self.delegate.onChatThreadsLoaded(args.threads) + proc getMySectionId*(self: Controller): string = return self.sectionId proc getMyChatId*(self: Controller): string = return self.chatId +proc getMyThreadId*(self: Controller): string = + return self.threadId + +proc setThreadId*(self: Controller, threadId: string) = + self.threadId = threadId + +proc createThread*(self: Controller, parentMessageId: string) = + if parentMessageId.len == 0: + return + self.messageService.asyncCreateThread(self.chatId, parentMessageId) + +proc loadChatThreadsIfNeeded*(self: Controller) = + self.messageService.loadChatThreadsIfNeeded(self.chatId) + +proc hasThreadForParentMessage*(self: Controller, parentMessageId: string): bool = + return self.messageService.chatHasThreadForParentMessage(self.chatId, parentMessageId) + +proc closeThread*(self: Controller) = + self.threadId = "" + proc getChatDetails*(self: Controller): lent ChatDto = return self.chatService.getChatById(self.chatId) @@ -264,6 +307,9 @@ proc belongsToCommunity*(self: Controller): bool = proc loadMoreMessages*(self: Controller): bool = let limit = self.loadingMessagesPerPageFactor * MESSAGES_PER_PAGE + # TODO is it possible to just extend the existing messages API? + if self.threadId.len > 0: + return self.messageService.asyncLoadMoreMessagesForThread(self.chatId, self.threadId, limit) return self.messageService.asyncLoadMoreMessagesForChat(self.chatId, limit) proc addReaction*(self: Controller, messageId: string, emoji: string) = diff --git a/src/app/modules/main/chat_section/chat_content/messages/io_interface.nim b/src/app/modules/main/chat_section/chat_content/messages/io_interface.nim index f0a455614a..6338637eb8 100644 --- a/src/app/modules/main/chat_section/chat_content/messages/io_interface.nim +++ b/src/app/modules/main/chat_section/chat_content/messages/io_interface.nim @@ -1,8 +1,9 @@ import nimqml, uuids -import ../../../../../../app_service/service/message/dto/[message, reaction, pinned_message] -import ../../../../../../app_service/service/community/dto/community -import ../../../../../../app_service/common/types +import app_service/service/message/dto/[message, reaction, pinned_message] +import app_service/service/message/dto/thread +import app_service/service/community/dto/community +import app_service/common/types type AccessInterface* {.pure inheritable.} = ref object of RootObj @@ -105,6 +106,9 @@ method getSectionId*(self: AccessInterface): string {.base.} = method getChatId*(self: AccessInterface): string {.base.} = raise newException(ValueError, "No implementation available") +method getThreadId*(self: AccessInterface): string {.base.} = + raise newException(ValueError, "No implementation available") + method getChatType*(self: AccessInterface): int {.base.} = raise newException(ValueError, "No implementation available") @@ -188,3 +192,18 @@ method onGetMessageById*(self: AccessInterface, requestId: UUID, messageId: stri method forceLinkPreviewsLocalData*(self: AccessInterface, messageId: string) {.base.} = raise newException(ValueError, "No implementation available") + +method createThread*(self: AccessInterface, parentMessageId: string) {.base.} = + raise newException(ValueError, "No implementation available") + +method closeThread*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method setThreadId*(self: AccessInterface, threadId: string) {.base.} = + raise newException(ValueError, "No implementation available") + +method onThreadCreated*(self: AccessInterface, parentMessageId: string, threads: seq[ThreadDto]) {.base.} = + raise newException(ValueError, "No implementation available") + +method onChatThreadsLoaded*(self: AccessInterface, threads: seq[ThreadDto]) {.base.} = + raise newException(ValueError, "No implementation available") diff --git a/src/app/modules/main/chat_section/chat_content/messages/module.nim b/src/app/modules/main/chat_section/chat_content/messages/module.nim index fe3ca7f4f8..551649b06b 100644 --- a/src/app/modules/main/chat_section/chat_content/messages/module.nim +++ b/src/app/modules/main/chat_section/chat_content/messages/module.nim @@ -11,6 +11,7 @@ import ../../../../../../app_service/service/contacts/service as contact_service import ../../../../../../app_service/service/community/service as community_service import ../../../../../../app_service/service/chat/service as chat_service import ../../../../../../app_service/service/message/service as message_service +import ../../../../../../app_service/service/message/dto/thread import ../../../../../../app_service/service/mailservers/service as mailservers_service import ../../../../../../app_service/service/shared_urls/service as shared_urls_service import ../../../../../../app_service/service/contacts/dto/contact_details @@ -47,14 +48,15 @@ type proc newModule*(delegate: delegate_interface.AccessInterface, events: EventEmitter, sectionId: string, chatId: string, belongsToCommunity: bool, contactService: contact_service.Service, communityService: community_service.Service, chatService: chat_service.Service, messageService: message_service.Service, - mailserversService: mailservers_service.Service, sharedUrlsService: shared_urls_service.Service): + mailserversService: mailservers_service.Service, sharedUrlsService: shared_urls_service.Service, + threadId: string = ""): Module = result = Module() result.delegate = delegate result.view = view.newView(result) result.viewVariant = newQVariant(result.view) result.controller = controller.newController(result, events, sectionId, chatId, belongsToCommunity, contactService, - communityService, chatService, messageService, mailserversService, sharedUrlsService) + communityService, chatService, messageService, mailserversService, sharedUrlsService, threadId = threadId) result.moduleLoaded = false result.initialMessagesLoaded = false result.firstUnseenMessageState = (false, false, false) @@ -82,8 +84,14 @@ method isLoaded*(self: Module): bool = return self.moduleLoaded method viewDidLoad*(self: Module) = + self.controller.loadChatThreadsIfNeeded() + self.view.setThreadId(self.controller.getMyThreadId()) + + if self.controller.getMyThreadId().len > 0: + discard self.controller.loadMoreMessages() + let chatDto = self.controller.getChatDetails() - if chatDto.hasMoreMessagesToRequest(): + if self.controller.getMyThreadId().len == 0 and chatDto.hasMoreMessagesToRequest(): self.view.model().insertItemBasedOnClock(self.createFetchMoreMessagesItem()) self.updateChatIdentifier() @@ -159,6 +167,8 @@ proc createMessageItemsFromMessageDtos(self: Module, messages: seq[MessageDto], transactionValue, ) + item.hasThread = self.controller.hasThreadForParentMessage(message.id) + self.updateLinkPreviewsContacts(item, requestFromMailserver = item.seen) self.updateLinkPreviewsCommunities(item, requestFromMailserver = item.seen) @@ -264,17 +274,22 @@ proc currentUserWalletContainsAddress(self: Module, address: string): bool = return false method reevaluateViewLoadingState*(self: Module) = + let inThreadMode = self.controller.getMyThreadId().len > 0 let loading = not self.initialMessagesLoaded or - not self.firstUnseenMessageState.initialized or - self.firstUnseenMessageState.fetching or + (not inThreadMode and (not self.firstUnseenMessageState.initialized or + self.firstUnseenMessageState.fetching)) or self.view.getMessageSearchOngoing() self.view.setLoading(loading) method newMessagesLoaded*(self: Module, messages: seq[MessageDto], reactions: seq[ReactionDto]) = - if messages.len > 0: - var viewItems = self.createMessageItemsFromMessageDtos(messages, reactions) + var filtered = messages + if self.controller.getMyThreadId().len > 0: + filtered = messages.filterIt(it.threadId == self.controller.getMyThreadId()) - if self.controller.getChatDetails().hasMoreMessagesToRequest(): + if filtered.len > 0: + var viewItems = self.createMessageItemsFromMessageDtos(filtered, reactions) + + if self.controller.getMyThreadId().len == 0 and self.controller.getChatDetails().hasMoreMessagesToRequest(): viewItems.add(self.createFetchMoreMessagesItem()) viewItems.add(self.createChatIdentifierItem()) self.view.model().removeItem(FETCH_MORE_MESSAGES_MESSAGE_ID) @@ -290,7 +305,14 @@ method newMessagesLoaded*(self: Module, messages: seq[MessageDto], reactions: se self.reevaluateViewLoadingState() method messagesAdded*(self: Module, messages: seq[MessageDto]) = - let items = self.createMessageItemsFromMessageDtos(messages) + var filtered = messages + if self.controller.getMyThreadId().len > 0: + filtered = messages.filterIt(it.threadId == self.controller.getMyThreadId()) + + if filtered.len == 0: + return + + let items = self.createMessageItemsFromMessageDtos(filtered) self.view.model().insertItemsBasedOnClock(items) self.checkIfMessageLoadedAndScroll() @@ -396,6 +418,63 @@ method getSectionId*(self: Module): string = method getChatId*(self: Module): string = return self.controller.getMyChatId() +method getThreadId*(self: Module): string = + return self.controller.getMyThreadId() + +method setThreadId*(self: Module, threadId: string) = + if self.controller.getMyThreadId() == threadId: + return + + self.controller.setThreadId(threadId) + self.view.setThreadId(threadId) + self.initialMessagesLoaded = false + self.view.model().clear() + + if threadId.len == 0 and self.controller.getChatDetails().hasMoreMessagesToRequest(): + self.view.model().insertItemBasedOnClock(self.createFetchMoreMessagesItem()) + + self.updateChatIdentifier() + discard self.controller.loadMoreMessages() + self.reevaluateViewLoadingState() + +method createThread*(self: Module, parentMessageId: string) = + if self.controller.hasThreadForParentMessage(parentMessageId): + # TODO get thread name from service + # threadId == parentMessageId; open the existing thread as a sub-channel chat + # Note: threadName would ideally come from ThreadDto, but for existing threads + # the module should already be loaded, so threadName is not used + self.delegate.openThreadAsChat(parentMessageId, "", parentMessageId) + return + + self.controller.createThread(parentMessageId) + +method closeThread*(self: Module) = + self.setThreadId("") + +method onThreadCreated*(self: Module, parentMessageId: string, threads: seq[ThreadDto]) = + if threads.len == 0: + return + + var selectedThread = threads[0] + if parentMessageId.len > 0: + for thread in threads: + if thread.parentMessageId == parentMessageId: + selectedThread = thread + break + + if selectedThread.parentMessageId.len > 0: + self.view.model().setHasThread(selectedThread.parentMessageId, true) + + # Open the freshly created thread as a sub-channel chat + self.delegate.openThreadAsChat(selectedThread.threadId, selectedThread.name, selectedThread.parentMessageId) + +method onChatThreadsLoaded*(self: Module, threads: seq[ThreadDto]) = + for thread in threads: + if thread.parentMessageId.len > 0: + self.view.model().setHasThread(thread.parentMessageId, true) + # Add the thread to the chat list so it appears as a sub-chat + self.delegate.openThreadAsChat(thread.threadId, thread.name, thread.parentMessageId) + method getChatType*(self: Module): int = let chatDto = self.controller.getChatDetails() return chatDto.chatType.int diff --git a/src/app/modules/main/chat_section/chat_content/messages/view.nim b/src/app/modules/main/chat_section/chat_content/messages/view.nim index 6c7191ef1a..97199e1457 100644 --- a/src/app/modules/main/chat_section/chat_content/messages/view.nim +++ b/src/app/modules/main/chat_section/chat_content/messages/view.nim @@ -10,6 +10,7 @@ QtObject: delegate: io_interface.AccessInterface model: Model modelVariant: QVariant + threadId: string messageSearchOngoing: bool amIChatAdmin: bool isPinMessageAllowedForMembers: bool @@ -26,6 +27,7 @@ QtObject: result.delegate = delegate result.model = newModel() result.modelVariant = newQVariant(result.model) + result.threadId = "" result.messageSearchOngoing = false result.amIChatAdmin = false result.isPinMessageAllowedForMembers = false @@ -92,6 +94,32 @@ QtObject: proc getChatId*(self: View): string {.slot.} = return self.delegate.getChatId() + proc threadIdChanged*(self: View) {.signal.} + proc getThreadId*(self: View): string {.slot.} = + return self.threadId + proc setThreadId*(self: View, value: string) {.slot.} = + self.threadId = value + self.threadIdChanged() + + QtProperty[string] threadId: + read = getThreadId + notify = threadIdChanged + + proc createThread*(self: View, parentMessageId: string) {.slot.} = + self.delegate.createThread(parentMessageId) + + # QML uses this to enter an existing thread; forward to the module so + # controller/view/model state stay in sync. + proc setThreadIdFromUI*(self: View, value: string) {.slot.} = + self.delegate.setThreadId(value) + + proc closeThread*(self: View) {.slot.} = + self.delegate.closeThread() + + proc threadCreated*(self: View, threadId: string, parentMessageId: string) {.signal.} + proc emitThreadCreatedSignal*(self: View, threadId: string, parentMessageId: string) = + self.threadCreated(threadId, parentMessageId) + proc getNumberOfPinnedMessages*(self: View): int {.slot.} = return self.delegate.getNumberOfPinnedMessages() diff --git a/src/app/modules/main/chat_section/chat_content/module.nim b/src/app/modules/main/chat_section/chat_content/module.nim index e93ef74dd0..4cd61ba976 100644 --- a/src/app/modules/main/chat_section/chat_content/module.nim +++ b/src/app/modules/main/chat_section/chat_content/module.nim @@ -14,15 +14,15 @@ import input_area/module as input_area_module import messages/module as messages_module import users/module as users_module -import ../../../../../app_service/service/settings/service as settings_service -import ../../../../../app_service/service/node_configuration/service as node_configuration_service -import ../../../../../app_service/service/contacts/service as contact_service -import ../../../../../app_service/service/chat/service as chat_service -import ../../../../../app_service/service/community/service as community_service -import ../../../../../app_service/service/message/service as message_service -import ../../../../../app_service/service/mailservers/service as mailservers_service -import ../../../../../app_service/service/shared_urls/service as shared_urls_service -import ../../../../../app_service/common/types +import app_service/service/settings/service as settings_service +import app_service/service/node_configuration/service as node_configuration_service +import app_service/service/contacts/service as contact_service +import app_service/service/chat/service as chat_service +import app_service/service/community/service as community_service +import app_service/service/message/service as message_service +import app_service/service/mailservers/service as mailservers_service +import app_service/service/shared_urls/service as shared_urls_service +import app_service/common/types export io_interface @@ -45,7 +45,8 @@ proc newModule*(delegate: delegate_interface.AccessInterface, events: EventEmitt nodeConfigurationService: node_configuration_service.Service, contactService: contact_service.Service, chatService: chat_service.Service, communityService: community_service.Service, messageService: message_service.Service, - mailserversService: mailservers_service.Service, sharedUrlsService: shared_urls_service.Service): + mailserversService: mailservers_service.Service, sharedUrlsService: shared_urls_service.Service, + threadId: string = ""): Module = result = Module() result.delegate = delegate @@ -57,9 +58,9 @@ proc newModule*(delegate: delegate_interface.AccessInterface, events: EventEmitt result.moduleLoaded = false result.inputAreaModule = input_area_module.newModule(result, events, sectionId, chatId, belongsToCommunity, - chatService, communityService, contactService, messageService, settingsService) + chatService, communityService, contactService, messageService, settingsService, threadId = threadId) result.messagesModule = messages_module.newModule(result, events, sectionId, chatId, belongsToCommunity, - contactService, communityService, chatService, messageService, mailserversService, sharedUrlsService) + contactService, communityService, chatService, messageService, mailserversService, sharedUrlsService, threadId = threadId) result.usersModule = users_module.newModule(events, sectionId, chatId, belongsToCommunity, isUsersListAvailable, contactService, chat_service, communityService, messageService) @@ -258,6 +259,15 @@ method onMessageEdited*(self: Module, message: MessageDto) = method getMyChatId*(self: Module): string = self.controller.getMyChatId() +method openThreadAsChat*(self: Module, threadId: string, threadName: string, parentMessageId: string) = + self.delegate.openThreadAsChat(self.controller.getMyChatId(), threadId, threadName, parentMessageId, setActive = true) + +method onChatThreadsLoaded*(self: Module, threads: seq[ThreadDto]) = + for thread in threads: + if thread.parentMessageId.len > 0: + # Add the thread to the chat list so it appears as a sub-chat + self.delegate.openThreadAsChat(self.controller.getMyChatId(), thread.threadId, thread.name, thread.parentMessageId, setActive = false) + method muteChat*(self: Module, interval: int) = self.controller.muteChat(interval) diff --git a/src/app/modules/main/chat_section/controller.nim b/src/app/modules/main/chat_section/controller.nim index 54f2b4bcab..e2ea4abd83 100644 --- a/src/app/modules/main/chat_section/controller.nim +++ b/src/app/modules/main/chat_section/controller.nim @@ -169,7 +169,7 @@ proc init*(self: Controller) = self.contactService, self.chatService, self.communityService, self.messageService, self.mailserversService, self.sharedUrlsService, setChatAsActive = true) - if (self.isCommunitySection): + if self.isCommunitySection: self.events.on(SIGNAL_COMMUNITY_CHANNEL_CREATED) do(e:Args): let args = CommunityChatArgs(e) let belongsToCommunity = args.chat.communityId.len > 0 @@ -488,7 +488,8 @@ proc setActiveItem*(self: Controller, itemId: string) = self.delegate.activeItemSet(self.activeItemId) if self.activeItemId != "": - self.messageService.asyncLoadInitialMessagesForChat(self.activeItemId) + if not self.delegate.isChatThread(self.activeItemId): + self.messageService.asyncLoadInitialMessagesForChat(self.activeItemId) proc removeCommunityChat*(self: Controller, itemId: string) = self.communityService.deleteCommunityChat(self.getMySectionId(), itemId) diff --git a/src/app/modules/main/chat_section/io_interface.nim b/src/app/modules/main/chat_section/io_interface.nim index 4438bf0687..5771e8da81 100644 --- a/src/app/modules/main/chat_section/io_interface.nim +++ b/src/app/modules/main/chat_section/io_interface.nim @@ -171,6 +171,12 @@ method viewDidLoad*(self: AccessInterface) {.base.} = method setActiveItem*(self: AccessInterface, itemId: string) {.base.} = raise newException(ValueError, "No implementation available") +method openThreadAsChat*(self: AccessInterface, parentChatId: string, threadId: string, threadName: string, parentMessageId: string, setActive: bool = false) {.base.} = + raise newException(ValueError, "No implementation available") + +method isChatThread*(self: AccessInterface, chatId: string): bool {.base.} = + raise newException(ValueError, "No implementation available") + method getChatContentModule*(self: AccessInterface, chatId: string): QVariant {.base.} = raise newException(ValueError, "No implementation available") diff --git a/src/app/modules/main/chat_section/item.nim b/src/app/modules/main/chat_section/item.nim index be4d5fdacd..629e185cd5 100644 --- a/src/app/modules/main/chat_section/item.nim +++ b/src/app/modules/main/chat_section/item.nim @@ -42,6 +42,7 @@ type missingEncryptionKey: bool permissionsCheckOngoing: bool hidden: bool # cached: row hidden by its collapsed category (see recomputeHidden) + isThread: bool # Row is hidden by its collapsed category. Active, unmuted-unread and # notification-carrying chats stay visible so the list can surface them. @@ -88,6 +89,7 @@ proc initChatItem*( hideIfPermissionsNotMet: bool = false, missingEncryptionKey: bool = false, permissionsCheckOngoing: bool = false, + isThread: bool = false, ): ChatItem = result = ChatItem() result.id = id @@ -125,6 +127,7 @@ proc initChatItem*( result.missingEncryptionKey = missingEncryptionKey result.permissionsCheckOngoing = permissionsCheckOngoing result.recomputeHidden() + result.isThread = isThread proc `$`*(self: ChatItem): string = result = fmt"""chat_section/ChatItem( @@ -410,3 +413,6 @@ proc permissionsCheckOngoing*(self: ChatItem): bool = proc `permissionsCheckOngoing=`*(self: var ChatItem, value: bool) = self.permissionsCheckOngoing = value + +proc isThread*(self: ChatItem): bool = + self.isThread diff --git a/src/app/modules/main/chat_section/model.nim b/src/app/modules/main/chat_section/model.nim index 8e341ecc15..e5d4454cc1 100644 --- a/src/app/modules/main/chat_section/model.nim +++ b/src/app/modules/main/chat_section/model.nim @@ -263,6 +263,7 @@ QtObject: if categoryIdx == -1: return indexToInsertTo = categoryIdx + item.position + 1 + if indexToInsertTo < 0: indexToInsertTo = 0 elif indexToInsertTo >= self.items.len + 1: @@ -274,6 +275,20 @@ QtObject: self.countChanged() + proc appendItemAfterParent*(self: Model, item: ChatItem, parentIndex: int) = + if parentIndex < 0 or parentIndex >= self.items.len: + return + + let parentModelIndex = newQModelIndex() + defer: parentModelIndex.delete + + let indexToInsertTo = parentIndex + 1 + self.beginInsertRows(parentModelIndex, indexToInsertTo, indexToInsertTo) + self.items.insert(item, indexToInsertTo) + self.endInsertRows() + + self.countChanged() + proc changeCategoryOpened*(self: Model, categoryId: string, opened: bool) {.slot.} = for ind in 0 ..< self.items.len: if self.items[ind].categoryId == categoryId: diff --git a/src/app/modules/main/chat_section/module.nim b/src/app/modules/main/chat_section/module.nim index 2329e68751..98ce0ebaf0 100644 --- a/src/app/modules/main/chat_section/module.nim +++ b/src/app/modules/main/chat_section/module.nim @@ -1,4 +1,4 @@ -import nimqml, tables, chronicles, json, sequtils, std/strformat, sugar, marshal +import nimqml, tables, chronicles, json, sequtils, std/strformat, sugar, marshal, std/sets from seaqt/qtimer import QTimer, create, setSingleShot, onTimeout, start, stop, isActive import io_interface @@ -56,6 +56,17 @@ type # defers the first-activation model build off the tap handler (seaqt # QTimer, auto-destroyed via =destroy) initialBuildTimer: QTimer + # services retained so thread sub-channels can be created on demand + events: EventEmitter + settingsService: settings_service.Service + nodeConfigurationService: node_configuration_service.Service + contactService: contact_service.Service + chatService: chat_service.Service + communityService: community_service.Service + messageService: message_service.Service + mailserversService: mailservers_service.Service + sharedUrlsService: shared_urls_service.Service + threadChatIds: HashSet[string] # Forward declaration proc buildChatSectionUI( @@ -124,6 +135,17 @@ proc newModule*( result.moduleLoaded = false result.chatsLoaded = false + result.events = events + result.settingsService = settingsService + result.nodeConfigurationService = nodeConfigurationService + result.contactService = contactService + result.chatService = chatService + result.communityService = communityService + result.messageService = messageService + result.mailserversService = mailserversService + result.sharedUrlsService = sharedUrlsService + result.threadChatIds = initHashSet[string]() + result.chatContentModules = initOrderedTable[string, chat_content_module.AccessInterface]() if isCommunity: result.membersListModule = users_module.newModule(events, sectionId, chatId = "", isCommunity, @@ -447,6 +469,71 @@ method chatContentDidLoad*(self: Module) = method setActiveItem*(self: Module, itemId: string) = self.controller.setActiveItem(itemId) +method isChatThread*(self: Module, chatId: string): bool = + return self.threadChatIds.contains(chatId) + +method openThreadAsChat*(self: Module, parentChatId: string, threadId: string, threadName: string, parentMessageId: string, setActive: bool = false) = + if threadId.len == 0: + return + + # If the thread sub-channel already exists, just activate it. + if self.chatContentModules.contains(threadId) and setActive: + self.setActiveItem(threadId) + return + + let parentItem = self.view.chatsModel().getItemById(parentChatId) + if parentItem.isNil: + error "openThreadAsChat: unknown parent chat", parentChatId, methodName="openThreadAsChat" + return + + let parentIndex = self.view.chatsModel().getItemIdxById(parentChatId) + if parentIndex == -1: + error "openThreadAsChat: unknown parent chat index", parentChatId, methodName="openThreadAsChat" + return + + let belongsToCommunity = self.controller.isCommunity() + let isUsersListAvailable = parentItem.`type` != ChatType.OneToOne.int + + # The thread content module is keyed in the chat list by the threadId, but it + # loads/sends messages against the parent chat id together with the threadId. + self.chatContentModules[threadId] = chat_content_module.newModule( + self, self.events, self.controller.getMySectionId(), parentChatId, + belongsToCommunity, isUsersListAvailable, self.settingsService, self.nodeConfigurationService, + self.contactService, self.chatService, self.communityService, self.messageService, + self.mailserversService, self.sharedUrlsService, threadId = threadId) + + self.threadChatIds.incl(threadId) + + let threadItem = chat_item.initChatItem( + id = threadId, + name = "🧵 " & threadName, + usesDefaultName = false, + icon = parentItem.icon, + color = parentItem.color, + emoji = parentItem.emoji, + description = "", + `type` = parentItem.`type`, + parentItem.memberRole, + lastMessageTimestamp = 0, + lastMessageText = "", + hasUnreadMessages = false, + notificationsCount = 0, + muted = false, + blocked = false, + active = false, + position = parentItem.position, + categoryId = parentItem.categoryId, + categoryPosition = parentItem.categoryPosition, + canPost = parentItem.canPost, + canView = parentItem.canView, + canPostReactions = parentItem.canPostReactions, + isThread = true, + ) + + self.view.chatsModel().appendItemAfterParent(threadItem, parentIndex) + if setActive: + self.setActiveItem(threadId) + proc updateActiveChatMembership*(self: Module) = let activeChatId = self.controller.getActiveChatId() let chat = self.controller.getChatDetails(activeChatId) diff --git a/src/app/modules/main/gifs/controller.nim b/src/app/modules/main/gifs/controller.nim index 5dcc2e8799..93b3ce686f 100644 --- a/src/app/modules/main/gifs/controller.nim +++ b/src/app/modules/main/gifs/controller.nim @@ -48,7 +48,7 @@ proc init*(self: Controller) = self.events.on(SIGNAL_SEARCH_GIFS_DONE) do(e:Args): let args = GifsArgs(e) - self.delegate.serachGifsDone(args.gifs) + self.delegate.searchGifsDone(args.gifs) self.events.on(SIGNAL_SEARCH_GIFS_ERROR) do(e:Args): self.delegate.searchGifsError() diff --git a/src/app/modules/main/gifs/io_interface.nim b/src/app/modules/main/gifs/io_interface.nim index 903e43c57c..aaffc19040 100644 --- a/src/app/modules/main/gifs/io_interface.nim +++ b/src/app/modules/main/gifs/io_interface.nim @@ -45,7 +45,7 @@ method searchGifsStarted*(self: AccessInterface) {.base.} = method searchGifsError*(self: AccessInterface) {.base.} = raise newException(ValueError, "No implementation available") -method serachGifsDone*(self: AccessInterface, gifs: seq[GifDto]) {.base.} = +method searchGifsDone*(self: AccessInterface, gifs: seq[GifDto]) {.base.} = raise newException(ValueError, "No implementation available") method getFavoritesGifs*(self: AccessInterface): seq[GifDto] {.base.} = diff --git a/src/app/modules/main/gifs/module.nim b/src/app/modules/main/gifs/module.nim index 008c4aa0fc..4fd3852284 100644 --- a/src/app/modules/main/gifs/module.nim +++ b/src/app/modules/main/gifs/module.nim @@ -84,7 +84,7 @@ method searchGifsError*(self: Module) = # Just setting loading to false works because the UI shows an error when there are no gifs self.view.setGifLoading(false) -method serachGifsDone*(self: Module, gifs: seq[GifDto]) = +method searchGifsDone*(self: Module, gifs: seq[GifDto]) = self.view.setGifLoading(false) self.view.updateGifColumns(gifs) diff --git a/src/app/modules/shared_models/message_item.nim b/src/app/modules/shared_models/message_item.nim index 194303537a..2af5b89286 100644 --- a/src/app/modules/shared_models/message_item.nim +++ b/src/app/modules/shared_models/message_item.nim @@ -76,6 +76,7 @@ type albumImagesCount: int bridgeName: string paymentRequestModel: payment_request_model.Model + hasThread: bool proc initMessageItem*( id, @@ -132,6 +133,7 @@ proc initMessageItem*( bridgeMessage: BridgeMessage, quotedBridgeMessage: BridgeMessage, paymentRequests: seq[PaymentRequest], + hasThread: bool = false, ): Item = result = Item() result.id = id @@ -193,6 +195,7 @@ proc initMessageItem*( result.albumMessageIds = albumMessageIds result.albumImagesCount = albumImagesCount result.paymentRequestModel = newPaymentRequestModel(paymentRequests) + result.hasThread = hasThread if quotedMessageContentType == ContentType.DiscordMessage: result.quotedMessageAuthorDisplayName = quotedMessageDiscordMessage.author.name @@ -596,9 +599,16 @@ proc toJsonNode*(self: Item): JsonNode = "albumMessageImages": self.albumMessageImages, "albumMessageIds": self.albumMessageIds, "albumImagesCount": self.albumImagesCount, - "bridgeName": self.bridgeName + "bridgeName": self.bridgeName, + "hasThread": self.hasThread } +proc hasThread*(self: Item): bool {.inline.} = + self.hasThread + +proc `hasThread=`*(self: Item, value: bool) {.inline.} = + self.hasThread = value + proc editMode*(self: Item): bool {.inline.} = self.editMode diff --git a/src/app/modules/shared_models/message_model.nim b/src/app/modules/shared_models/message_model.nim index f57791f7bd..ea84d0a5a0 100644 --- a/src/app/modules/shared_models/message_model.nim +++ b/src/app/modules/shared_models/message_model.nim @@ -75,6 +75,7 @@ type BridgeName PaymentRequestModel CompressedKey + HasThread QtObject: type @@ -185,6 +186,7 @@ QtObject: ModelRole.BridgeName.int: "bridgeName", ModelRole.PaymentRequestModel.int: "paymentRequestModel", ModelRole.CompressedKey.int: "compressedKey", + ModelRole.HasThread.int: "hasThread", }.toTable method data(self: Model, index: QModelIndex, role: int): QVariant = @@ -356,6 +358,8 @@ QtObject: result = newQVariant(item.paymentRequestModel) of ModelRole.CompressedKey: result = newQVariant(item.compressedKey) + of ModelRole.HasThread: + result = newQVariant(item.hasThread) proc updateAdjacentMessageRolesAtIndex(self: Model, row: int) = if row < 0 or row >= self.items.len: @@ -628,6 +632,20 @@ QtObject: updateRole(pinned) updateRoleWithValue(pinnedBy, targetPinnedBy) + proc setHasThread*(self: Model, messageId: string, hasThread: bool) = + let ind = self.findIndexForMessageId(messageId) + if ind == -1: + return + + if self.items[ind].hasThread == hasThread: + return + + self.items[ind].hasThread = hasThread + + let index = self.createIndex(ind, 0, nil) + defer: index.delete + self.dataChanged(index, index, @[ModelRole.HasThread.int]) + proc getMessageByIdAsJson*(self: Model, messageId: string): JsonNode = for it in self.items: if(it.id == messageId): @@ -1002,4 +1020,5 @@ QtObject: message.bridgeMessage, message.quotedMessage.bridgeMessage, message.paymentRequests, + hasThread = false, ) diff --git a/src/app_service/service/chat/async_tasks.nim b/src/app_service/service/chat/async_tasks.nim index 99c0f8f18f..b8a316e54a 100644 --- a/src/app_service/service/chat/async_tasks.nim +++ b/src/app_service/service/chat/async_tasks.nim @@ -68,6 +68,7 @@ type chatId: string processedMsg: string replyTo: string + threadId: string contentType: int preferredUsername: string communityId: string @@ -84,6 +85,7 @@ const asyncSendMessageTask: Task = proc(argEncoded: string) {.gcsafe, nimcall.} arg.processedMsg, arg.replyTo, arg.contentType, + arg.threadId, arg.preferredUsername, arg.standardLinkPreviews, arg.statusLinkPreviews, @@ -107,6 +109,7 @@ type imagePathsJson: string msg: string replyTo: string + threadId: string preferredUsername: string standardLinkPreviews: JsonNode statusLinkPreviews: JsonNode @@ -138,6 +141,7 @@ const asyncSendImagesTask: Task = proc(argEncoded: string) {.gcsafe, nimcall.} = arg.msg, arg.replyTo, arg.preferredUsername, + arg.threadId, arg.standardLinkPreviews, arg.statusLinkPreviews, arg.paymentRequests diff --git a/src/app_service/service/chat/service.nim b/src/app_service/service/chat/service.nim index 940f300dad..e8ec198b32 100644 --- a/src/app_service/service/chat/service.nim +++ b/src/app_service/service/chat/service.nim @@ -425,7 +425,8 @@ QtObject: replyTo: string, preferredUsername: string = "", linkPreviews: seq[LinkPreview] = @[], - paymentRequests: seq[PaymentRequest] = @[]) = + paymentRequests: seq[PaymentRequest] = @[], + threadId: string = "") = try: let (standardLinkPreviews, statusLinkPreviews) = extractLinkPreviewsLists(linkPreviews) @@ -437,6 +438,7 @@ QtObject: imagePathsJson: imagePathsJson, msg: msg, replyTo: replyTo, + threadId: threadId, preferredUsername: preferredUsername, standardLinkPreviews: %standardLinkPreviews, statusLinkPreviews: %statusLinkPreviews, @@ -474,7 +476,8 @@ QtObject: preferredUsername: string = "", linkPreviews: seq[LinkPreview] = @[], paymentRequests: seq[PaymentRequest] = @[], - communityId: string = "") = + communityId: string = "", + threadId: string = "") = try: let allKnownContacts = self.contactService.getContactsByGroup(ContactsGroup.AllKnownContacts) let processedMsg = message_common.replaceMentionsWithPubKeys(allKnownContacts, msg) @@ -488,6 +491,7 @@ QtObject: chatId: chatId, processedMsg: processedMsg, replyTo: replyTo, + threadId: threadId, contentType: contentType, preferredUsername: preferredUsername, communityId: communityId, # Only send a community ID for the community invites diff --git a/src/app_service/service/message/async_tasks.nim b/src/app_service/service/message/async_tasks.nim index d997207e75..8e22b4917b 100644 --- a/src/app_service/service/message/async_tasks.nim +++ b/src/app_service/service/message/async_tasks.nim @@ -23,20 +23,29 @@ proc getCountAndCountWithMentionsFromResponse(chatId: string, seenAndUnseenMessa type AsyncFetchChatMessagesTaskArg = ref object of QObjectTaskArg chatId: string + threadId: string msgCursor: string limit: int + AsyncFetchChatThreadsTaskArg = ref object of QObjectTaskArg + chatId: string + + AsyncCreateThreadTaskArg = ref object of QObjectTaskArg + chatId: string + parentMessageId: string + proc asyncFetchChatMessagesTask(argEncoded: string) {.gcsafe, nimcall.} = let arg = decode[AsyncFetchChatMessagesTaskArg](argEncoded) try: var responseJson = %*{ - "chatId": arg.chatId + "chatId": arg.chatId, + "threadId": arg.threadId, } # handle messages var messagesArr: JsonNode var messagesCursor: JsonNode - let msgsResponse = status_go.fetchMessages(arg.chatId, arg.msgCursor, arg.limit) + let msgsResponse = status_go.fetchMessages(arg.chatId, arg.threadId, arg.msgCursor, arg.limit) if not msgsResponse.error.isNil: raise newException(CatchableError, msgsResponse.error.message) @@ -46,20 +55,70 @@ proc asyncFetchChatMessagesTask(argEncoded: string) {.gcsafe, nimcall.} = responseJson["messages"] = messagesArr responseJson["messagesCursor"] = messagesCursor - # handle reactions - var reactionsArr: JsonNode - let rResponse = status_go.fetchReactions(arg.chatId, arg.msgCursor, arg.limit) - if not rResponse.error.isNil: - raise newException(CatchableError, rResponse.error.message) - - reactionsArr = rResponse.result - responseJson["reactions"] = reactionsArr + # handle reactions (only for base chats, not threads) + if arg.threadId == "": + var reactionsArr: JsonNode + let rResponse = status_go.fetchReactions(arg.chatId, arg.msgCursor, arg.limit) + if not rResponse.error.isNil: + raise newException(CatchableError, rResponse.error.message) + responseJson["reactions"] = rResponse.result arg.finish(responseJson) except Exception as e: arg.finish(%* { "chatId": arg.chatId, + "threadId": arg.threadId, + "error": e.msg, + }) + +proc asyncFetchChatThreadsTask(argEncoded: string) {.gcsafe, nimcall.} = + let arg = decode[AsyncFetchChatThreadsTaskArg](argEncoded) + try: + let response = status_go_chat.fetchChatThreads(arg.chatId) + if not response.error.isNil: + raise newException(CatchableError, response.error.message) + + var responseJson = %*{ + "chatId": arg.chatId, + "threads": %*[], + "error": "", + } + + var threadsArr: JsonNode + if response.result.getProp("threads", threadsArr): + responseJson["threads"] = threadsArr + + arg.finish(responseJson) + except Exception as e: + arg.finish(%* { + "chatId": arg.chatId, + "error": e.msg, + }) + +proc asyncCreateThreadTask(argEncoded: string) {.gcsafe, nimcall.} = + let arg = decode[AsyncCreateThreadTaskArg](argEncoded) + try: + let response = status_go_chat.createThread(arg.chatId, arg.parentMessageId) + if not response.error.isNil: + raise newException(CatchableError, response.error.message) + + var responseJson = %*{ + "chatId": arg.chatId, + "parentMessageId": arg.parentMessageId, + "threads": %*[], + "error": "", + } + + var threadsArr: JsonNode + if response.result.getProp("threads", threadsArr): + responseJson["threads"] = threadsArr + + arg.finish(responseJson) + except Exception as e: + arg.finish(%* { + "chatId": arg.chatId, + "parentMessageId": arg.parentMessageId, "error": e.msg, }) diff --git a/src/app_service/service/message/dto/message.nim b/src/app_service/service/message/dto/message.nim index f5dab88481..3c0e63208d 100644 --- a/src/app_service/service/message/dto/message.nim +++ b/src/app_service/service/message/dto/message.nim @@ -111,6 +111,7 @@ type MessageDto* = object text*: string chatId*: string localChatId*: string + threadId*: string clock*: int64 replace*: string responseTo*: string @@ -259,6 +260,7 @@ proc toMessageDto*(jsonObj: JsonNode): MessageDto = discard jsonObj.getProp("text", result.text) discard jsonObj.getProp("chatId", result.chatId) discard jsonObj.getProp("localChatId", result.localChatId) + discard jsonObj.getProp("threadId", result.threadId) discard jsonObj.getProp("clock", result.clock) discard jsonObj.getProp("replace", result.replace) discard jsonObj.getProp("responseTo", result.responseTo) diff --git a/src/app_service/service/message/dto/thread.nim b/src/app_service/service/message/dto/thread.nim new file mode 100644 index 0000000000..5dbeb3a4d9 --- /dev/null +++ b/src/app_service/service/message/dto/thread.nim @@ -0,0 +1,16 @@ +import json + +include ../../../common/json_utils + +type ThreadDto* = object + threadId*: string + chatId*: string + parentMessageId*: string + name*: string + +proc toThreadDto*(jsonObj: JsonNode): ThreadDto = + result = ThreadDto() + discard jsonObj.getProp("threadId", result.threadId) + discard jsonObj.getProp("chatId", result.chatId) + discard jsonObj.getProp("parentMessageId", result.parentMessageId) + discard jsonObj.getProp("name", result.name) diff --git a/src/app_service/service/message/service.nim b/src/app_service/service/message/service.nim index d7f843e496..fd47cbf740 100644 --- a/src/app_service/service/message/service.nim +++ b/src/app_service/service/message/service.nim @@ -1,4 +1,4 @@ -import nimqml, tables, json, regex, sequtils, std/strformat, strutils, chronicles, times, oids, uuids +import nimqml, tables, json, regex, sequtils, std/strformat, strutils, chronicles, times, oids, uuids, sets import ../../common/utils as common_utils import ../../../app/core/tasks/[qt, threadpool] @@ -15,6 +15,7 @@ import ../wallet_account/service as wallet_account_service import ./dto/message as message_dto import ./dto/pinned_message as pinned_msg_dto import ./dto/reaction as reaction_dto +import ./dto/thread as thread_dto import ../chat/dto/chat as chat_dto import ./dto/pinned_message_update as pinned_msg_update_dto import ./dto/removed_message as removed_msg_dto @@ -32,6 +33,7 @@ import web3/conversions export message_dto export pinned_msg_dto export reaction_dto +export thread_dto logScope: topics = "messages-service" @@ -41,6 +43,12 @@ const MESSAGES_PER_PAGE_MAX* = 40 # Signals which may be emitted by this service: const SIGNAL_MESSAGES_LOADED* = "messagesLoaded" +const SIGNAL_CHAT_THREADS_LOADED* = "chatThreadsLoaded" +const SIGNAL_CHAT_THREADS_LOADING_FAILED* = "chatThreadsLoadingFailed" +const SIGNAL_THREAD_MESSAGES_LOADED* = "threadMessagesLoaded" +const SIGNAL_THREAD_MESSAGES_LOADING_FAILED* = "threadMessagesLoadingFailed" +const SIGNAL_THREAD_CREATED* = "threadCreated" +const SIGNAL_THREAD_CREATION_FAILED* = "threadCreationFailed" const SIGNAL_PINNED_MESSAGES_LOADED* = "pinnedMessagesLoaded" const SIGNAL_REACTIONS_FOR_MESSAGE_LOADED* = "signalReactionsForMessageLoaded" const SIGNAL_FIRST_UNSEEN_MESSAGE_LOADED* = "firstUnseenMessageLoaded" @@ -83,6 +91,20 @@ type messages*: seq[MessageDto] reactions*: seq[ReactionDto] + ChatThreadsLoadedArgs* = ref object of Args + chatId*: string + threads*: seq[ThreadDto] + + ThreadCreatedArgs* = ref object of Args + chatId*: string + parentMessageId*: string + threads*: seq[ThreadDto] + + ThreadMessagesLoadedArgs* = ref object of Args + chatId*: string + threadId*: string + messages*: seq[MessageDto] + PinnedMessagesLoadedArgs* = ref object of Args chatId*: string pinnedMessages*: seq[PinnedMessageDto] @@ -184,8 +206,14 @@ QtObject: walletAccountService: wallet_account_service.Service networkService: network_service.Service msgCursor: Table[string, MessageCursor] + threadMsgCursor: Table[string, MessageCursor] pinnedMsgCursor: Table[string, MessageCursor] numOfPinnedMessagesPerChat: Table[string, int] # [chat_id, num_of_pinned_messages] + chatThreadsParentIdsByChat: Table[string, HashSet[string]] + chatThreadsLoadedChats: HashSet[string] + chatThreadsLoadingChats: HashSet[string] + + proc asyncLoadChatThreads*(self: Service, chatId: string) proc delete*(self: Service) proc newService*( @@ -207,7 +235,53 @@ QtObject: result.walletAccountService = walletAccountService result.networkService = networkService result.msgCursor = initTable[string, MessageCursor]() + result.threadMsgCursor = initTable[string, MessageCursor]() result.pinnedMsgCursor = initTable[string, MessageCursor]() + result.chatThreadsParentIdsByChat = initTable[string, HashSet[string]]() + result.chatThreadsLoadedChats = initHashSet[string]() + result.chatThreadsLoadingChats = initHashSet[string]() + + proc replaceChatThreadsCache(self: Service, chatId: string, threads: seq[ThreadDto]) = + var parentIds = initHashSet[string]() + for thread in threads: + if thread.parentMessageId.len > 0: + parentIds.incl(thread.parentMessageId) + self.chatThreadsParentIdsByChat[chatId] = parentIds + + proc cacheCreatedThreads(self: Service, chatId: string, threads: seq[ThreadDto]) = + if not self.chatThreadsParentIdsByChat.hasKey(chatId): + self.chatThreadsParentIdsByChat[chatId] = initHashSet[string]() + + for thread in threads: + if thread.parentMessageId.len > 0: + self.chatThreadsParentIdsByChat[chatId].incl(thread.parentMessageId) + + proc clearChatThreadsCacheForChat(self: Service, chatId: string) = + self.chatThreadsParentIdsByChat.del(chatId) + self.chatThreadsLoadedChats.excl(chatId) + self.chatThreadsLoadingChats.excl(chatId) + + proc loadChatThreadsIfNeeded*(self: Service, chatId: string) = + if chatId.len == 0: + return + + if self.chatThreadsLoadedChats.contains(chatId): + return + + if self.chatThreadsLoadingChats.contains(chatId): + return + + self.chatThreadsLoadingChats.incl(chatId) + self.asyncLoadChatThreads(chatId) + + proc chatHasThreadForParentMessage*(self: Service, chatId: string, parentMessageId: string): bool = + if chatId.len == 0 or parentMessageId.len == 0: + return false + + if not self.chatThreadsParentIdsByChat.hasKey(chatId): + return false + + return self.chatThreadsParentIdsByChat[chatId].contains(parentMessageId) proc isChatCursorInitialized(self: Service, chatId: string): bool = return self.msgCursor.hasKey(chatId) @@ -219,8 +293,26 @@ QtObject: proc resetAllMessageCursors*(self: Service) = self.msgCursor = initTable[string, MessageCursor]() + self.threadMsgCursor = initTable[string, MessageCursor]() self.pinnedMsgCursor = initTable[string, MessageCursor]() + proc getThreadMessageCursorKey(self: Service, chatId: string, threadId: string): string = + return chatId & ":" & threadId + + proc initOrGetThreadMessageCursor(self: Service, chatId: string, threadId: string): MessageCursor = + let key = self.getThreadMessageCursorKey(chatId, threadId) + if not self.threadMsgCursor.hasKey(key): + self.threadMsgCursor[key] = initMessageCursor(value="", pending=false, mostRecent=false) + return self.threadMsgCursor[key] + + proc resetThreadMessageCursorsForChat*(self: Service, chatId: string) = + var keys = newSeq[string]() + for key in self.threadMsgCursor.keys: + if key.startsWith(chatId & ":"): + keys.add(key) + for key in keys: + self.threadMsgCursor.del(key) + proc initOrGetMessageCursor(self: Service, chatId: string): MessageCursor = if(not self.msgCursor.hasKey(chatId)): self.msgCursor[chatId] = initMessageCursor(value="", pending=false, mostRecent=false) @@ -281,6 +373,64 @@ QtObject: self.threadpool.start(arg) return true + proc asyncLoadChatThreads*(self: Service, chatId: string) = + if chatId.len == 0: + error "empty chat id", procName="asyncLoadChatThreads" + return + + let arg = AsyncFetchChatThreadsTaskArg( + tptr: asyncFetchChatThreadsTask, + vptr: cast[uint](self.vptr), + slot: "onAsyncLoadChatThreads", + chatId: chatId, + ) + + self.threadpool.start(arg) + + proc asyncLoadMoreMessagesForThread*(self: Service, chatId: string, threadId: string, + limit = MESSAGES_PER_PAGE): bool = + if chatId.len == 0 or threadId.len == 0: + error "empty chat id or thread id", procName="asyncLoadMoreMessagesForThread" + return false + + let msgCursor = self.initOrGetThreadMessageCursor(chatId, threadId) + if msgCursor.isPending(): + return true + + if msgCursor.isMostRecent(): + return false + + let msgCursorValue = msgCursor.getValue() + msgCursor.setPending() + + let arg = AsyncFetchChatMessagesTaskArg( + tptr: asyncFetchChatMessagesTask, + vptr: cast[uint](self.vptr), + slot: "onAsyncLoadMoreMessagesForThread", + chatId: chatId, + threadId: threadId, + msgCursor: msgCursorValue, + limit: if(limit <= MESSAGES_PER_PAGE_MAX): limit else: MESSAGES_PER_PAGE_MAX, + ) + + self.threadpool.start(arg) + return true + + proc asyncCreateThread*(self: Service, chatId: string, parentMessageId: string) = + if chatId.len == 0 or parentMessageId.len == 0: + error "empty chat id or parent message id", procName="asyncCreateThread" + return + + let arg = AsyncCreateThreadTaskArg( + tptr: asyncCreateThreadTask, + vptr: cast[uint](self.vptr), + slot: "onAsyncCreateThread", + chatId: chatId, + parentMessageId: parentMessageId, + ) + + self.threadpool.start(arg) + proc onAsyncLoadReactionsForMessage*(self: Service, response: string) {.slot.} = try: let responseObj = response.parseJson @@ -475,6 +625,13 @@ QtObject: for k in keys: self.msgCursor.del(k) + keys = @[] + for k in self.threadMsgCursor.keys: + if k.startsWith(communityId): + keys.add(k) + for k in keys: + self.threadMsgCursor.del(k) + keys = @[] for k in self.pinnedMsgCursor.keys: if k.startsWith(communityId): @@ -482,6 +639,13 @@ QtObject: for k in keys: self.pinnedMsgCursor.del(k) + keys = @[] + for k in self.chatThreadsParentIdsByChat.keys: + if k.startsWith(communityId): + keys.add(k) + for k in keys: + self.clearChatThreadsCacheForChat(k) + self.events.emit(SIGNAL_RELOAD_MESSAGES, ReloadMessagesArgs(communityId: communityId)) proc init*(self: Service) = @@ -550,6 +714,8 @@ QtObject: self.events.on(SIGNAL_CHAT_LEFT) do(e: Args): var chatArg = ChatArgs(e) self.resetMessageCursor(chatArg.chatId) + self.resetThreadMessageCursorsForChat(chatArg.chatId) + self.clearChatThreadsCacheForChat(chatArg.chatId) self.events.on(SignalType.LocalMessageBackupDone.event) do(e: Args): self.resetAllMessageCursors() @@ -678,6 +844,106 @@ QtObject: # notify view, this is important self.events.emit(SIGNAL_MESSAGES_LOADED, MessagesLoadedArgs()) + proc onAsyncLoadChatThreads*(self: Service, response: string) {.slot.} = + var chatId = "" + try: + let responseObj = response.parseJson + if responseObj.kind != JObject: + raise newException(CatchableError, "load chat threads response is not a json object") + + discard responseObj.getProp("chatId", chatId) + + let errorString = responseObj{"error"}.getStr() + if errorString != "": + raise newException(CatchableError, errorString) + + var threads: seq[ThreadDto] + var threadsArr: JsonNode + if responseObj.getProp("threads", threadsArr): + threads = map(threadsArr.getElems(), proc(x: JsonNode): ThreadDto = x.toThreadDto()) + + self.replaceChatThreadsCache(chatId, threads) + self.chatThreadsLoadedChats.incl(chatId) + self.chatThreadsLoadingChats.excl(chatId) + + self.events.emit(SIGNAL_CHAT_THREADS_LOADED, ChatThreadsLoadedArgs(chatId: chatId, threads: threads)) + except Exception as e: + if chatId.len > 0: + self.chatThreadsLoadingChats.excl(chatId) + self.events.emit(SIGNAL_CHAT_THREADS_LOADING_FAILED, ChatThreadsLoadedArgs(chatId: chatId, threads: @[])) + error "error loading chat threads", msg = e.msg + + proc onAsyncLoadMoreMessagesForThread*(self: Service, response: string) {.slot.} = + var threadId: string = "" + var chatId: string = "" + try: + let responseObj = response.parseJson + if responseObj.kind != JObject: + raise newException(CatchableError, "load thread messages response is not a json object") + + discard responseObj.getProp("chatId", chatId) + discard responseObj.getProp("threadId", threadId) + + let errorString = responseObj{"error"}.getStr() + if errorString != "": + raise newException(CatchableError, errorString) + + let msgCursor = self.initOrGetThreadMessageCursor(chatId, threadId) + + var msgCursorValue: string + if responseObj.getProp("messagesCursor", msgCursorValue): + msgCursor.setValue(msgCursorValue) + + var messagesArr: JsonNode + var messages: seq[MessageDto] + if responseObj.getProp("messages", messagesArr): + messages = map(messagesArr.getElems(), proc(x: JsonNode): MessageDto = x.toMessageDto()) + + self.checkPaymentRequestsInMessages(messages) + + self.events.emit(SIGNAL_THREAD_MESSAGES_LOADED, + ThreadMessagesLoadedArgs(chatId: chatId, threadId: threadId, messages: messages)) + except Exception as e: + error "error loading thread messages", msg = e.msg + if chatId.len > 0 and threadId.len > 0: + let key = self.getThreadMessageCursorKey(chatId, threadId) + if self.threadMsgCursor.hasKey(key): + # Clear the cursor for this thread so that we can try to load it again later + self.threadMsgCursor.del(key) + self.events.emit(SIGNAL_THREAD_MESSAGES_LOADING_FAILED, + ThreadMessagesLoadedArgs(chatId: chatId, threadId: threadId, messages: @[])) + + proc onAsyncCreateThread*(self: Service, response: string) {.slot.} = + var chatId: string = "" + var parentMessageId: string = "" + try: + let responseObj = response.parseJson + if responseObj.kind != JObject: + raise newException(CatchableError, "create thread response is not a json object") + + discard responseObj.getProp("chatId", chatId) + discard responseObj.getProp("parentMessageId", parentMessageId) + + let errorString = responseObj{"error"}.getStr() + if errorString != "": + raise newException(CatchableError, errorString) + + + var threads: seq[ThreadDto] + var threadsArr: JsonNode + if responseObj.getProp("threads", threadsArr): + threads = map(threadsArr.getElems(), proc(x: JsonNode): ThreadDto = x.toThreadDto()) + + self.cacheCreatedThreads(chatId, threads) + + self.events.emit(SIGNAL_THREAD_CREATED, + ThreadCreatedArgs(chatId: chatId, parentMessageId: parentMessageId, threads: threads)) + except Exception as e: + error "error creating thread", msg = e.msg + if chatId.len > 0: + self.events.emit(SIGNAL_THREAD_CREATION_FAILED, + ThreadCreatedArgs(chatId: chatId, parentMessageId: parentMessageId, threads: @[])) + proc onAsyncLoadCommunityMemberAllMessages*(self: Service, response: string) {.slot.} = try: let rpcResponseObj = response.parseJson diff --git a/src/app_service/service/stickers/async_tasks.nim b/src/app_service/service/stickers/async_tasks.nim index ccdbc738f5..5294a092e2 100644 --- a/src/app_service/service/stickers/async_tasks.nim +++ b/src/app_service/service/stickers/async_tasks.nim @@ -86,6 +86,7 @@ const asyncSendStickerTask: Task = proc(argEncoded: string) {.gcsafe, nimcall.} "You can see a nice sticker here!", arg.replyTo, ContentType.Sticker.int, + "", arg.preferredUsername, standardLinkPreviews = JsonNode(), statusLinkPreviews = JsonNode(), diff --git a/src/backend/chat.nim b/src/backend/chat.nim index 2191dee582..c9761d487f 100644 --- a/src/backend/chat.nim +++ b/src/backend/chat.nim @@ -55,6 +55,7 @@ proc sendChatMessage*( msg: string, replyTo: string, contentType: int, + threadId: string = "", preferredUsername: string = "", standardLinkPreviews: JsonNode, statusLinkPreviews: JsonNode, @@ -68,6 +69,7 @@ proc sendChatMessage*( "chatId": chatId, "text": msg, "responseTo": replyTo, + "threadId": threadId, "ensName": preferredUsername, "sticker": { "hash": stickerHash, @@ -86,6 +88,7 @@ proc sendImages*(chatId: string, msg: string, replyTo: string, preferredUsername: string, + threadId: string = "", standardLinkPreviews: JsonNode, statusLinkPreviews: JsonNode, paymentRequests: JsonNode, @@ -98,6 +101,7 @@ proc sendImages*(chatId: string, "ensName": preferredUsername, "text": msg, "responseTo": replyTo, + "threadId": threadId, "linkPreviews": standardLinkPreviews, "statusLinkPreviews": statusLinkPreviews, "paymentRequests": paymentRequests, @@ -105,6 +109,14 @@ proc sendImages*(chatId: string, ) callPrivateRPC("sendChatMessages".prefix, %* [imagesJson]) +proc createThread*(chatId: string, parentMessageId: string): RpcResponse[JsonNode] = + let payload = %* [chatId, parentMessageId] + result = callPrivateRPC("createThread".prefix, payload) + +proc fetchChatThreads*(chatId: string): RpcResponse[JsonNode] = + let payload = %* [chatId] + result = callPrivateRPC("chatThreads".prefix, payload) + proc muteChat*(chatId: string, interval: int): RpcResponse[JsonNode] = result = callPrivateRPC("muteChatV2".prefix, %* [ { diff --git a/src/backend/messages.nim b/src/backend/messages.nim index a9a41d44a2..28f75a6d59 100644 --- a/src/backend/messages.nim +++ b/src/backend/messages.nim @@ -4,9 +4,9 @@ import response_type export response_type -proc fetchMessages*(chatId: string, cursorVal: string, limit: int): RpcResponse[JsonNode] = - let payload = %* [chatId, cursorVal, limit] - result = callPrivateRPC("chatMessages".prefix, payload) +proc fetchMessages*(chatId: string, threadId: string = "", cursorVal: string, limit: int): RpcResponse[JsonNode] = + let payload = %* [chatId, threadId, cursorVal, limit] + result = callPrivateRPC("chatMessagesV2".prefix, payload) proc fetchPinnedMessages*(chatId: string, cursorVal: string, limit: int): RpcResponse[JsonNode] = let payload = %* [chatId, cursorVal, limit] diff --git a/ui/app/AppLayouts/Chat/ChatLayout.qml b/ui/app/AppLayouts/Chat/ChatLayout.qml index 4ab125cc6d..0e1c238ded 100644 --- a/ui/app/AppLayouts/Chat/ChatLayout.qml +++ b/ui/app/AppLayouts/Chat/ChatLayout.qml @@ -99,6 +99,7 @@ StackLayout { property bool sendViaPersonalChatEnabled property bool messageLinkSharingEnabled + property bool threadsFeatureEnabled property string disabledTooltipText property int extraLeftPadding: 0 @@ -317,6 +318,7 @@ StackLayout { root.communityPermissionsStore.viewOnlyPermissionsModel.count > 0 sendViaPersonalChatEnabled: root.sendViaPersonalChatEnabled messageLinkSharingEnabled: root.messageLinkSharingEnabled + threadsFeatureEnabled: root.threadsFeatureEnabled disabledTooltipText: root.disabledTooltipText paymentRequestFeatureEnabled: root.paymentRequestFeatureEnabled extraLeftPadding: root.extraLeftPadding diff --git a/ui/app/AppLayouts/Chat/stores/MessageStore.qml b/ui/app/AppLayouts/Chat/stores/MessageStore.qml index 51923349bd..059d049c3c 100644 --- a/ui/app/AppLayouts/Chat/stores/MessageStore.qml +++ b/ui/app/AppLayouts/Chat/stores/MessageStore.qml @@ -23,6 +23,7 @@ QtObject { readonly property string chatColor: messageModule ? messageModule.chatColor : Theme.palette.primaryColor1 readonly property string chatIcon: messageModule ? messageModule.chatIcon : "" readonly property bool keepUnread: messageModule ? messageModule.keepUnread : false + readonly property string threadId: messageModule ? messageModule.threadId : "" onMessageModuleChanged: { if(!messageModule) @@ -232,4 +233,10 @@ QtObject { return sharedUrlsModule.createMessageUrl(chatId, messageId) } + + function createThread(parentMessageId) { + if (!messageModule) + return + messageModule.createThread(parentMessageId) + } } diff --git a/ui/app/AppLayouts/Chat/stores/RootStore.qml b/ui/app/AppLayouts/Chat/stores/RootStore.qml index 7b1c2028ca..2a23d0b502 100644 --- a/ui/app/AppLayouts/Chat/stores/RootStore.qml +++ b/ui/app/AppLayouts/Chat/stores/RootStore.qml @@ -294,7 +294,10 @@ QtObject { return UrlUtils.convertUrlToLocalPath(file) } }) - chatContentModule.inputAreaModule.sendImages(JSON.stringify(convertedImagePaths), textMsg.trim(), replyMessageId) + chatContentModule.inputAreaModule.sendImages( + JSON.stringify(convertedImagePaths), + textMsg.trim(), + replyMessageId) result = true } else { if (textMsg.trim() !== "") { diff --git a/ui/app/AppLayouts/Chat/views/ChatColumnView.qml b/ui/app/AppLayouts/Chat/views/ChatColumnView.qml index 411ced0a9b..011f118661 100644 --- a/ui/app/AppLayouts/Chat/views/ChatColumnView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatColumnView.qml @@ -68,6 +68,7 @@ Item { property bool amIBanned: false property bool sendViaPersonalChatEnabled property bool messageLinkSharingEnabled + property bool threadsFeatureEnabled property string disabledTooltipText property bool paymentRequestFeatureEnabled property bool joined @@ -490,6 +491,7 @@ Item { isBlocked: model.blocked sendViaPersonalChatEnabled: root.sendViaPersonalChatEnabled messageLinkSharingEnabled: root.messageLinkSharingEnabled + threadsFeatureEnabled: root.threadsFeatureEnabled disabledTooltipText: root.disabledTooltipText areTestNetworksEnabled: root.areTestNetworksEnabled extraLeftPadding: root.extraLeftPadding @@ -512,6 +514,13 @@ Item { onEditMessageRequested: (messageId) => { d.startEditMessage(messageId) } + onOpenThread: (messageId) => { + if (root.threadsFeatureEnabled + && root.activeChatType === Constants.chatType.communityChat + && !d.activeMessagesStore.threadId) { + d.activeMessagesStore.createThread(messageId) + } + } onForceInputFocus: { chatInput.forceInputActiveFocus() } @@ -668,7 +677,7 @@ Item { return } - if (root.rootStore.sendMessage(d.activeChatContentModule.getMyChatId(), + if (root.rootStore.sendMessage(root.activeChatId, chatInput.getTextWithPublicKeys(), chatInput.isReply? chatInput.replyMessageId : "", chatInput.fileUrlsAndSources @@ -729,4 +738,4 @@ Item { } } } -} +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Chat/views/ChatContentView.qml b/ui/app/AppLayouts/Chat/views/ChatContentView.qml index bf1a91eb4d..64c0f157ef 100644 --- a/ui/app/AppLayouts/Chat/views/ChatContentView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatContentView.qml @@ -60,6 +60,7 @@ ColumnLayout { property bool sendViaPersonalChatEnabled property bool messageLinkSharingEnabled + property bool threadsFeatureEnabled property string disabledTooltipText property int extraLeftPadding: 0 @@ -68,6 +69,7 @@ ColumnLayout { property string myPublicKey signal showReplyArea(messageId: string) + signal openThread(messageId: string) signal forceInputFocus() signal editMessageRequested(messageId: string) @@ -140,6 +142,7 @@ ColumnLayout { channelEmoji: !chatContentModule ? "" : (chatContentModule.chatDetails.emoji || "") sendViaPersonalChatEnabled: root.sendViaPersonalChatEnabled messageLinkSharingEnabled: root.messageLinkSharingEnabled + threadsFeatureEnabled: root.threadsFeatureEnabled disabledTooltipText: root.disabledTooltipText areTestNetworksEnabled: root.areTestNetworksEnabled extraLeftPadding: root.extraLeftPadding @@ -156,6 +159,9 @@ ColumnLayout { onShowReplyArea: (messageId, senderId) => { root.showReplyArea(messageId) } + onOpenThread: (messageId) => { + root.openThread(messageId) + } onOpenStickerPackPopup: stickerPackId => root.openStickerPackPopup(stickerPackId) onTokenPaymentRequested: root.tokenPaymentRequested(recipientAddress, tokenKey, rawAmount) onEditModeChanged: (editModeOn, messageId) => { diff --git a/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml b/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml index 3b45fc9651..e539eb1245 100644 --- a/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml @@ -66,6 +66,7 @@ Item { property bool sendViaPersonalChatEnabled property bool messageLinkSharingEnabled + property bool threadsFeatureEnabled property string disabledTooltipText property int extraLeftPadding: 0 @@ -78,6 +79,7 @@ Item { signal tokenPaymentRequested(string recipientAddress, string tokenKey, string rawAmount) signal showReplyArea(string messageId, string author) signal editModeChanged(bool editModeOn, string messageId) + signal openThread(string messageId) // Unfurling related requests: signal setNeverAskAboutUnfurlingAgain(bool neverAskAgain) @@ -360,6 +362,7 @@ Item { sendViaPersonalChatEnabled: root.sendViaPersonalChatEnabled messageLinkSharingEnabled: root.messageLinkSharingEnabled createMessageLink: (chatId, messageId) => root.messageStore.createMessageLink(chatId, messageId) + threadsFeatureEnabled: root.threadsFeatureEnabled disabledTooltipText: root.disabledTooltipText areTestNetworksEnabled: root.areTestNetworksEnabled extraLeftPadding: root.extraLeftPadding @@ -418,6 +421,7 @@ Item { quotedMessageAlbumMessageImages: model.quotedMessageAlbumMessageImages.split(" ") quotedMessageAlbumImagesCount: model.quotedMessageAlbumImagesCount bridgeName: model.bridgeName + hasThread: model.hasThread gapFrom: model.gapFrom gapTo: model.gapTo @@ -446,6 +450,7 @@ Item { onTokenPaymentRequested: root.tokenPaymentRequested(recipientAddress, tokenKey, rawAmount) onShowReplyArea: (messageId, author) => root.showReplyArea(messageId, author) + onOpenThread: (messageId) => root.openThread(messageId) stickersLoaded: root.stickersLoaded diff --git a/ui/app/AppLayouts/Chat/views/ChatView.qml b/ui/app/AppLayouts/Chat/views/ChatView.qml index 4eaa5284d4..1c5f77c237 100644 --- a/ui/app/AppLayouts/Chat/views/ChatView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatView.qml @@ -110,6 +110,7 @@ Item { property bool sendViaPersonalChatEnabled property bool messageLinkSharingEnabled + property bool threadsFeatureEnabled property string disabledTooltipText property bool paymentRequestFeatureEnabled @@ -496,6 +497,7 @@ Item { amIBanned: root.sectionItemModel ? root.sectionItemModel.amIBanned : false sendViaPersonalChatEnabled: root.sendViaPersonalChatEnabled messageLinkSharingEnabled: root.messageLinkSharingEnabled + threadsFeatureEnabled: root.threadsFeatureEnabled disabledTooltipText: root.disabledTooltipText paymentRequestFeatureEnabled: root.paymentRequestFeatureEnabled extraLeftPadding: root.extraLeftPadding diff --git a/ui/app/AppLayouts/stores/FeatureFlagsStore.qml b/ui/app/AppLayouts/stores/FeatureFlagsStore.qml index 28ebb1bc19..8c6443514f 100644 --- a/ui/app/AppLayouts/stores/FeatureFlagsStore.qml +++ b/ui/app/AppLayouts/stores/FeatureFlagsStore.qml @@ -15,4 +15,5 @@ QtObject { property bool messageLinkSharingEnabled property bool statusSupportBotEnabled property bool buyEnabled + property bool threadsEnabled } diff --git a/ui/app/mainui/sectionLoaders/ChatLoader.qml b/ui/app/mainui/sectionLoaders/ChatLoader.qml index 333f4b8fe6..aa7200669a 100644 --- a/ui/app/mainui/sectionLoaders/ChatLoader.qml +++ b/ui/app/mainui/sectionLoaders/ChatLoader.qml @@ -248,6 +248,7 @@ Loader { ? root.networkConnectionStore.walletReadyForTransactionsToolTipText : ""), messageLinkSharingEnabled: Qt.binding(() => root.featureFlagsStore.messageLinkSharingEnabled && root.advancedStore.copyMessageLinksEnabled), + threadsFeatureEnabled: Qt.binding(() => root.featureFlagsStore.threadsEnabled), paymentRequestFeatureEnabled: Qt.binding(() => root.featureFlagsStore.paymentRequestEnabled), extraLeftPadding: Qt.binding(() => root.isPortraitMode ? SQUtils.Utils.swipeIndicatorWidth : 0), isPortraitMode: Qt.binding(() => root.isPortraitMode), diff --git a/ui/app/mainui/sectionLoaders/CommunityChatLoader.qml b/ui/app/mainui/sectionLoaders/CommunityChatLoader.qml index 99914f8871..6c7f8ae8e4 100644 --- a/ui/app/mainui/sectionLoaders/CommunityChatLoader.qml +++ b/ui/app/mainui/sectionLoaders/CommunityChatLoader.qml @@ -318,6 +318,7 @@ Loader { advancedStore: Qt.binding(() => root.advancedStore), messageLinkSharingEnabled: Qt.binding(() => root.featureFlagsStore.messageLinkSharingEnabled && root.advancedStore.copyMessageLinksEnabled), + threadsFeatureEnabled: Qt.binding(() => root.featureFlagsStore.threadsEnabled), paymentRequestFeatureEnabled: Qt.binding(() => root.featureFlagsStore.paymentRequestEnabled), extraLeftPadding: Qt.binding(() => root.isPortraitMode ? SQUtils.Utils.swipeIndicatorWidth : 0), mutualContactsModel: Qt.binding(() => root.contactsAdaptor.mutualContacts), diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts index 9011a17175..29b5071bd1 100644 --- a/ui/i18n/qml_base_en.ts +++ b/ui/i18n/qml_base_en.ts @@ -11356,6 +11356,14 @@ to load Reply to + + Open Thread + + + + Create Thread + + Edit message diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts index 220bea95a1..a47b836b5e 100644 --- a/ui/i18n/qml_cs.ts +++ b/ui/i18n/qml_cs.ts @@ -11426,6 +11426,14 @@ selhalo Reply to Odpovědět na + + Open Thread + + + + Create Thread + + Edit message Upravit zprávu diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts index 3b92750251..5e2d2f5bf6 100644 --- a/ui/i18n/qml_es.ts +++ b/ui/i18n/qml_es.ts @@ -11369,6 +11369,14 @@ al cargar Reply to Responder a + + Open Thread + + + + Create Thread + + Edit message Editar mensaje diff --git a/ui/i18n/qml_fr.ts b/ui/i18n/qml_fr.ts index 4e619413b5..b48bd94e69 100644 --- a/ui/i18n/qml_fr.ts +++ b/ui/i18n/qml_fr.ts @@ -11366,6 +11366,14 @@ chargement Reply to Répondre à + + Open Thread + Ouvrir le fil + + + Create Thread + Créer un fil + Edit message Modifier le message @@ -17019,11 +17027,11 @@ avec un retour à la ligne Fastest - + Le plus rapide Lowest fee - + Frais le plus bas Swap + Bridge @@ -17040,11 +17048,11 @@ avec un retour à la ligne %1s short for seconds - %1 s + %1 s Choose route - + Choisir la route by %1 diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts index 79a9c71cd0..41557d2582 100644 --- a/ui/i18n/qml_ko.ts +++ b/ui/i18n/qml_ko.ts @@ -11313,6 +11313,14 @@ to load Reply to 답글 대상 + + Open Thread + + + + Create Thread + + Edit message 메시지 편집 diff --git a/ui/i18n/qml_uk.ts b/ui/i18n/qml_uk.ts index 28ffc9d8bd..85866293a0 100644 --- a/ui/i18n/qml_uk.ts +++ b/ui/i18n/qml_uk.ts @@ -1842,6 +1842,10 @@ from "%1" to "%2" This channel no longer exists Цей канал більше не існує + + Send the Status Team bot a contact request to get useful Status tips, important updates, and share your feedback, ideas, or issues directly with the Status team. + + Invite People Запросити людей @@ -11425,6 +11429,14 @@ to load Reply to Відповісти + + Open Thread + + + + Create Thread + + Edit message Редагувати @@ -13667,6 +13679,10 @@ to load PrimaryNavSidebar + + Status Help Bot + + Activity Center Центр активності @@ -18682,6 +18698,10 @@ If a transaction with a lower nonce is pending, higher nonce transactions will r Copy link to profile Копіювати посилання на профіль + + Settings + Налаштування + Always online Завжди в мережі diff --git a/ui/imports/shared/views/chat/MessageContextMenuView.qml b/ui/imports/shared/views/chat/MessageContextMenuView.qml index 0ee2960b04..f8dee56d35 100644 --- a/ui/imports/shared/views/chat/MessageContextMenuView.qml +++ b/ui/imports/shared/views/chat/MessageContextMenuView.qml @@ -28,6 +28,8 @@ StatusMenu { property string selectedText property bool pinMessageAllowedForMembers: false + property bool threadsFeatureEnabled: false + property bool hasThread: false property bool isDebugEnabled: false property bool editRestricted: false property bool pinnedMessage: false @@ -43,6 +45,7 @@ StatusMenu { signal unpinMessage() signal pinnedMessagesLimitReached() signal showReplyArea(string messageSenderId) + signal openThread() signal toggleReaction(string hexcode) signal deleteMessage() signal editClicked() @@ -87,6 +90,17 @@ StatusMenu { enabled: !root.disabledForChat } + StatusAction { + id: openThreadAction + objectName: "messageContextMenu_openThread" + text: root.hasThread ? qsTr("Open Thread") : qsTr("Create Thread") + icon.name: "chat" + onTriggered: root.openThread() + enabled: !root.disabledForChat && + root.threadsFeatureEnabled && + root.chatType === Constants.chatType.communityChat + } + StatusAction { id: editMessageAction objectName: "messageContextMenu_edit" @@ -178,6 +192,7 @@ StatusMenu { StatusMenuSeparator { visible: deleteMessageAction.enabled && (replyToMenuItem.enabled || + openThreadAction.enabled || copyMessageMenuItem.enabled || copyMessageIdAction.enabled || copyMessageLinkAction.enabled || diff --git a/ui/imports/shared/views/chat/MessageView.qml b/ui/imports/shared/views/chat/MessageView.qml index 889ac2fa7f..cc8b42fbe6 100644 --- a/ui/imports/shared/views/chat/MessageView.qml +++ b/ui/imports/shared/views/chat/MessageView.qml @@ -149,6 +149,8 @@ Loader { property bool sendViaPersonalChatEnabled property bool messageLinkSharingEnabled + property bool threadsFeatureEnabled + property bool hasThread: false property string disabledTooltipText property int extraLeftPadding: 0 @@ -274,6 +276,8 @@ Loader { myPublicKey: userProfile.pubKey, amIChatAdmin: root.amIChatAdmin, pinMessageAllowedForMembers: messageStore.isPinMessageAllowedForMembers, + threadsFeatureEnabled: root.threadsFeatureEnabled, + hasThread: root.hasThread, chatType: messageStore.chatType, messageId: root.messageId, @@ -328,6 +332,7 @@ Loader { } signal showReplyArea(string messageId, string author) + signal openThread(string messageId) function startMessageFoundAnimation() { @@ -1352,6 +1357,9 @@ Loader { onShowReplyArea: (senderId) => { root.showReplyArea(messageContextMenuView.messageId, senderId) } + onOpenThread: { + root.openThread(messageContextMenuView.messageId) + } onCopyToClipboard: (text) => { ClipboardUtils.setText(text) } diff --git a/ui/main.qml b/ui/main.qml index 444c5a7fa4..5bafc7979d 100644 --- a/ui/main.qml +++ b/ui/main.qml @@ -55,6 +55,7 @@ Window { messageLinkSharingEnabled: featureFlags ? featureFlags.messageLinkSharingEnabled : false statusSupportBotEnabled: featureFlags ? featureFlags.statusSupportBotEnabled : false buyEnabled: featureFlags ? featureFlags.buyEnabled : false + threadsEnabled: featureFlags ? featureFlags.threadsEnabled : false } readonly property UtilsStore utilsStore: UtilsStore {} diff --git a/vendor/status-go b/vendor/status-go index d39761cb7a..563b40c238 160000 --- a/vendor/status-go +++ b/vendor/status-go @@ -1 +1 @@ -Subproject commit d39761cb7afb3aa23308d2f73e89770c3d4007e1 +Subproject commit 563b40c238e7126ef25c59dbc16e39249354c7ab