feat(threads): implement threads feature POC

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)
This commit is contained in:
Jonathan Rainville
2026-08-26 11:43:28 -04:00
parent 54f6272062
commit 4bfbcec0f2
51 changed files with 944 additions and 70 deletions
+10
View File
@@ -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
@@ -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
@@ -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 =
@@ -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.} =
@@ -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)
@@ -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
@@ -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")
@@ -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) =
@@ -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")
@@ -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
@@ -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()
@@ -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)
@@ -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)
@@ -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")
@@ -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
@@ -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:
+88 -1
View File
@@ -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)
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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.} =
+1 -1
View File
@@ -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)
+11 -1
View File
@@ -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
@@ -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,
)
@@ -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
+6 -2
View File
@@ -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
+69 -10
View File
@@ -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,
})
@@ -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)
@@ -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)
+267 -1
View File
@@ -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
@@ -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(),
+12
View File
@@ -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, %* [
{
+3 -3
View File
@@ -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]
+2
View File
@@ -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
@@ -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)
}
}
+4 -1
View File
@@ -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() !== "") {
@@ -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 {
}
}
}
}
}
@@ -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) => {
@@ -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
@@ -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
@@ -15,4 +15,5 @@ QtObject {
property bool messageLinkSharingEnabled
property bool statusSupportBotEnabled
property bool buyEnabled
property bool threadsEnabled
}
@@ -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),
@@ -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),
+8
View File
@@ -11356,6 +11356,14 @@ to load</source>
<source>Reply to</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Open Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Create Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit message</source>
<translation type="unfinished"></translation>
+8
View File
@@ -11426,6 +11426,14 @@ selhalo</translation>
<source>Reply to</source>
<translation>Odpovědět na</translation>
</message>
<message>
<source>Open Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Create Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit message</source>
<translation>Upravit zprávu</translation>
+8
View File
@@ -11369,6 +11369,14 @@ al cargar</translation>
<source>Reply to</source>
<translation>Responder a</translation>
</message>
<message>
<source>Open Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Create Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit message</source>
<translation>Editar mensaje</translation>
+12 -4
View File
@@ -11366,6 +11366,14 @@ chargement</translation>
<source>Reply to</source>
<translation>Répondre à</translation>
</message>
<message>
<source>Open Thread</source>
<translation>Ouvrir le fil</translation>
</message>
<message>
<source>Create Thread</source>
<translation>Créer un fil</translation>
</message>
<message>
<source>Edit message</source>
<translation>Modifier le message</translation>
@@ -17019,11 +17027,11 @@ avec un retour à la ligne</translation>
</message>
<message>
<source>Fastest</source>
<translation type="unfinished"></translation>
<translation>Le plus rapide</translation>
</message>
<message>
<source>Lowest fee</source>
<translation type="unfinished"></translation>
<translation>Frais le plus bas</translation>
</message>
<message>
<source>Swap + Bridge</source>
@@ -17040,11 +17048,11 @@ avec un retour à la ligne</translation>
<message>
<source>%1s</source>
<comment>short for seconds</comment>
<translation type="unfinished">%1&#xa0;s</translation>
<translation>%1&#xa0;s</translation>
</message>
<message>
<source>Choose route</source>
<translation type="unfinished"></translation>
<translation>Choisir la route</translation>
</message>
<message>
<source>by %1</source>
+8
View File
@@ -11313,6 +11313,14 @@ to load</source>
<source>Reply to</source>
<translation> </translation>
</message>
<message>
<source>Open Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Create Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit message</source>
<translation> </translation>
+20
View File
@@ -1842,6 +1842,10 @@ from &quot;%1&quot; to &quot;%2&quot;</source>
<source>This channel no longer exists</source>
<translation>Цей канал більше не існує</translation>
</message>
<message>
<source>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.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Invite People</source>
<translation>Запросити людей</translation>
@@ -11425,6 +11429,14 @@ to load</source>
<source>Reply to</source>
<translation>Відповісти</translation>
</message>
<message>
<source>Open Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Create Thread</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit message</source>
<translation>Редагувати</translation>
@@ -13667,6 +13679,10 @@ to load</source>
</context>
<context>
<name>PrimaryNavSidebar</name>
<message>
<source>Status Help Bot</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Activity Center</source>
<translation>Центр активності</translation>
@@ -18682,6 +18698,10 @@ If a transaction with a lower nonce is pending, higher nonce transactions will r
<source>Copy link to profile</source>
<translation>Копіювати посилання на профіль</translation>
</message>
<message>
<source>Settings</source>
<translation type="unfinished">Налаштування</translation>
</message>
<message>
<source>Always online</source>
<translation>Завжди в мережі</translation>
@@ -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 ||
@@ -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)
}
+1
View File
@@ -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 {}