From bf14b06d55a8bed1474b6138c4317261ba52245e Mon Sep 17 00:00:00 2001 From: Pascal Precht <445106+PascalPrecht@users.noreply.github.com> Date: Thu, 15 Sep 2022 09:31:38 +0200 Subject: [PATCH] feat(CreateCommunityPopup): add discord import progress panel and discord message handling This adds the UI plus all necessary models and signal handling to render discord import progress in the desktop application. It also introduces message handling for discord chat message types. Requires status-im/status-go#2826 to function Co-authored with @caybro --- .../core/signals/remote_signals/community.nim | 41 ++ .../signals/remote_signals/signal_type.nim | 3 + src/app/core/signals/signals_manager.nim | 3 + .../modules/main/activity_center/module.nim | 5 +- .../chat_content/messages/module.nim | 16 +- .../main/chat_section/chat_content/module.nim | 3 +- .../modules/main/communities/controller.nim | 37 ++ .../modules/main/communities/io_interface.nim | 11 + .../models/discord_import_error_item.nim | 31 ++ .../models/discord_import_errors_model.nim | 60 +++ .../models/discord_import_progress_item.nim | 45 +++ .../models/discord_import_task_item.nim | 68 ++++ .../models/discord_import_tasks_model.nim | 125 ++++++ src/app/modules/main/communities/module.nim | 45 +++ src/app/modules/main/communities/view.nim | 165 +++++++- .../shared_models/discord_message_item.nim | 68 ++++ .../modules/shared_models/message_item.nim | 27 +- .../modules/shared_models/message_model.nim | 6 +- src/app_service/common/types.nim | 1 + .../service/community/dto/community.nim | 24 ++ src/app_service/service/community/service.nim | 76 ++++ .../service/message/dto/message.nim | 63 ++++ src/app_service/service/message/service.nim | 10 +- src/backend/communities.nim | 39 ++ .../Chat/views/ChatMessagesView.qml | 1 + .../CommunitiesPortalLayout.qml | 15 +- .../popups/CreateCommunityPopup.qml | 81 ++-- .../popups/DiscordImportProgressContents.qml | 356 ++++++++++++++++++ .../popups/DiscordImportProgressDialog.qml | 51 +++ .../stores/CommunitiesStore.qml | 42 +++ ui/app/mainui/AppMain.qml | 51 +++ ui/imports/shared/controls/chat/UserImage.qml | 9 +- .../shared/controls/chat/UsernameLabel.qml | 11 +- ui/imports/shared/panels/CommunityBanner.qml | 14 +- ui/imports/shared/panels/ModuleWarning.qml | 70 +++- .../shared/popups/ConfirmationDialog.qml | 11 +- ui/imports/shared/views/chat/MessageView.qml | 23 +- ui/imports/utils/Constants.qml | 1 + 38 files changed, 1634 insertions(+), 74 deletions(-) create mode 100644 src/app/modules/main/communities/models/discord_import_error_item.nim create mode 100644 src/app/modules/main/communities/models/discord_import_errors_model.nim create mode 100644 src/app/modules/main/communities/models/discord_import_progress_item.nim create mode 100644 src/app/modules/main/communities/models/discord_import_task_item.nim create mode 100644 src/app/modules/main/communities/models/discord_import_tasks_model.nim create mode 100644 src/app/modules/shared_models/discord_message_item.nim create mode 100644 ui/app/AppLayouts/CommunitiesPortal/popups/DiscordImportProgressContents.qml create mode 100644 ui/app/AppLayouts/CommunitiesPortal/popups/DiscordImportProgressDialog.qml diff --git a/src/app/core/signals/remote_signals/community.nim b/src/app/core/signals/remote_signals/community.nim index e83835f9ad..039dbdb2d6 100644 --- a/src/app/core/signals/remote_signals/community.nim +++ b/src/app/core/signals/remote_signals/community.nim @@ -19,6 +19,18 @@ type DiscordCategoriesAndChannelsExtractedSignal* = ref object of Signal errors*: Table[string, DiscordImportError] errorsCount*: int +type DiscordCommunityImportProgressSignal* = ref object of Signal + communityId*: string + communityName*: string + tasks*: seq[DiscordImportTaskProgress] + progress*: float + errorsCount*: int + warningsCount*: int + stopped*: bool + +type DiscordCommunityImportFinishedSignal* = ref object of Signal + communityId*: string + proc fromEvent*(T: type CommunitySignal, event: JsonNode): CommunitySignal = result = CommunitySignal() result.signalType = SignalType.CommunityFound @@ -46,6 +58,30 @@ proc fromEvent*(T: type DiscordCategoriesAndChannelsExtractedSignal, event: Json result.errors[key] = err result.errorsCount = result.errorsCount+1 +proc fromEvent*(T: type DiscordCommunityImportProgressSignal, event: JsonNode): DiscordCommunityImportProgressSignal = + result = DiscordCommunityImportProgressSignal() + result.signalType = SignalType.DiscordCommunityImportProgress + result.tasks = @[] + + if event["event"]["importProgress"].kind == JObject: + let importProgressObj = event["event"]["importProgress"] + + result.communityId = importProgressObj{"communityId"}.getStr() + result.communityName = importProgressObj{"communityName"}.getStr() + result.progress = importProgressObj{"progress"}.getFloat() + result.errorsCount = importProgressObj{"errorsCount"}.getInt() + result.warningsCount = importProgressObj{"warningsCount"}.getInt() + result.stopped = importProgressObj{"stopped"}.getBool() + + if importProgressObj["tasks"].kind == JArray: + for task in importProgressObj["tasks"]: + result.tasks.add(task.toDiscordImportTaskProgress()) + +proc fromEvent*(T: type DiscordCommunityImportFinishedSignal, event: JsonNode): DiscordCommunityImportFinishedSignal = + result = DiscordCommunityImportFinishedSignal() + result.signalType = SignalType.DiscordCommunityImportFinished + result.communityId = event["event"]{"communityId"}.getStr() + proc createFromEvent*(T: type HistoryArchivesSignal, event: JsonNode): HistoryArchivesSignal = result = HistoryArchivesSignal() result.communityId = event["event"]{"communityId"}.getStr() @@ -83,3 +119,8 @@ proc historyArchivesUnseededFromEvent*(T: type HistoryArchivesSignal, event: Jso proc historyArchiveDownloadedFromEvent*(T: type HistoryArchivesSignal, event: JsonNode): HistoryArchivesSignal = result = HistoryArchivesSignal.createFromEvent(event) result.signalType = SignalType.HistoryArchiveDownloaded + +proc downloadingHistoryArchivesFinishedFromEvent*(T: type HistoryArchivesSignal, event: JsonNode): HistoryArchivesSignal = + result = HistoryArchivesSignal() + result.communityId = event["event"]{"communityId"}.getStr() + result.signalType = SignalType.DownloadingHistoryArchivesFinished diff --git a/src/app/core/signals/remote_signals/signal_type.nim b/src/app/core/signals/remote_signals/signal_type.nim index 7426e692dd..5ff2275f02 100644 --- a/src/app/core/signals/remote_signals/signal_type.nim +++ b/src/app/core/signals/remote_signals/signal_type.nim @@ -39,9 +39,12 @@ type SignalType* {.pure.} = enum HistoryArchivesSeeding = "community.historyArchivesSeeding" HistoryArchivesUnseeded = "community.historyArchivesUnseeded" HistoryArchiveDownloaded = "community.historyArchiveDownloaded" + DownloadingHistoryArchivesFinished = "community.downloadingHistoryArchivesFinished" UpdateAvailable = "update.available" DiscordCategoriesAndChannelsExtracted = "community.discordCategoriesAndChannelsExtracted" StatusUpdatesTimedout = "status.updates.timedout" + DiscordCommunityImportFinished = "community.discordCommunityImportFinished" + DiscordCommunityImportProgress = "community.discordCommunityImportProgress" Unknown proc event*(self:SignalType):string = diff --git a/src/app/core/signals/signals_manager.nim b/src/app/core/signals/signals_manager.nim index 70e2f46ee1..cea602a70c 100644 --- a/src/app/core/signals/signals_manager.nim +++ b/src/app/core/signals/signals_manager.nim @@ -93,9 +93,12 @@ QtObject: of SignalType.HistoryArchivesSeeding: HistoryArchivesSignal.historyArchivesSeedingFromEvent(jsonSignal) of SignalType.HistoryArchivesUnseeded: HistoryArchivesSignal.historyArchivesUnseededFromEvent(jsonSignal) of SignalType.HistoryArchiveDownloaded: HistoryArchivesSignal.historyArchiveDownloadedFromEvent(jsonSignal) + of SignalType.DownloadingHistoryArchivesFinished: HistoryArchivesSignal.downloadingHistoryArchivesFinishedFromEvent(jsonSignal) of SignalType.UpdateAvailable: UpdateAvailableSignal.fromEvent(jsonSignal) of SignalType.DiscordCategoriesAndChannelsExtracted: DiscordCategoriesAndChannelsExtractedSignal.fromEvent(jsonSignal) of SignalType.StatusUpdatesTimedout: StatusUpdatesTimedoutSignal.fromEvent(jsonSignal) + of SignalType.DiscordCommunityImportFinished: DiscordCommunityImportFinishedSignal.fromEvent(jsonSignal) + of SignalType.DiscordCommunityImportProgress: DiscordCommunityImportProgressSignal.fromEvent(jsonSignal) else: Signal() result.signalType = signalType diff --git a/src/app/modules/main/activity_center/module.nim b/src/app/modules/main/activity_center/module.nim index bd52f2d72a..498865bb01 100644 --- a/src/app/modules/main/activity_center/module.nim +++ b/src/app/modules/main/activity_center/module.nim @@ -96,7 +96,8 @@ proc createMessageItemFromDto(self: Module, message: MessageDto, chatDetails: Ch newTransactionParametersItem("","","","","","",-1,""), message.mentionedUsersPks, contactDetails.details.trustStatus, - contactDetails.details.ensVerified + contactDetails.details.ensVerified, + message.discordMessage )) method convertToItems*( @@ -243,4 +244,4 @@ method getDetails*(self: Module, sectionId: string, chatId: string): string = jsonObject["cImage"] = %* chatImage jsonObject["cColor"] = %* c.color jsonObject["cEmoji"] = %* c.emoji - return $jsonObject \ No newline at end of file + return $jsonObject 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 d50ed724f4..67792428f2 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 @@ -97,7 +97,8 @@ proc createFetchMoreMessagesItem(self: Module): Item = transactionParameters = newTransactionParametersItem("","","","","","",-1,""), mentionedUsersPks = @[], senderTrustStatus = TrustStatus.Unknown, - senderEnsVerified = false + senderEnsVerified = false, + DiscordMessage() ) proc createChatIdentifierItem(self: Module): Item = @@ -136,7 +137,8 @@ proc createChatIdentifierItem(self: Module): Item = transactionParameters = newTransactionParametersItem("","","","","","",-1,""), mentionedUsersPks = @[], senderTrustStatus = TrustStatus.Unknown, - senderEnsVerified = false + senderEnsVerified = false, + DiscordMessage() ) proc checkIfMessageLoadedAndScrollToItIfItIs(self: Module): bool = @@ -185,7 +187,7 @@ method newMessagesLoaded*(self: Module, messages: seq[MessageDto], reactions: se sender.defaultDisplayName, sender.optionalName, sender.icon, - isCurrentUser, + (isCurrentUser and m.contentType.ContentType != ContentType.DiscordMessage), sender.details.added, m.outgoingStatus, renderedMessageText, @@ -209,7 +211,8 @@ method newMessagesLoaded*(self: Module, messages: seq[MessageDto], reactions: se m.transactionParameters.signature), m.mentionedUsersPks(), sender.details.trustStatus, - sender.details.ensVerified + sender.details.ensVerified, + m.discordMessage ) for r in reactions: @@ -277,7 +280,7 @@ method messageAdded*(self: Module, message: MessageDto) = sender.defaultDisplayName, sender.optionalName, sender.icon, - isCurrentUser, + (isCurrentUser and message.contentType.ContentType != ContentType.DiscordMessage), sender.details.added, message.outgoingStatus, renderedMessageText, @@ -301,7 +304,8 @@ method messageAdded*(self: Module, message: MessageDto) = message.transactionParameters.signature), message.mentionedUsersPks, sender.details.trustStatus, - sender.details.ensVerified + sender.details.ensVerified, + message.discordMessage ) self.view.model().insertItemBasedOnTimestamp(item) 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 65e8d939c9..e3f7f07871 100644 --- a/src/app/modules/main/chat_section/chat_content/module.nim +++ b/src/app/modules/main/chat_section/chat_content/module.nim @@ -194,7 +194,8 @@ proc buildPinnedMessageItem(self: Module, messageId: string, actionInitiatedBy: m.transactionParameters.signature), m.mentionedUsersPks, contactDetails.details.trustStatus, - contactDetails.details.ensVerified + contactDetails.details.ensVerified, + m.discordMessage ) item.pinned = true item.pinnedBy = actionInitiatedBy diff --git a/src/app/modules/main/communities/controller.nim b/src/app/modules/main/communities/controller.nim index 9a91b64ec7..da2a81169b 100644 --- a/src/app/modules/main/communities/controller.nim +++ b/src/app/modules/main/communities/controller.nim @@ -70,6 +70,10 @@ proc init*(self: Controller) = let args = DiscordCategoriesAndChannelsArgs(e) self.delegate.discordCategoriesAndChannelsExtracted(args.categories, args.channels, args.oldestMessageTimestamp, args.errors, args.errorsCount) + self.events.on(SIGNAL_DISCORD_COMMUNITY_IMPORT_PROGRESS) do(e:Args): + let args = DiscordImportProgressArgs(e) + self.delegate.discordImportProgressUpdated(args.communityId, args.communityName, args.tasks, args.progress, args.errorsCount, args.warningsCount, args.stopped) + proc getCommunityTags*(self: Controller): string = result = self.communityService.getCommunityTags() @@ -113,6 +117,36 @@ proc createCommunity*( pinMessageAllMembersEnabled, bannerJsonStr) +proc requestImportDiscordCommunity*( + self: Controller, + name: string, + description: string, + introMessage: string, + outroMessage: string, + access: int, + color: string, + tags: string, + imageUrl: string, + aX: int, aY: int, bX: int, bY: int, + historyArchiveSupportEnabled: bool, + pinMessageAllMembersEnabled: bool, + filesToImport: seq[string], + fromTimestamp: int) = + self.communityService.requestImportDiscordCommunity( + name, + description, + introMessage, + outroMessage, + access, + color, + tags, + imageUrl, + aX, aY, bX, bY, + historyArchiveSupportEnabled, + pinMessageAllMembersEnabled, + filesToImport, + fromTimestamp) + proc reorderCommunityChat*( self: Controller, communityId: string, @@ -167,3 +201,6 @@ proc getStatusForContactWithId*(self: Controller, publicKey: string): StatusUpda proc requestExtractDiscordChannelsAndCategories*(self: Controller, filesToImport: seq[string]) = self.communityService.requestExtractDiscordChannelsAndCategories(filesToImport) + +proc requestCancelDiscordCommunityImport*(self: Controller, id: string) = + self.communityService.requestCancelDiscordCommunityImport(id) diff --git a/src/app/modules/main/communities/io_interface.nim b/src/app/modules/main/communities/io_interface.nim index 1662129da4..fddc0a52fa 100644 --- a/src/app/modules/main/communities/io_interface.nim +++ b/src/app/modules/main/communities/io_interface.nim @@ -34,6 +34,11 @@ method createCommunity*(self: AccessInterface, name: string, description, introM historyArchiveSupportEnabled: bool, pinMessageAllMembersEnabled: bool, bannerJsonStr: string) {.base.} = raise newException(ValueError, "No implementation available") +method requestImportDiscordCommunity*(self: AccessInterface, name: string, description, introMessage, outroMessage: string, access: int, + color: string, tags: string, imagePath: string, aX: int, aY: int, bX: int, bY: int, + historyArchiveSupportEnabled: bool, pinMessageAllMembersEnabled: bool, filesToImport: seq[string], fromTimestamp: int) {.base.} = + raise newException(ValueError, "No implementation available") + method deleteCommunityCategory*(self: AccessInterface, communityId: string, categoryId: string) {.base.} = raise newException(ValueError, "No implementation available") @@ -117,3 +122,9 @@ method requestExtractDiscordChannelsAndCategories*(self: AccessInterface, filesT method discordCategoriesAndChannelsExtracted*(self: AccessInterface, categories: seq[DiscordCategoryDto], channels: seq[DiscordChannelDto], oldestMessageTimestamp: int, errors: Table[string, DiscordImportError], errorsCount: int) {.base.} = raise newException(ValueError, "No implementation available") + +method discordImportProgressUpdated*(self: AccessInterface, communityId: string, communityName: string, tasks: seq[DiscordImportTaskProgress], progress: float, errorsCount: int, warningsCount: int, stopped: bool) {.base.} = + raise newException(ValueError, "No implementation available") + +method requestCancelDiscordCommunityImport*(self: AccessInterface, id: string) {.base.} = + raise newException(ValueError, "No implementation available") diff --git a/src/app/modules/main/communities/models/discord_import_error_item.nim b/src/app/modules/main/communities/models/discord_import_error_item.nim new file mode 100644 index 0000000000..6143bc43da --- /dev/null +++ b/src/app/modules/main/communities/models/discord_import_error_item.nim @@ -0,0 +1,31 @@ +import strformat +type + DiscordImportErrorItem* = object + taskId*: string + code*: int + message*: string + +proc initDiscordImportErrorItem*( + taskId: string, + code: int, + message: string, +): DiscordImportErrorItem = + result.taskId = taskId + result.code = code + result.message = message + +proc `$`*(self: DiscordImportErrorItem): string = + result = fmt"""DiscordImportErrorItem( + taskId: {self.taskId}, + code: {self.code}, + message: {self.message} + ]""" + +proc getTaskId*(self: DiscordImportErrorItem): string = + return self.taskId + +proc getCode*(self: DiscordImportErrorItem): int = + return self.code + +proc getMessage*(self: DiscordImportErrorItem): string = + return self.message diff --git a/src/app/modules/main/communities/models/discord_import_errors_model.nim b/src/app/modules/main/communities/models/discord_import_errors_model.nim new file mode 100644 index 0000000000..9fe3fbbe11 --- /dev/null +++ b/src/app/modules/main/communities/models/discord_import_errors_model.nim @@ -0,0 +1,60 @@ +import NimQml, Tables +import discord_import_error_item + +type + ModelRole {.pure.} = enum + TaskId = UserRole + 1 + Code + Message + +QtObject: + type DiscordImportErrorsModel* = ref object of QAbstractListModel + items*: seq[DiscordImportErrorItem] + + proc setup(self: DiscordImportErrorsModel) = + self.QAbstractListModel.setup + + proc delete(self: DiscordImportErrorsModel) = + self.items = @[] + self.QAbstractListModel.delete + + proc newDiscordDiscordImportErrorsModel*(): DiscordImportErrorsModel = + new(result, delete) + result.setup + + method roleNames(self: DiscordImportErrorsModel): Table[int, string] = + { + ModelRole.TaskId.int:"taskId", + ModelRole.Code.int:"code", + ModelRole.Message.int:"message", + }.toTable + + method rowCount(self: DiscordImportErrorsModel, index: QModelIndex = nil): int = + return self.items.len + + method data(self: DiscordImportErrorsModel, index: QModelIndex, role: int): QVariant = + if not index.isValid: + return + if index.row < 0 or index.row >= self.items.len: + return + let item = self.items[index.row] + let enumRole = role.ModelRole + case enumRole: + of ModelRole.TaskId: + result = newQVariant(item.getTaskId()) + of ModelRole.Code: + result = newQVariant(item.getCode()) + of ModelRole.Message: + result = newQVariant(item.getMessage()) + + proc setItems*(self: DiscordImportErrorsModel, items: seq[DiscordImportErrorItem]) = + self.beginResetModel() + self.items = items + self.endResetModel() + + proc addItem*(self: DiscordImportErrorsModel, item: DiscordImportErrorItem) = + let parentModelIndex = newQModelIndex() + defer: parentModelIndex.delete + self.beginInsertRows(parentModelIndex, self.items.len, self.items.len) + self.items.add(item) + self.endInsertRows() diff --git a/src/app/modules/main/communities/models/discord_import_progress_item.nim b/src/app/modules/main/communities/models/discord_import_progress_item.nim new file mode 100644 index 0000000000..ce78726b9c --- /dev/null +++ b/src/app/modules/main/communities/models/discord_import_progress_item.nim @@ -0,0 +1,45 @@ +import strformat +type + DiscordImportProgressItem* = object + communityId*: string + progress*: float + errorsCount*: int + warningsCount*: int + stopped*: bool + +proc initDiscordImportProgressItem*( + communityId: string, + progress: float, + errorsCount: int, + warningsCount: int, + stopped: bool, +): DiscordImportProgressItem = + result.communityId = communityId + result.progress = progress + result.errorsCount = errorsCount + result.warningsCount = warningsCount + result.stopped = stopped + +proc `$`*(self: DiscordImportProgressItem): string = + result = fmt"""DiscordImportProgressItem( + communityId: {self.communityId}, + progress: {self.progress}, + errorsCount: {self.errorsCount}, + warningsCount: {self.warningsCount}, + stopped: {self.stopped} + ]""" + +proc getCommunitId*(self: DiscordImportProgressItem): string = + return self.communityId + +proc getProgress*(self: DiscordImportProgressItem): float = + return self.progress + +proc getErrorsCount*(self: DiscordImportProgressItem): int = + return self.errorsCount + +proc getWarningsCount*(self: DiscordImportProgressItem): int = + return self.warningsCount + +proc getStopped*(self: DiscordImportProgressItem): bool = + return self.stopped diff --git a/src/app/modules/main/communities/models/discord_import_task_item.nim b/src/app/modules/main/communities/models/discord_import_task_item.nim new file mode 100644 index 0000000000..69d869297c --- /dev/null +++ b/src/app/modules/main/communities/models/discord_import_task_item.nim @@ -0,0 +1,68 @@ +import strformat +import discord_import_errors_model, discord_import_error_item +import ../../../../../app_service/service/community/dto/community + + +const MAX_VISIBLE_ERROR_ITEMS* = 3 + +type + DiscordImportTaskItem* = object + `type`*: string + progress*: float + state*: string + errors*: DiscordImportErrorsModel + stopped*: bool + errorsCount*: int + warningsCount*: int + +proc `$`*(self: DiscordImportTaskItem): string = + result = fmt"""DiscordImportTaskItem( + type: {self.type}, + state: {self.state}, + progress: {self.progress}, + stopped: {self.stopped}, + ]""" + +proc initDiscordImportTaskItem*( + `type`: string, + progress: float, + state: string, + errors: seq[DiscordImportError], + stopped: bool, + errorsCount: int, + warningsCount: int +): DiscordImportTaskItem = + result.type = type + result.progress = progress + result.state = state + result.errors = newDiscordDiscordImportErrorsModel() + result.stopped = stopped + result.errorsCount = errorsCount + result.warningsCount = warningsCount + + # We only show the first 3 errors per task, then we add another + # "#n more issues" item in the UI + for i, error in errors: + if i < MAX_VISIBLE_ERROR_ITEMS: + result.errors.addItem(initDiscordImportErrorItem(`type`, error.code, error.message)) + +proc getType*(self: DiscordImportTaskItem): string = + return self.type + +proc getProgress*(self: DiscordImportTaskItem): float = + return self.progress + +proc getState*(self: DiscordImportTaskItem): string = + return self.state + +proc getErrors*(self: DiscordImportTaskItem): DiscordImportErrorsModel = + return self.errors + +proc getStopped*(self: DiscordImportTaskItem): bool = + return self.stopped + +proc getErrorsCount*(self: DiscordImportTaskItem): int = + return self.errorsCount + +proc getWarningsCount*(self: DiscordImportTaskItem): int = + return self.warningsCount diff --git a/src/app/modules/main/communities/models/discord_import_tasks_model.nim b/src/app/modules/main/communities/models/discord_import_tasks_model.nim new file mode 100644 index 0000000000..15ec603c7c --- /dev/null +++ b/src/app/modules/main/communities/models/discord_import_tasks_model.nim @@ -0,0 +1,125 @@ +import NimQml, Tables +import discord_import_error_item, discord_import_errors_model +import discord_import_task_item as taskItem +import ../../../../../app_service/service/community/dto/community + +type + ModelRole {.pure.} = enum + Type = UserRole + 1 + Progress + State + Errors + Stopped + ErrorsCount + WarningsCount + +QtObject: + type DiscordImportTasksModel* = ref object of QAbstractListModel + items*: seq[DiscordImportTaskItem] + + proc setup(self: DiscordImportTasksModel) = + self.QAbstractListModel.setup + + proc delete(self: DiscordImportTasksModel) = + self.items = @[] + self.QAbstractListModel.delete + + proc newDiscordDiscordImportTasksModel*(): DiscordImportTasksmodel = + new(result, delete) + result.setup + + method roleNames(self: DiscordImportTasksModel): Table[int, string] = + { + ModelRole.Type.int:"type", + ModelRole.Progress.int:"progress", + ModelRole.State.int:"state", + ModelRole.Errors.int:"errors", + ModelRole.Stopped.int:"stopped", + ModelRole.ErrorsCount.int:"errorsCount", + ModelRole.WarningsCount.int:"warningsCount", + }.toTable + + method data(self: DiscordImportTasksModel, index: QModelIndex, role: int): QVariant = + if not index.isValid: + return + if index.row < 0 or index.row >= self.items.len: + return + let item = self.items[index.row] + let enumRole = role.ModelRole + case enumRole: + of ModelRole.Type: + result = newQVariant(item.getType()) + of ModelRole.Progress: + result = newQVariant(item.getProgress()) + of ModelRole.State: + result = newQVariant(item.getState()) + of ModelRole.Errors: + result = newQVariant(item.getErrors()) + of ModelRole.Stopped: + result = newQVariant(item.getStopped()) + of ModelRole.ErrorsCount: + result = newQVariant(item.getErrorsCount()) + of ModelRole.WarningsCount: + result = newQVariant(item.getWarningsCount()) + + method rowCount(self: DiscordImportTasksModel, index: QModelIndex = nil): int = + return self.items.len + + proc setItems*(self: DiscordImportTasksModel, items: seq[DiscordImportTaskItem]) = + self.beginResetModel() + self.items = items + self.endResetModel() + + proc hasItemByType*(self: DiscordImportTasksModel, `type`: string): bool = + for i, item in self.items: + if self.items[i].`type` == `type`: + return true + return false + + proc addItem*(self: DiscordImportTasksModel, item: DiscordImportTaskItem) = + let parentModelIndex = newQModelIndex() + defer: parentModelIndex.delete + self.beginInsertRows(parentModelIndex, self.items.len, self.items.len) + self.items.add(item) + self.endInsertRows() + + proc findIndexByType(self: DiscordImportTasksModel, `type`: string): int = + for i in 0 ..< self.items.len: + if(self.items[i].`type` == `type`): + return i + return -1 + + proc updateItem*(self: DiscordImportTasksModel, item: DiscordImportTaskProgress) = + let idx = self.findIndexByType(item.`type`) + if idx > -1: + let index = self.createIndex(idx, 0, nil) + let errorsAndWarningsCount = self.items[idx].warningsCount + self.items[idx].errorsCount + self.items[idx].progress = item.progress + self.items[idx].state = item.state + self.items[idx].stopped = item.stopped + self.items[idx].errorsCount = item.errorsCount + self.items[idx].warningsCount = item.warningsCount + + let errorItemsCount = self.items[idx].errors.items.len + + # We only show the first 3 errors per task, then we add another + # "#n more issues" item in the UI + for i, error in item.errors: + if errorItemsCount + i < taskItem.MAX_VISIBLE_ERROR_ITEMS: + let errorItem = initDiscordImportErrorItem(item.`type`, error.code, error.message) + self.items[idx].errors.addItem(errorItem) + + self.dataChanged(index, index, @[ + ModelRole.Progress.int, + ModelRole.State.int, + ModelRole.Errors.int, + ModelRole.Stopped.int, + ModelRole.ErrorsCount.int, + ModelRole.WarningsCount.int + ]) + + + proc clearItems*(self: DiscordImportTasksModel) = + self.beginResetModel() + self.items = @[] + self.endResetModel() diff --git a/src/app/modules/main/communities/module.nim b/src/app/modules/main/communities/module.nim index 15a9214466..4df453e8a9 100644 --- a/src/app/modules/main/communities/module.nim +++ b/src/app/modules/main/communities/module.nim @@ -10,6 +10,10 @@ import ./models/discord_categories_model import ./models/discord_channel_item import ./models/discord_channels_model import ./models/discord_file_list_model +import ./models/discord_import_task_item +import ./models/discord_import_tasks_model +import ./models/discord_import_error_item +import ./models/discord_import_errors_model import ../../shared_models/section_item import ../../shared_models/[member_item, member_model, section_model] import ../../../global/global_singleton @@ -288,3 +292,44 @@ method onImportCommunityErrorOccured*(self: Module, error: string) = method requestExtractDiscordChannelsAndCategories*(self: Module, filesToImport: seq[string]) = self.view.setDiscordDataExtractionInProgress(true) self.controller.requestExtractDiscordChannelsAndCategories(filesToImport) + +method requestImportDiscordCommunity*(self: Module, name: string, description, introMessage, outroMessage: string, access: int, + color: string, tags: string, imagePath: string, aX: int, aY: int, bX: int, bY: int, + historyArchiveSupportEnabled: bool, pinMessageAllMembersEnabled: bool, filesToImport: seq[string], fromTimestamp: int) = + self.controller.requestImportDiscordCommunity(name, description, introMessage, outroMessage, access, color, tags, imagePath, aX, aY, bX, bY, historyArchiveSupportEnabled, pinMessageAllMembersEnabled, filesToImport, fromTimestamp) + +method getDiscordImportTaskItem(self: Module, t: DiscordImportTaskProgress): DiscordImportTaskItem = + return initDiscordImportTaskItem( + t.`type`, + t.progress, + t.state, + t.errors, + t.stopped, + t.errorsCount, + t.warningsCount) + +method discordImportProgressUpdated*(self: Module, communityId: string, communityName: string, tasks: seq[DiscordImportTaskProgress], progress: float, errorsCount: int, warningsCount: int, stopped: bool) = + + var taskItems: seq[DiscordImportTaskItem] = @[] + + for task in tasks: + if not self.view.discordImportTasksModel().hasItemByType(task.`type`): + self.view.discordImportTasksModel().addItem(self.getDiscordImportTaskItem(task)) + else: + self.view.discordImportTasksModel().updateItem(task) + + self.view.setDiscordImportCommunityId(communityId) + self.view.setDiscordImportCommunityName(communityName) + self.view.setDiscordImportErrorsCount(errorsCount) + self.view.setDiscordImportWarningsCount(warningsCount) + # For some reason, exposing the global `progress` as QtProperty[float]` + # doesn't translate well into QML. + # That's why we pass it as integer instead. + self.view.setDiscordImportProgress((progress*100).int) + self.view.setDiscordImportProgressStopped(stopped) + if stopped or progress.int >= 1: + self.view.setDiscordImportInProgress(false) + +method requestCancelDiscordCommunityImport*(self: Module, id: string) = + self.controller.requestCancelDiscordCommunityImport(id) + diff --git a/src/app/modules/main/communities/view.nim b/src/app/modules/main/communities/view.nim index c610b2ebde..df37af36ea 100644 --- a/src/app/modules/main/communities/view.nim +++ b/src/app/modules/main/communities/view.nim @@ -12,6 +12,8 @@ import ./models/discord_categories_model import ./models/discord_category_item import ./models/discord_channels_model import ./models/discord_channel_item +import ./models/discord_import_tasks_model +import ./models/discord_import_errors_model QtObject: type @@ -32,7 +34,15 @@ QtObject: discordOldestMessageTimestamp: int discordImportErrorsCount: int discordImportWarningsCount: int + discordImportProgress: int + discordImportInProgress: bool + discordImportCancelled: bool + discordImportProgressStopped: bool + discordImportTasksModel: DiscordImportTasksModel + discordImportTasksModelVariant: QVariant discordDataExtractionInProgress: bool + discordImportCommunityId: string + discordImportCommunityName: string proc delete*(self: View) = self.model.delete @@ -46,6 +56,8 @@ QtObject: self.discordCategoriesModelVariant.delete self.discordChannelsModel.delete self.discordChannelsModelVariant.delete + self.discordImportTasksModel.delete + self.discordImportTasksModelVariant.delete self.QObject.delete proc newView*(delegate: io_interface.AccessInterface): View = @@ -67,6 +79,12 @@ QtObject: result.discordDataExtractionInProgress = false result.discordImportWarningsCount = 0 result.discordImportErrorsCount = 0 + result.discordImportProgress = 0 + result.discordImportInProgress = false + result.discordImportCancelled = false + result.discordImportProgressStopped = false + result.discordImportTasksModel = newDiscordDiscordImportTasksModel() + result.discordImportTasksModelVariant = newQVariant(result.discordImportTasksModel) result.observedItem = newActiveSection() proc load*(self: View) = @@ -119,6 +137,62 @@ QtObject: read = getDiscordImportErrorsCount notify = discordImportErrorsCountChanged + proc discordImportProgressChanged*(self: View) {.signal.} + + proc setDiscordImportProgress*(self: View, value: int) {.slot.} = + if (self.discordImportProgress == value): return + self.discordImportProgress = value + self.discordImportProgressChanged() + + proc getDiscordImportProgress*(self: View): int {.slot.} = + return self.discordImportProgress + + QtProperty[int] discordImportProgress: + read = getDiscordImportProgress + notify = discordImportProgressChanged + + proc discordImportInProgressChanged*(self: View) {.signal.} + + proc setDiscordImportInProgress*(self: View, value: bool) {.slot.} = + if (self.discordImportInProgress == value): return + self.discordImportInProgress = value + self.discordImportInProgressChanged() + + proc getDiscordImportInProgress*(self: View): bool {.slot.} = + return self.discordImportInProgress + + QtProperty[bool] discordImportInProgress: + read = getDiscordImportInProgress + notify = discordImportInProgressChanged + + proc discordImportCancelledChanged*(self: View) {.signal.} + + proc setDiscordImportCancelled*(self: View, value: bool) {.slot.} = + if (self.discordImportCancelled == value): return + self.discordImportCancelled = value + self.discordImportCancelledChanged() + + proc getDiscordImportCancelled*(self: View): bool {.slot.} = + return self.discordImportCancelled + + QtProperty[bool] discordImportCancelled: + read = getDiscordImportCancelled + notify = discordImportCancelledChanged + + proc discordImportProgressStoppedChanged*(self: View) {.signal.} + + proc setDiscordImportProgressStopped*(self: View, stopped: bool) {.slot.} = + if (self.discordImportProgressStopped == stopped): return + self.discordImportProgressStopped = stopped + self.discordImportProgressStoppedChanged() + + proc getDiscordImportProgressStopped*(self: View): bool {.slot.} = + return self.discordImportProgressStopped + + QtProperty[int] discordImportProgressStopped: + read = getDiscordImportProgressStopped + notify = discordImportProgressStoppedChanged + proc addItem*(self: View, item: SectionItem) = self.model.addItem(item) self.communityAdded(item.id) @@ -174,6 +248,15 @@ QtObject: QtProperty[QVariant] discordChannels: read = getDiscordChannelsModel + proc discordImportTasksModel*(self: View): DiscordImportTasksModel = + result = self.discordImportTasksModel + + proc getDiscordImportTasksModel(self: View): QVariant {.slot.} = + return self.discordImportTasksModelVariant + + QtProperty[QVariant] discordImportTasks: + read = getDiscordImportTasksModel + proc observedItemChanged*(self:View) {.signal.} proc getObservedItem(self: View): QVariant {.slot.} = @@ -204,6 +287,34 @@ QtObject: read = getDiscordDataExtractionInProgress notify = discordDataExtractionInProgressChanged + proc discordImportCommunityIdChanged*(self: View) {.signal.} + + proc getDiscordImportCommunityId(self: View): string {.slot.} = + return self.discordImportCommunityId + + proc setDiscordImportCommunityId*(self: View, id: string) {.slot.} = + if (self.discordImportCommunityId == id): return + self.discordImportCommunityId = id + self.discordImportCommunityIdChanged() + + QtProperty[string] discordImportCommunityId: + read = getDiscordImportCommunityId + notify = discordImportCommunityIdChanged + + proc discordImportCommunityNameChanged*(self: View) {.signal.} + + proc getDiscordImportCommunityName(self: View): string {.slot.} = + return self.discordImportCommunityName + + proc setDiscordImportCommunityName*(self: View, name: string) {.slot.} = + if (self.discordImportCommunityName == name): return + self.discordImportCommunityName = name + self.discordImportCommunityNameChanged() + + QtProperty[string] discordImportCommunityName: + read = getDiscordImportCommunityName + notify = discordImportCommunityNameChanged + proc joinCommunity*(self: View, communityId: string, ensName: string) {.slot.} = # Users always have to request to join a community but might # get automatically accepted. @@ -219,6 +330,48 @@ QtObject: self.delegate.createCommunity(name, description, introMessage, outroMessage, access, color, tags, imagePath, aX, aY, bX, bY, historyArchiveSupportEnabled, pinMessageAllMembersEnabled, bannerJsonStr) + proc clearFileList*(self: View) {.slot.} = + self.discordFileListModel.clearItems() + self.setDiscordImportErrorsCount(0) + self.setDiscordImportWarningsCount(0) + + proc clearDiscordCategoriesAndChannels*(self: View) {.slot.} = + self.discordCategoriesModel.clearItems() + self.discordChannelsModel.clearItems() + + proc resetDiscordImport*(self: View, cancelled: bool) {.slot.} = + self.clearFileList() + self.clearDiscordCategoriesAndChannels() + self.discordImportTasksModel.clearItems() + self.setDiscordImportProgress(0) + self.setDiscordImportProgressStopped(false) + self.setDiscordImportErrorsCount(0) + self.setDiscordImportWarningsCount(0) + self.setDiscordImportCommunityId("") + self.setDiscordImportCommunityName("") + self.setDiscordImportInProgress(false) + self.setDiscordImportCancelled(cancelled) + + + proc requestImportDiscordCommunity*(self: View, name: string, + description: string, introMessage: string, outroMessage: string, + access: int, color: string, tags: string, + imagePath: string, + aX: int, aY: int, bX: int, bY: int, + historyArchiveSupportEnabled: bool, + pinMessageAllMembersEnabled: bool, + fromTimestamp: int) {.slot.} = + let selectedItems = self.discordChannelsModel.getSelectedItems() + var filesToImport: seq[string] = @[] + + for i in 0 ..< selectedItems.len: + filesToImport.add(selectedItems[i].getFilePath()) + + self.resetDiscordImport(false) + self.setDiscordImportInProgress(true) + self.delegate.requestImportDiscordCommunity(name, description, introMessage, outroMessage, access, color, tags, + imagePath, aX, aY, bX, bY, historyArchiveSupportEnabled, pinMessageAllMembersEnabled, filesToImport, fromTimestamp) + proc deleteCommunityCategory*(self: View, communityId: string, categoryId: string): string {.slot.} = self.delegate.deleteCommunityCategory(communityId, categoryId) @@ -288,15 +441,14 @@ QtObject: self.setDiscordImportErrorsCount(0) self.setDiscordImportWarningsCount(0) - proc clearFileList*(self: View) {.slot.} = - self.discordFileListModel.clearItems() - self.setDiscordImportErrorsCount(0) - self.setDiscordImportWarningsCount(0) - proc requestExtractDiscordChannelsAndCategories*(self: View) {.slot.} = let filePaths = self.discordFileListModel.getSelectedFilePaths() self.delegate.requestExtractDiscordChannelsAndCategories(filePaths) + proc requestCancelDiscordCommunityImport*(self: View, id: string) {.slot.} = + self.delegate.requestCancelDiscordCommunityImport(id) + self.resetDiscordImport(true) + proc toggleDiscordCategory*(self: View, id: string, selected: bool) {.slot.} = if selected: self.discordCategoriesModel.selectItem(id) @@ -316,6 +468,3 @@ QtObject: if self.discordChannelsModel.allChannelsByCategoryUnselected(item.getCategoryId()): self.discordCategoriesModel.unselectItem(item.getCategoryId()) - proc clearDiscordCategoriesAndChannels*(self: View) {.slot.} = - self.discordCategoriesModel.clearItems() - self.discordChannelsModel.clearItems() diff --git a/src/app/modules/shared_models/discord_message_item.nim b/src/app/modules/shared_models/discord_message_item.nim new file mode 100644 index 0000000000..d9524718ab --- /dev/null +++ b/src/app/modules/shared_models/discord_message_item.nim @@ -0,0 +1,68 @@ +import Nimqml, json, strformat + +import ../../../app_service/service/message/dto/message + +QtObject: + type + DiscordMessageItem* = ref object of QObject + id: string + timestamp: string + timestampEdited: string + content: string + author: DiscordMessageAuthor + + proc setup(self: DiscordMessageItem) = + self.QObject.setup + + proc delete*(self: DiscordMessageItem) = + self.QObject.delete + + proc newDiscordMessageItem*( + id: string, + timestamp: string, + timestampEdited: string, + content: string, + author: DiscordMessageAuthor + ): DiscordMessageItem = + new(result, delete) + result.setup + result.id = id + result.timestamp = timestamp + result.timestampEdited = timestampEdited + result.content = content + result.author = author + + proc `$`*(self: DiscordMessageItem): string = + result = fmt"""DiscordMessageItem( + id: {$self.id}, + timestamp: {$self.timestamp}, + timestampEdited: {$self.timestampEdited}, + content: {$self.content}, + )""" + + proc id*(self: DiscordMessageItem): string {.inline.} = + self.id + + QtProperty[string] id: + read = id + + proc timestamp*(self: DiscordMessageItem): string {.inline.} = + self.timestamp + + QtProperty[string] timestamp: + read = timestamp + + proc timestampEdited*(self: DiscordMessageItem): string {.inline.} = + self.timestampEdited + + QtProperty[string] timestampEdited: + read = timestampEdited + + proc content*(self: DiscordMessageItem): string {.inline.} = + self.content + + QtProperty[string] content: + read = content + + proc author*(self: DiscordMessageItem): DiscordMessageAuthor {.inline.} = + self.author diff --git a/src/app/modules/shared_models/message_item.nim b/src/app/modules/shared_models/message_item.nim index 56d2802a8f..0a614e521d 100644 --- a/src/app/modules/shared_models/message_item.nim +++ b/src/app/modules/shared_models/message_item.nim @@ -1,6 +1,7 @@ -import json, strformat +import json, strformat, strutils import ../../../app_service/common/types import ../../../app_service/service/contacts/dto/contacts +import ../../../app_service/service/message/dto/message export types.ContentType import message_reaction_model, message_reaction_item, message_transaction_parameters_item @@ -64,6 +65,7 @@ type mentionedUsersPks: seq[string] senderTrustStatus: TrustStatus senderEnsVerified: bool + messageAttachments: seq[string] proc initItem*( id, @@ -90,7 +92,8 @@ proc initItem*( transactionParameters: TransactionParametersItem, mentionedUsersPks: seq[string], senderTrustStatus: TrustStatus, - senderEnsVerified: bool + senderEnsVerified: bool, + discordMessage: DiscordMessage ): Item = result = Item() result.id = id @@ -124,6 +127,23 @@ proc initItem*( result.gapTo = 0 result.senderTrustStatus = senderTrustStatus result.senderEnsVerified = senderEnsVerified + result.messageAttachments = @[] + + if ContentType.DiscordMessage == contentType: + result.messageText = discordMessage.content + result.senderDisplayName = discordMessage.author.name + result.senderIcon = discordMessage.author.localUrl + result.timestamp = parseInt(discordMessage.timestamp)*1000 + + if result.senderIcon == "": + result.senderIcon = discordMessage.author.avatarUrl + + if discordMessage.timestampEdited != "": + result.timestamp = parseInt(discordMessage.timestampEdited)*1000 + + for attachment in discordMessage.attachments: + if attachment.contentType.contains("image"): + result.messageAttachments.add(attachment.localUrl) proc `$`*(self: Item): string = result = fmt"""Item( @@ -276,6 +296,9 @@ proc addReaction*(self: Item, emojiId: EmojiId, didIReactWithThisEmoji: bool, us proc removeReaction*(self: Item, emojiId: EmojiId, reactionId: string, didIRemoveThisReaction: bool) = self.reactionsModel.removeReaction(emojiId, reactionId, didIRemoveThisReaction) +proc messageAttachments*(self: Item): seq[string] {.inline.} = + self.messageAttachments + proc links*(self: Item): seq[string] {.inline.} = self.links diff --git a/src/app/modules/shared_models/message_model.nim b/src/app/modules/shared_models/message_model.nim index d71bca4f1b..1132bed775 100644 --- a/src/app/modules/shared_models/message_model.nim +++ b/src/app/modules/shared_models/message_model.nim @@ -41,6 +41,7 @@ type MentionedUsersPks SenderTrustStatus SenderEnsVerified + MessageAttachments QtObject: type @@ -116,7 +117,8 @@ QtObject: ModelRole.TransactionParameters.int: "transactionParameters", ModelRole.MentionedUsersPks.int: "mentionedUsersPks", ModelRole.SenderTrustStatus.int: "senderTrustStatus", - ModelRole.SenderEnsVerified.int: "senderEnsVerified" + ModelRole.SenderEnsVerified.int: "senderEnsVerified", + ModelRole.MessageAttachments.int: "messageAttachments" }.toTable method data(self: Model, index: QModelIndex, role: int): QVariant = @@ -211,6 +213,8 @@ QtObject: result = newQVariant(item.mentionedUsersPks.join(" ")) of ModelRole.SenderEnsVerified: result = newQVariant(item.senderEnsVerified) + of ModelRole.MessageAttachments: + result = newQVariant(item.messageAttachments.join(" ")) proc updateItemAtIndex(self: Model, index: int) = let ind = self.createIndex(index, 0, nil) diff --git a/src/app_service/common/types.nim b/src/app_service/common/types.nim index a5c49ad1ba..92aaff7eab 100644 --- a/src/app_service/common/types.nim +++ b/src/app_service/common/types.nim @@ -14,6 +14,7 @@ type Community = 9 Gap = 10 Edit = 11 + DiscordMessage = 12 type StatusType* {.pure.} = enum diff --git a/src/app_service/service/community/dto/community.nim b/src/app_service/service/community/dto/community.nim index 51ec2e2076..e71df99fad 100644 --- a/src/app_service/service/community/dto/community.nim +++ b/src/app_service/service/community/dto/community.nim @@ -96,6 +96,15 @@ type DiscordImportError* = object code*: int message*: string +type DiscordImportTaskProgress* = object + `type`*: string + progress*: float + errors*: seq[DiscordImportError] + errorsCount*: int + warningsCount*: int + stopped*: bool + state*: string + proc toCommunityAdminSettingsDto*(jsonObj: JsonNode): CommunityAdminSettingsDto = result = CommunityAdminSettingsDto() discard jsonObj.getProp("pinMessageAllMembersEnabled", result.pinMessageAllMembersEnabled) @@ -118,6 +127,21 @@ proc toDiscordImportError*(jsonObj: JsonNode): DiscordImportError = discard jsonObj.getProp("code", result.code) discard jsonObj.getProp("message", result.message) +proc toDiscordImportTaskProgress*(jsonObj: JsonNode): DiscordImportTaskProgress = + result = DiscordImportTaskProgress() + result.`type` = jsonObj{"type"}.getStr() + result.progress = jsonObj{"progress"}.getFloat() + result.stopped = jsonObj{"stopped"}.getBool() + result.errorsCount = jsonObj{"errorsCount"}.getInt() + result.warningsCount = jsonObj{"warningsCount"}.getInt() + result.state = jsonObj{"state"}.getStr() + + var importErrorsObj: JsonNode + if(jsonObj.getProp("errors", importErrorsObj) and importErrorsObj.kind == JArray): + for error in importErrorsObj: + let importError = error.toDiscordImportError() + result.errors.add(importError) + proc toCommunityDto*(jsonObj: JsonNode): CommunityDto = result = CommunityDto() discard jsonObj.getProp("id", result.id) diff --git a/src/app_service/service/community/service.nim b/src/app_service/service/community/service.nim index f3ba5e80b4..1f98b09401 100644 --- a/src/app_service/service/community/service.nim +++ b/src/app_service/service/community/service.nim @@ -80,6 +80,15 @@ type errors*: Table[string, DiscordImportError] errorsCount*: int + DiscordImportProgressArgs* = ref object of Args + communityId*: string + communityName*: string + tasks*: seq[DiscordImportTaskProgress] + progress*: float + errorsCount*: int + warningsCount*: int + stopped*: bool + # Signals which may be emitted by this service: const SIGNAL_COMMUNITY_JOINED* = "communityJoined" const SIGNAL_COMMUNITY_MY_REQUEST_ADDED* = "communityMyRequestAdded" @@ -107,6 +116,8 @@ const SIGNAL_COMMUNITY_MUTED* = "communityMuted" const SIGNAL_CATEGORY_MUTED* = "categoryMuted" const SIGNAL_CATEGORY_UNMUTED* = "categoryUnmuted" const SIGNAL_DISCORD_CATEGORIES_AND_CHANNELS_EXTRACTED* = "discordCategoriesAndChannelsExtracted" +const SIGNAL_DISCORD_COMMUNITY_IMPORT_FINISHED* = "discordCommunityImportFinished" +const SIGNAL_DISCORD_COMMUNITY_IMPORT_PROGRESS* = "discordCommunityImportProgress" QtObject: type @@ -198,6 +209,22 @@ QtObject: errorsCount: receivedData.errorsCount )) + self.events.on(SignalType.DiscordCommunityImportFinished.event) do(e: Args): + var receivedData = DiscordCommunityImportFinishedSignal(e) + self.events.emit(SIGNAL_DISCORD_COMMUNITY_IMPORT_FINISHED, CommunityIdArgs(communityId: receivedData.communityId)) + + self.events.on(SignalType.DiscordCommunityImportProgress.event) do(e: Args): + var receivedData = DiscordCommunityImportProgressSignal(e) + self.events.emit(SIGNAL_DISCORD_COMMUNITY_IMPORT_PROGRESS, DiscordImportProgressArgs( + communityId: receivedData.communityId, + communityName: receivedData.communityName, + tasks: receivedData.tasks, + progress: receivedData.progress, + errorsCount: receivedData.errorsCount, + warningsCount: receivedData.warningsCount, + stopped: receivedData.stopped + )) + proc updateMissingFields(chatDto: var ChatDto, chat: ChatDto) = # This proc sets fields of `chatDto` which are available only for community channels. chatDto.position = chat.position @@ -648,6 +675,49 @@ QtObject: except Exception as e: error "Error leaving community", msg = e.msg, communityId + proc requestImportDiscordCommunity*( + self: Service, + name: string, + description: string, + introMessage: string, + outroMessage: string, + access: int, + color: string, + tags: string, + imageUrl: string, + aX: int, aY: int, bX: int, bY: int, + historyArchiveSupportEnabled: bool, + pinMessageAllMembersEnabled: bool, + filesToImport: seq[string], + fromTimestamp: int) = + try: + var image = singletonInstance.utils.formatImagePath(imageUrl) + var tagsString = tags + if len(tagsString) == 0: + tagsString = "[]" + + let response = status_go.requestImportDiscordCommunity( + name, + description, + introMessage, + outroMessage, + access, + color, + tagsString, + image, + aX, aY, bX, bY, + historyArchiveSupportEnabled, + pinMessageAllMembersEnabled, + filesToImport, + fromTimestamp) + + if response.error != nil: + let error = Json.decode($response.error, RpcError) + raise newException(RpcException, "Error creating community: " & error.message) + + except Exception as e: + error "Error creating community", msg = e.msg + proc createCommunity*( self: Service, name: string, @@ -1226,3 +1296,9 @@ QtObject: except Exception as e: error "Error extracting discord channels and categories", msg = e.msg + proc requestCancelDiscordCommunityImport*(self: Service, communityId: string) = + try: + discard status_go.requestCancelDiscordCommunityImport(communityId) + except Exception as e: + error "Error extracting discord channels and categories", msg = e.msg + diff --git a/src/app_service/service/message/dto/message.nim b/src/app_service/service/message/dto/message.nim index 80f73c9248..9098d9f926 100644 --- a/src/app_service/service/message/dto/message.nim +++ b/src/app_service/service/message/dto/message.nim @@ -32,6 +32,29 @@ type QuotedMessage* = object text*: string parsedText*: seq[ParsedText] +type DiscordMessageAttachment* = object + id*: string + fileUrl*: string + fileName*: string + localUrl*: string + contentType*: string + +type DiscordMessageAuthor* = object + id*: string + name*: string + nickname*: string + avatarUrl*: string + localUrl*: string + +type DiscordMessage* = object + id*: string + `type`*: string + timestamp*: string + timestampEdited*: string + content*: string + author*: DiscordMessageAuthor + attachments*: seq[DiscordMessageAttachment] + type Sticker* = object hash*: string url*: string @@ -60,6 +83,7 @@ type MessageDto* = object seen*: bool outgoingStatus*: string quotedMessage*: QuotedMessage + discordMessage*: DiscordMessage rtl*: bool parsedText*: seq[ParsedText] lineCount*: int @@ -91,6 +115,41 @@ proc toParsedText*(jsonObj: JsonNode): ParsedText = for childObj in childrenArr: result.children.add(toParsedText(childObj)) +proc toDiscordMessageAuthor*(jsonObj: JsonNode): DiscordMessageAuthor = + result = DiscordMessageAuthor() + discard jsonObj.getProp("id", result.id) + discard jsonObj.getProp("name", result.name) + discard jsonObj.getProp("nickname", result.nickname) + discard jsonObj.getProp("avatarUrl", result.avatarUrl) + discard jsonObj.getProp("localUrl", result.localUrl) + + +proc toDiscordMessageAttachment*(jsonObj: JsonNOde): DiscordMessageAttachment = + result = DiscordMessageAttachment() + discard jsonObj.getProp("id", result.id) + discard jsonObj.getProp("url", result.fileUrl) + discard jsonObj.getProp("localUrl", result.localUrl) + discard jsonObj.getProp("fileName", result.fileName) + discard jsonObj.getProp("contentType", result.contentType) + +proc toDiscordMessage*(jsonObj: JsonNode): DiscordMessage = + result = DiscordMessage() + discard jsonObj.getProp("id", result.id) + discard jsonObj.getProp("type", result.type) + discard jsonObj.getProp("timestamp", result.timestamp) + discard jsonObj.getProp("timestampEdited", result.timestampEdited) + discard jsonObj.getProp("content", result.content) + + var discordMessageAuthorObj: JsonNode + if(jsonObj.getProp("author", discordMessageAuthorObj)): + result.author = toDiscordMessageAuthor(discordMessageAuthorObj) + + result.attachments = @[] + var attachmentsArr: JsonNode + if(jsonObj.getProp("attachments", attachmentsArr) and attachmentsArr.kind == JArray): + for attachment in attachmentsArr: + result.attachments.add(toDiscordMessageAttachment(attachment)) + proc toQuotedMessage*(jsonObj: JsonNode): QuotedMessage = result = QuotedMessage() discard jsonObj.getProp("from", result.from) @@ -151,6 +210,10 @@ proc toMessageDto*(jsonObj: JsonNode): MessageDto = if(jsonObj.getProp("quotedMessage", quotedMessageObj)): result.quotedMessage = toQuotedMessage(quotedMessageObj) + var discordMessageObj: JsonNode + if(jsonObj.getProp("discordMessage", discordMessageObj)): + result.discordMessage = toDiscordMessage(discordMessageObj) + var stickerObj: JsonNode if(jsonObj.getProp("sticker", stickerObj)): result.sticker = toSticker(stickerObj) diff --git a/src/app_service/service/message/service.nim b/src/app_service/service/message/service.nim index f6e074dd91..ca28321e78 100644 --- a/src/app_service/service/message/service.nim +++ b/src/app_service/service/message/service.nim @@ -296,11 +296,13 @@ QtObject: if (receivedData.emojiReactions.len > 0): self.handleEmojiReactionsUpdate(receivedData.emojiReactions) - self.events.on(SignalType.HistoryArchiveDownloaded.event) do(e: Args): + self.events.on(SignalType.DownloadingHistoryArchivesFinished.event) do(e: Args): var receivedData = HistoryArchivesSignal(e) - if now().toTime().toUnix()-receivedData.begin <= WEEK_AS_MILLISECONDS: - # we don't need to reload the messages for archives older than 7 days - self.handleMessagesReload(receivedData.communityId) + self.handleMessagesReload(receivedData.communityId) + + self.events.on(SignalType.DiscordCommunityImportFinished.event) do(e: Args): + var receivedData = DiscordCommunityImportFinishedSignal(e) + self.handleMessagesReload(receivedData.communityId) proc initialMessagesFetched(self: Service, chatId: string): bool = return self.msgCursor.hasKey(chatId) diff --git a/src/backend/communities.nim b/src/backend/communities.nim index 81e8db9488..fa46a646a9 100644 --- a/src/backend/communities.nim +++ b/src/backend/communities.nim @@ -123,6 +123,45 @@ proc editCommunity*( "pinMessageAllMembersEnabled": pinMessageAllMembersEnabled }]) +proc requestImportDiscordCommunity*( + name: string, + description: string, + introMessage: string, + outroMessage: string, + access: int, + color: string, + tags: string, + imageUrl: string, + aX: int, aY: int, bX: int, bY: int, + historyArchiveSupportEnabled: bool, + pinMessageAllMembersEnabled: bool, + filesToImport: seq[string], + fromTimestamp: int + ): RpcResponse[JsonNode] {.raises: [Exception].} = + result = callPrivateRPC("requestImportDiscordCommunity".prefix, %*[{ + # TODO this will need to be renamed membership (small m) + "Membership": access, + "name": name, + "description": description, + "introMessage": introMessage, + "outroMessage": outroMessage, + "ensOnly": false, # TODO ensOnly is no longer supported. Remove this when we remove it in status-go + "color": color, + "tags": parseJson(tags), + "image": imageUrl, + "imageAx": aX, + "imageAy": aY, + "imageBx": bX, + "imageBy": bY, + "historyArchiveSupportEnabled": historyArchiveSupportEnabled, + "pinMessageAllMembersEnabled": pinMessageAllMembersEnabled, + "from": fromTimestamp, + "filesToImport": filesToImport + }]) + +proc requestCancelDiscordCommunityImport*(communityId: string): RpcResponse[JsonNode] {.raises: [Exception].} = + result = callPrivateRPC("requestCancelDiscordCommunityImport".prefix, %*[communityId]) + proc createCommunityChannel*( communityId: string, name: string, diff --git a/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml b/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml index db01941a38..293f48dce4 100644 --- a/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml @@ -284,6 +284,7 @@ Item { editModeOn: model.editMode isEdited: model.isEdited linkUrls: model.links + messageAttachments: model.messageAttachments transactionParams: model.transactionParameters hasMention: model.mentionedUsersPks.split(" ").includes(root.rootStore.userProfileInst.pubKey) diff --git a/ui/app/AppLayouts/CommunitiesPortal/CommunitiesPortalLayout.qml b/ui/app/AppLayouts/CommunitiesPortal/CommunitiesPortalLayout.qml index 849de68051..ef0feda623 100644 --- a/ui/app/AppLayouts/CommunitiesPortal/CommunitiesPortalLayout.qml +++ b/ui/app/AppLayouts/CommunitiesPortal/CommunitiesPortalLayout.qml @@ -29,6 +29,7 @@ StatusSectionLayout { property var importCommunitiesPopup: importCommunitiesPopupComponent property var createCommunitiesPopup: createCommunitiesPopupComponent property int contentPrefferedWidth: 100 + property var discordImportProgressPopup: discordImportProgressDialog notificationCount: root.communitiesStore.unreadNotificationsCount onNotificationButtonClicked: Global.openActivityCenterPopup() @@ -219,9 +220,14 @@ StatusSectionLayout { } } CommunityBanner { - text: qsTr("Import existing Discord community into Status") + property bool importInProgress: root.communitiesStore.discordImportInProgress && !root.communitiesStore.discordImportCancelled + text: importInProgress ? + qsTr("'%1' import in progress...").arg(root.communitiesStore.discordImportCommunityName) : + qsTr("Import existing Discord community into Status") buttonText: qsTr("Import existing") icon.name: "download" + buttonTooltipText: qsTr("Your current import must finished or be cancelled before a new import can be started.") + buttonLoading: importInProgress onButtonClicked: { chooseCommunityCreationTypePopup.close() Global.openPopup(createCommunitiesPopupComponent, {isDiscordImport: true}) @@ -230,4 +236,11 @@ StatusSectionLayout { } } } + + Component { + id: discordImportProgressDialog + DiscordImportProgressDialog { + store: root.communitiesStore + } + } } diff --git a/ui/app/AppLayouts/CommunitiesPortal/popups/CreateCommunityPopup.qml b/ui/app/AppLayouts/CommunitiesPortal/popups/CreateCommunityPopup.qml index 601cd88ccc..d086629981 100644 --- a/ui/app/AppLayouts/CommunitiesPortal/popups/CreateCommunityPopup.qml +++ b/ui/app/AppLayouts/CommunitiesPortal/popups/CreateCommunityPopup.qml @@ -125,9 +125,16 @@ StatusStackModal { } IssuePill { type: root.store.discordImportErrorsCount ? IssuePill.Type.Error : IssuePill.Type.Warning - count: root.store.discordImportErrorsCount ? root.store.discordImportErrorsCount : - root.store.discordImportWarningsCount ? root.store.discordImportWarningsCount : 0 - visible: count + count: { + if (root.store.discordImportErrorsCount > 0) { + return root.store.discordImportErrorsCount + } + if (root.store.discordImportWarningsCount > 0) { + return root.store.discordImportWarningsCount + } + return 0 + } + visible: !!count } Item { Layout.fillWidth: true } StatusButton { @@ -243,6 +250,20 @@ StatusStackModal { readonly property bool canGoNext: root.store.discordChannelsModel.hasSelectedItems readonly property var nextAction: function () { + d.requestImportDiscordCommunity() + // replace ourselves with the progress dialog, no way back + root.leftButtons[0].visible = false + root.backgroundColor = Theme.palette.baseColor4 + root.replace(progressComponent) + } + + Component { + id: progressComponent + DiscordImportProgressContents { + width: root.availableWidth + store: root.store + onClose: root.close() + } } Item { @@ -456,34 +477,46 @@ StatusStackModal { QtObject { id: d + function _getCommunityConfig() { + return { + name: StatusQUtils.Utils.filterXSS(nameInput.input.text), + description: StatusQUtils.Utils.filterXSS(descriptionTextInput.input.text), + introMessage: StatusQUtils.Utils.filterXSS(introMessageInput.input.text), + outroMessage: StatusQUtils.Utils.filterXSS(outroMessageInput.input.text), + color: colorPicker.color.toString().toUpperCase(), + tags: communityTagsPicker.selectedTags, + image: { + src: logoPicker.source, + AX: logoPicker.cropRect.x, + AY: logoPicker.cropRect.y, + BX: logoPicker.cropRect.x + logoPicker.cropRect.width, + BY: logoPicker.cropRect.y + logoPicker.cropRect.height, + }, + options: { + historyArchiveSupportEnabled: options.archiveSupportEnabled, + checkedMembership: options.requestToJoinEnabled ? Constants.communityChatOnRequestAccess : Constants.communityChatPublicAccess, + pinMessagesAllowedForMembers: options.pinMessagesEnabled + }, + bannerJsonStr: JSON.stringify({imagePath: String(bannerPicker.source).replace("file://", ""), cropRect: bannerPicker.cropRect}) + } + } + function createCommunity() { - const error = store.createCommunity({ - name: StatusQUtils.Utils.filterXSS(nameInput.input.text), - description: StatusQUtils.Utils.filterXSS(descriptionTextInput.input.text), - introMessage: StatusQUtils.Utils.filterXSS(introMessageInput.input.text), - outroMessage: StatusQUtils.Utils.filterXSS(outroMessageInput.input.text), - color: colorPicker.color.toString().toUpperCase(), - tags: communityTagsPicker.selectedTags, - image: { - src: logoPicker.source, - AX: logoPicker.cropRect.x, - AY: logoPicker.cropRect.y, - BX: logoPicker.cropRect.x + logoPicker.cropRect.width, - BY: logoPicker.cropRect.y + logoPicker.cropRect.height, - }, - options: { - historyArchiveSupportEnabled: options.archiveSupportEnabled, - checkedMembership: options.requestToJoinEnabled ? Constants.communityChatOnRequestAccess : Constants.communityChatPublicAccess, - pinMessagesAllowedForMembers: options.pinMessagesEnabled - }, - bannerJsonStr: JSON.stringify({imagePath: String(bannerPicker.source).replace("file://", ""), cropRect: bannerPicker.cropRect}) - }) + const error = root.store.createCommunity(_getCommunityConfig()) if (error) { errorDialog.text = error.error errorDialog.open() } root.close() } + + function requestImportDiscordCommunity() { + const error = root.store.requestImportDiscordCommunity(_getCommunityConfig(), datePicker.selectedDate.valueOf()/1000) + if (error) { + errorDialog.text = error.error + errorDialog.open() + } + } } MessageDialog { diff --git a/ui/app/AppLayouts/CommunitiesPortal/popups/DiscordImportProgressContents.qml b/ui/app/AppLayouts/CommunitiesPortal/popups/DiscordImportProgressContents.qml new file mode 100644 index 0000000000..738bd5c19d --- /dev/null +++ b/ui/app/AppLayouts/CommunitiesPortal/popups/DiscordImportProgressContents.qml @@ -0,0 +1,356 @@ +import QtQuick 2.14 +import QtQuick.Controls 2.14 +import QtQuick.Layouts 1.14 + +import utils 1.0 +import shared.popups 1.0 + +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Components 0.1 +import StatusQ.Popups.Dialog 0.1 + +import SortFilterProxyModel 0.2 + +import "../controls" + +StatusScrollView { + id: root + + property var store + + signal close() + + implicitWidth: childrenRect.width + padding: 0 + + enum ImportStatus { + Unknown, + InProgress, + Stopped, + StoppedWithErrors, + CompletedWithWarnings, + CompletedSuccessfully + } + + readonly property list rightButtons: [ + StatusButton { + visible: d.status === DiscordImportProgressContents.ImportStatus.CompletedWithWarnings || + d.status === DiscordImportProgressContents.ImportStatus.StoppedWithErrors + type: StatusButton.Danger + font.weight: Font.Medium + text: qsTr("Delete community & restart import") + onClicked: { + // TODO display a confirmation and open CreateCommunityPopup again + root.close() + } + }, + StatusButton { + visible: d.status === DiscordImportProgressContents.ImportStatus.InProgress + type: StatusButton.Danger + font.weight: Font.Medium + text: qsTr("Cancel import") + onClicked: { + Global.openPopup(cancelConfirmationPopupCmp) + } + }, + StatusButton { + visible: d.status === DiscordImportProgressContents.ImportStatus.Stopped // TODO find out exactly when to display this button + type: StatusButton.Danger + font.weight: Font.Medium + text: qsTr("Restart import") + onClicked: { + // TODO open CreateCommunityPopup again + root.store.resetDiscordImport() + root.close() + } + }, + StatusButton { + visible: d.status === DiscordImportProgressContents.ImportStatus.InProgress + font.weight: Font.Medium + text: qsTr("Hide window") + onClicked: root.close() + }, + StatusButton { + visible: d.status === DiscordImportProgressContents.ImportStatus.CompletedSuccessfully || + d.status === DiscordImportProgressContents.ImportStatus.CompletedWithWarnings + font.weight: Font.Medium + text: qsTr("Visit your new community") + onClicked: { + root.close() + root.store.setActiveCommunity(root.store.discordImportCommunityId) + } + } + ] + + QtObject { + id: d + + readonly property var helperInfo: { + "import.communityCreation": { + icon: "network", + text: qsTr("Setting up your community") + }, + "import.channelsCreation": { + icon: "channel", + text: qsTr("Importing channels") + }, + "import.importMessages": { + icon: "receive", + text: qsTr("Importing messages") + }, + "import.downloadAssets": { + icon: "image", + text: qsTr("Downloading assets") + }, + "import.initializeCommunity": { + icon: "communities", + text: qsTr("Initializing community") + } + } + + readonly property int importProgress: root.store.discordImportProgress // FIXME for now it is 0..100 + readonly property bool importStopped: root.store.discordImportProgressStopped + readonly property bool hasErrors: root.store.discordImportErrorsCount + readonly property bool hasWarnings: root.store.discordImportWarningsCount + + readonly property int status: { + if (importStopped) { + if (hasErrors) + return DiscordImportProgressContents.ImportStatus.StoppedWithErrors + return DiscordImportProgressContents.ImportStatus.Stopped + } + if (importProgress >= 100) { + if (hasWarnings) + return DiscordImportProgressContents.ImportStatus.CompletedWithWarnings + return DiscordImportProgressContents.ImportStatus.CompletedSuccessfully + } + if (importProgress > 0 && importProgress < 100) + return DiscordImportProgressContents.ImportStatus.InProgress + return DiscordImportProgressContents.ImportStatus.Unknown + } + + function getSubtaskDescription(progress, stopped, state) { + if (progress >= 1.0) + return qsTr("✓ Complete") + if (progress > 0 && stopped) + return qsTr("Import stopped...") + if (importStopped) + return "" + if (progress <= 0.0) + return qsTr("Pending...") + + return state == "import.taskState.saving" ? + qsTr("Saving... This can take a moment, almost done!") : + qsTr("Working...") + } + } + + Component { + id: subtaskComponent + ColumnLayout { + id: subtaskDelegate + spacing: 40 + width: parent.width + + readonly property int errorsAndWarningsCount: model.errorsCount + model.warningsCount + readonly property string type: model.type + readonly property var errors: model.errors + + RowLayout { + id: subtaskRow + spacing: 12 + Layout.fillWidth: true + Layout.preferredHeight: 42 + + StatusRoundIcon { + id: subtaskIcon + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + asset.name: d.helperInfo[model.type].icon + } + ColumnLayout { + spacing: 4 + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + StatusBaseText { + font.pixelSize: 15 + text: d.helperInfo[model.type].text + } + StatusBaseText { + font.pixelSize: 12 + color: { + if (model.progress >= 1) + return Theme.palette.successColor1 + if (model.progress > 0 && d.hasErrors) + return Theme.palette.dangerColor1 + return Theme.palette.baseColor1 + } + text: d.getSubtaskDescription(model.progress, model.stopped, model.state) + } + } + Item { Layout.fillWidth: true } + StatusBaseText { + Layout.alignment: Qt.AlignVCenter + font.pixelSize: 13 + font.weight: Font.Medium + visible: subtaskProgressBar.visible + text: qsTr("%1%").arg(Math.round(model.progress*100)) + } + StatusProgressBar { + id: subtaskProgressBar + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 130 + Layout.preferredHeight: 10 + visible: value > 0 && value <= 1 && d.status !== DiscordImportProgressContents.ImportStatus.StoppedWithErrors + fillColor: Theme.palette.primaryColor1 + backgroundColor: Theme.palette.directColor8 + value: model.progress + } + } + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: subtaskIcon.width + subtaskRow.spacing + spacing: 12 + + Repeater { + model: SortFilterProxyModel { + sourceModel: subtaskDelegate.errors + sorters: RoleSorter { roleName: "code"; sortOrder: Qt.DescendingOrder } // errors first + } + delegate: IssuePill { + Layout.fillWidth: true + horizontalPadding: 12 + verticalPadding: 8 + bgCornerRadius: 8 + visible: text + type: model.code === Constants.DiscordImportErrorCode.Error ? IssuePill.Type.Error : IssuePill.Type.Warning + text: model.message + } + } + + Loader { + active: subtaskDelegate.errorsAndWarningsCount > 3 + Layout.fillWidth: true + sourceComponent: IssuePill { + width: parent.width + horizontalPadding: 12 + verticalPadding: 8 + bgCornerRadius: 8 + visible: text + type: IssuePill.Type.Warning + text: qsTr("%1 more issue(s) downloading assets").arg(errorsAndWarningsCount - 3) + } + } + } + StatusDialogDivider { + Layout.fillWidth: true + Layout.leftMargin: -24 // compensate for Control.horizontalPadding -> full width + Layout.rightMargin: -24 // compensate for Control.horizontalPadding -> full width + visible: !parent.Positioner.isLastItem + } + } + } + + ColumnLayout { + width: parent.width + spacing: 20 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Image { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + sourceSize: Qt.size(36, 36) + source: Style.svg("contact") // TODO community icon + } + StatusBaseText { + Layout.fillWidth: true + font.pixelSize: 15 + text: { + switch (d.status) { + case DiscordImportProgressContents.ImportStatus.InProgress: + return qsTr("Importing ‘%1’ from Discord...").arg(root.store.discordImportCommunityName) + case DiscordImportProgressContents.ImportStatus.Stopped: + return qsTr("Importing ‘%1’ from Discord stopped...").arg(root.store.discordImportCommunityName) + case DiscordImportProgressContents.ImportStatus.StoppedWithErrors: + return qsTr("Importing ‘%1’ stopped due to a critical issue...").arg(root.store.discordImportCommunityName) + case DiscordImportProgressContents.ImportStatus.CompletedWithWarnings: + return qsTr("‘%1’ was imported with %n issue(s).", "", root.store.discordImportWarningsCount).arg(root.store.discordImportCommunityName) + case DiscordImportProgressContents.ImportStatus.CompletedSuccessfully: + return qsTr("‘%1’ was successfully imported from Discord.").arg(root.store.discordImportCommunityName) + default: + return qsTr("Your Discord community import is in progress...") + } + } + } + Item { Layout.fillWidth: true } + IssuePill { + type: d.hasErrors ? IssuePill.Type.Error : IssuePill.Type.Warning + count: d.hasErrors ? root.store.discordImportErrorsCount : + d.hasWarnings ? root.store.discordImportWarningsCount : 0 + visible: count + } + } + + Control { + Layout.fillWidth: true + horizontalPadding: 24 + verticalPadding: 40 + background: Rectangle { + radius: 16 + color: Theme.palette.indirectColor1 + border.width: 1 + border.color: Theme.palette.directColor8 + } + contentItem: Column { + spacing: 40 + + Repeater { + model: root.store.discordImportTasks + delegate: subtaskComponent + } + } + } + + StatusBaseText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Qt.AlignHCenter + wrapMode: Text.WordWrap + font.pixelSize: 13 + text: d.status === DiscordImportProgressContents.ImportStatus.InProgress ? + qsTr("This process can take a while. Feel free to hide this window and use Status normally in the meantime. We’ll notify you when the Community is ready for you.") : + qsTr("If there were any issues with your import you can upload new JSON files via the community page at any time.") + } + } + + Component { + id: cancelConfirmationPopupCmp + ConfirmationDialog { + id: cancelConfirmationPopup + header.title: qsTr("Are you sure you want to cancel the import?") + confirmationText: qsTr("Your new Status community will be deleted and all information entered will be lost.") + showCancelButton: true + cancelBtnType: "default" + confirmButtonLabel: qsTr("Delete community") + cancelButtonLabel: qsTr("Continue importing") + onConfirmButtonClicked: { + root.store.requestCancelDiscordCommunityImport(root.store.discordImportCommunityId) + cancelConfirmationPopup.close() + root.close() + } + onCancelButtonClicked: { + cancelConfirmationPopup.close() + } + onClosed: { + destroy() + } + } + } +} diff --git a/ui/app/AppLayouts/CommunitiesPortal/popups/DiscordImportProgressDialog.qml b/ui/app/AppLayouts/CommunitiesPortal/popups/DiscordImportProgressDialog.qml new file mode 100644 index 0000000000..4f1b66088d --- /dev/null +++ b/ui/app/AppLayouts/CommunitiesPortal/popups/DiscordImportProgressDialog.qml @@ -0,0 +1,51 @@ +import QtQuick 2.14 +import QtQuick.Controls 2.14 +import QtQuick.Layouts 1.14 +import QtQml.Models 2.14 + +import utils 1.0 + +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Components 0.1 +import StatusQ.Popups.Dialog 0.1 + +import "../controls" + +StatusDialog { + id: root + + property var store + + title: qsTr("Import a community from Discord into Status") + + horizontalPadding: 16 + verticalPadding: 20 + width: 640 + + onClosed: destroy() + + Component.onCompleted: { + const buttons = contents.rightButtons + for (let i = 0; i < buttons.length; i++) { + footer.rightButtons.append(buttons[i]) + } + } + + footer: StatusDialogFooter { + id: footer + rightButtons: ObjectModel {} + } + + background: StatusDialogBackground { + color: Theme.palette.baseColor4 + } + + contentItem: DiscordImportProgressContents { + id: contents + width: root.availableWidth + store: root.store + onClose: root.close() + } +} diff --git a/ui/app/AppLayouts/CommunitiesPortal/stores/CommunitiesStore.qml b/ui/app/AppLayouts/CommunitiesPortal/stores/CommunitiesStore.qml index 0914d5d4be..06448a2636 100644 --- a/ui/app/AppLayouts/CommunitiesPortal/stores/CommunitiesStore.qml +++ b/ui/app/AppLayouts/CommunitiesPortal/stores/CommunitiesStore.qml @@ -17,6 +17,13 @@ QtObject { property bool discordDataExtractionInProgress: root.communitiesModuleInst.discordDataExtractionInProgress property int discordImportErrorsCount: root.communitiesModuleInst.discordImportErrorsCount property int discordImportWarningsCount: root.communitiesModuleInst.discordImportWarningsCount + property int discordImportProgress: root.communitiesModuleInst.discordImportProgress + property bool discordImportInProgress: root.communitiesModuleInst.discordImportInProgress + property bool discordImportCancelled: root.communitiesModuleInst.discordImportCancelled + property bool discordImportProgressStopped: root.communitiesModuleInst.discordImportProgressStopped + property string discordImportCommunityId: root.communitiesModuleInst.discordImportCommunityId + property string discordImportCommunityName: root.communitiesModuleInst.discordImportCommunityName + property var discordImportTasks: root.communitiesModuleInst.discordImportTasks property string locale: localAppSettings.language property var advancedModule: profileSectionModule.advancedModule property bool isCommunityHistoryArchiveSupportEnabled: advancedModule? advancedModule.isCommunityHistoryArchiveSupportEnabled : false @@ -105,5 +112,40 @@ QtObject { function toggleDiscordChannel(id, selected) { root.communitiesModuleInst.toggleDiscordChannel(id, selected) + } + + function requestCancelDiscordCommunityImport(id) { + root.communitiesModuleInst.requestCancelDiscordCommunityImport(id) + } + + function resetDiscordImport() { + root.communitiesModuleInst.resetDiscordImport(false) + } + + function requestImportDiscordCommunity(args = { + name: "", + description: "", + introMessage: "", + outroMessage: "", + color: "", + tags: "", + image: { + src: "", + AX: 0, + AY: 0, + BX: 0, + BY: 0, + }, + options: { + historyArchiveSupportEnabled: false, + checkedMembership: false, + pinMessagesAllowedForMembers: false + } + }, from = 0) { + return communitiesModuleInst.requestImportDiscordCommunity( + args.name, args.description, args.introMessage, args.outroMessage, args.options.checkedMembership, + args.color, args.tags, + args.image.src, args.image.AX, args.image.AY, args.image.BX, args.image.BY, + args.options.historyArchiveSupportEnabled, args.options.pinMessagesAllowedForMembers, from); } } diff --git a/ui/app/mainui/AppMain.qml b/ui/app/mainui/AppMain.qml index a3a0c8c714..f1cae7f9f5 100644 --- a/ui/app/mainui/AppMain.qml +++ b/ui/app/mainui/AppMain.qml @@ -498,6 +498,57 @@ Item { } } + + ModuleWarning { + Layout.fillWidth: true + readonly property int progress: communitiesPortalLayoutContainer.communitiesStore.discordImportProgress + readonly property bool inProgress: progress > 0 && progress < 100 + readonly property bool finished: progress >= 100 + readonly property bool cancelled: communitiesPortalLayoutContainer.communitiesStore.discordImportCancelled + readonly property bool stopped: communitiesPortalLayoutContainer.communitiesStore.discordImportProgressStopped + readonly property int errors: communitiesPortalLayoutContainer.communitiesStore.discordImportErrorsCount + readonly property int warnings: communitiesPortalLayoutContainer.communitiesStore.discordImportWarningsCount + readonly property string communityId: communitiesPortalLayoutContainer.communitiesStore.discordImportCommunityId + readonly property string communityName: communitiesPortalLayoutContainer.communitiesStore.discordImportCommunityName + + active: !cancelled && (inProgress || finished || stopped) + type: errors ? ModuleWarning.Type.Danger : ModuleWarning.Type.Success + text: { + if (finished || stopped) { + if (errors) + return qsTr("The import of ‘%1’ from Discord to Status was stopped: Critical issues found").arg(communityName) + + let result = qsTr("‘%1’ was successfully imported from Discord to Status").arg(communityName) + " " + if (warnings) + result += qsTr("Details (%1)").arg(qsTr("%n issue(s)", "", warnings)) + else + result += qsTr("Details") + result += "" + return result + } + if (inProgress) { + let result = qsTr("Importing ‘%1’ from Discord to Status").arg(communityName) + " " + if (warnings) + result += qsTr("Check progress (%1)").arg(qsTr("%n issue(s)", "", warnings)) + else + result += qsTr("Check progress") + result += "" + return result + } + } + onLinkActivated: Global.openPopup(communitiesPortalLayoutContainer.discordImportProgressPopup) + progressValue: progress + closeBtnVisible: finished || stopped + buttonText: finished && !errors ? qsTr("Visit your Community") : "" + onClicked: function() { + communitiesPortalLayoutContainer.communitiesStore.setActiveCommunity(communityId) + } + onCloseClicked: { + hide(); + } + } + + Component { id: connectedBannerComponent diff --git a/ui/imports/shared/controls/chat/UserImage.qml b/ui/imports/shared/controls/chat/UserImage.qml index 06451f8468..620bd3aa10 100644 --- a/ui/imports/shared/controls/chat/UserImage.qml +++ b/ui/imports/shared/controls/chat/UserImage.qml @@ -18,6 +18,7 @@ Loader { property string image property bool showRing: true property bool interactive: true + property bool disabled: false property int colorId: Utils.colorIdForPubkey(pubkey) property var colorHash: Utils.getColorHashAsJson(pubkey) @@ -44,10 +45,12 @@ Loader { active: root.interactive sourceComponent: MouseArea { - cursorShape: Qt.PointingHandCursor - hoverEnabled: true + cursorShape: hoverEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + hoverEnabled: !root.disabled onClicked: { - root.clicked() + if (!root.disabled) { + root.clicked() + } } } } diff --git a/ui/imports/shared/controls/chat/UsernameLabel.qml b/ui/imports/shared/controls/chat/UsernameLabel.qml index c68ec87723..913766bd2e 100644 --- a/ui/imports/shared/controls/chat/UsernameLabel.qml +++ b/ui/imports/shared/controls/chat/UsernameLabel.qml @@ -15,6 +15,7 @@ Item { property string displayName property string localName property bool amISender + property bool disabled signal clickMessage(bool isProfileClick) @@ -24,15 +25,15 @@ Item { color: text.startsWith("@") || root.amISender || localName !== "" ? Style.current.blue : Style.current.secondaryText font.weight: Font.Medium font.pixelSize: Style.current.secondaryTextFontSize - font.underline: root.isHovered + font.underline: root.isHovered && !root.disabled readOnly: true wrapMode: Text.WordWrap selectByMouse: true MouseArea { - cursorShape: Qt.PointingHandCursor + cursorShape: hoverEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor acceptedButtons: Qt.LeftButton | Qt.RightButton anchors.fill: parent - hoverEnabled: true + hoverEnabled: !root.disabled onEntered: { root.isHovered = true } @@ -40,7 +41,9 @@ Item { root.isHovered = false } onClicked: { - root.clickMessage(true); + if (!root.disabled) { + root.clickMessage(true); + } } } } diff --git a/ui/imports/shared/panels/CommunityBanner.qml b/ui/imports/shared/panels/CommunityBanner.qml index aaf4bb4d48..68982d227a 100644 --- a/ui/imports/shared/panels/CommunityBanner.qml +++ b/ui/imports/shared/panels/CommunityBanner.qml @@ -18,6 +18,8 @@ Rectangle { property alias text: bannerText.text property alias buttonText: bannerButton.text property alias icon: bannerIcon.asset + property string buttonTooltipText: "" + property bool buttonLoading: false implicitWidth: 272 implicitHeight: 168 @@ -74,7 +76,17 @@ Rectangle { anchors.bottom: parent.bottom anchors.bottomMargin: 16 font.weight: Font.Medium - onClicked: root.buttonClicked() + onClicked: { + if (!root.buttonLoading) { + root.buttonClicked() + } + } + loading: root.buttonLoading + + StatusQControls.StatusToolTip { + text: root.buttonTooltipText + visible: !!root.buttonTooltipText && bannerButton.loading && bannerButton.hovered + } } } diff --git a/ui/imports/shared/panels/ModuleWarning.qml b/ui/imports/shared/panels/ModuleWarning.qml index 1b7eb585b4..288f438161 100644 --- a/ui/imports/shared/panels/ModuleWarning.qml +++ b/ui/imports/shared/panels/ModuleWarning.qml @@ -1,5 +1,5 @@ -import QtQuick 2.13 -import QtQuick.Controls 2.13 +import QtQuick 2.14 +import QtQuick.Controls 2.14 import QtQuick.Layouts 1.13 import QtGraphicalEffects 1.13 @@ -7,8 +7,9 @@ import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 import utils 1.0 -import "../" -import "./" + +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 Item { id: root @@ -20,8 +21,10 @@ Item { property bool active: false property int type: ModuleWarning.Danger + property int progressValue: -1 // 0..100, -1 not visible property string text: "" property alias buttonText: button.text + property alias closeBtnVisible: closeImg.visible signal clicked() signal closeClicked() @@ -49,6 +52,8 @@ Item { closeButtonMouseArea.clicked(null) } + signal linkActivated(string link) + implicitHeight: root.active ? content.implicitHeight : 0 visible: implicitHeight > 0 @@ -123,14 +128,24 @@ Item { id: layout spacing: 12 - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter + anchors.centerIn: parent StatusBaseText { text: root.text - Layout.alignment: Qt.AlignVCenter font.pixelSize: 13 + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter color: Theme.palette.indirectColor1 + linkColor: color + onLinkActivated: root.linkActivated(link) + HoverHandler { + id: handler1 + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: handler1.hovered && parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } } Button { @@ -140,9 +155,9 @@ Item { onClicked: { root.clicked() } - contentItem: Text { + contentItem: StatusBaseText { text: button.text - font.pixelSize: 12 + font.pixelSize: 13 font.weight: Font.Medium font.family: Style.current.baseFont.name horizontalAlignment: Text.AlignHCenter @@ -163,6 +178,42 @@ Item { } } + StatusBaseText { + anchors.verticalCenter: parent.verticalCenter + anchors.right: progressBar.left + anchors.rightMargin: Style.current.halfPadding + text: qsTr("%1%").arg(progressBar.value) + visible: progressBar.visible + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + color: Theme.palette.white + } + + ProgressBar { + id: progressBar + anchors.verticalCenter: parent.verticalCenter + anchors.right: closeImg.left + anchors.rightMargin: Style.current.bigPadding + from: 0 + to: 100 + visible: root.progressValue > -1 + value: root.progressValue + background: Rectangle { + implicitWidth: 64 + implicitHeight: 8 + radius: 8 + color: "transparent" + border.width: 1 + border.color: Theme.palette.white + } + contentItem: Rectangle { + width: progressBar.width*progressBar.position + implicitHeight: 8 + radius: 8 + color: Theme.palette.white + } + } + StatusIcon { id: closeImg anchors.verticalCenter: parent.verticalCenter @@ -185,5 +236,4 @@ Item { } } } - } diff --git a/ui/imports/shared/popups/ConfirmationDialog.qml b/ui/imports/shared/popups/ConfirmationDialog.qml index be52364b15..6e2eec27c5 100644 --- a/ui/imports/shared/popups/ConfirmationDialog.qml +++ b/ui/imports/shared/popups/ConfirmationDialog.qml @@ -18,6 +18,7 @@ StatusModal { property var executeCancel property string confirmButtonObjectName: "" property string btnType: "warn" + property string cancelBtnType: "warn" property string confirmButtonLabel: qsTr("Confirm") property string rejectButtonLabel: qsTr("Reject") property string cancelButtonLabel: qsTr("Cancel") @@ -81,6 +82,14 @@ StatusModal { id: cancelButton visible: showCancelButton text: confirmationDialog.cancelButtonLabel + type: { + switch (confirmationDialog.cancelBtnType) { + case "warn": + return StatusBaseButton.Type.Danger + default: + return StatusBaseButton.Type.Normal + } + } onClicked: { if (executeCancel && typeof executeCancel === "function") { executeCancel() @@ -106,7 +115,7 @@ StatusModal { case "warn": return StatusBaseButton.Type.Danger default: - return StatusBaseButton.Type.Primary + return StatusBaseButton.Type.Normal } } text: confirmationDialog.confirmButtonLabel diff --git a/ui/imports/shared/views/chat/MessageView.qml b/ui/imports/shared/views/chat/MessageView.qml index cfdd0bc569..42c5736c34 100644 --- a/ui/imports/shared/views/chat/MessageView.qml +++ b/ui/imports/shared/views/chat/MessageView.qml @@ -62,6 +62,7 @@ Loader { property string messagePinnedBy: "" property var reactionsModel: [] property string linkUrls: "" + property string messageAttachments: "" property var transactionParams // External behavior changers @@ -101,7 +102,7 @@ Loader { property double prevMsgTimestamp: prevMessageAsJsonObj ? prevMessageAsJsonObj.timestamp : 0 property double nextMsgTimestamp: nextMessageAsJsonObj ? nextMessageAsJsonObj.timestamp : 0 - property bool shouldRepeatHeader: ((messageTimestamp - prevMsgTimestamp) / 60 / 1000) > Constants.repeatHeaderInterval + property bool shouldRepeatHeader: ((messageTimestamp - prevMsgTimestamp) / 60 / 1000) > Constants.repeatHeaderInterval || isDiscordMessage property bool hasMention: false property bool stickersLoaded: false @@ -111,11 +112,12 @@ Loader { property int stickerPack: -1 property bool isEmoji: messageContentType === Constants.messageContentType.emojiType - property bool isImage: messageContentType === Constants.messageContentType.imageType + property bool isImage: messageContentType === Constants.messageContentType.imageType || (isDiscordMessage && messageImage != "") property bool isAudio: messageContentType === Constants.messageContentType.audioType property bool isStatusMessage: messageContentType === Constants.messageContentType.systemMessagePrivateGroupType property bool isSticker: messageContentType === Constants.messageContentType.stickerType - property bool isText: messageContentType === Constants.messageContentType.messageType || messageContentType === Constants.messageContentType.editType + property bool isDiscordMessage: messageContentType === Constants.messageContentType.discordMessageType + property bool isText: messageContentType === Constants.messageContentType.messageType || messageContentType === Constants.messageContentType.editType || isDiscordMessage property bool isMessage: isEmoji || isImage || isSticker || isText || isAudio || messageContentType === Constants.messageContentType.communityInviteType || messageContentType === Constants.messageContentType.transactionType @@ -411,7 +413,7 @@ Loader { loadingImageText: qsTr("Loading image...") errorLoadingImageText: qsTr("Error loading the image") resendText: qsTr("Resend") - pinnedMsgInfoText: qsTr("Pinned by") + pinnedMsgInfoText: root.isDiscordMessage ? qsTr("Pinned") : qsTr("Pinned by") reactionIcons: [ Style.svg("emojiReactions/heart"), Style.svg("emojiReactions/thumbsUp"), @@ -427,7 +429,7 @@ Loader { isEdited: root.isEdited hasMention: root.hasMention isPinned: root.pinnedMessage - pinnedBy: root.pinnedMessage ? Utils.getContactDetailsAsJson(root.messagePinnedBy).displayName : "" + pinnedBy: root.pinnedMessage && !root.isDiscordMessage ? Utils.getContactDetailsAsJson(root.messagePinnedBy).displayName : "" hasExpired: root.isExpired reactionsModel: root.reactionsModel @@ -457,7 +459,8 @@ Loader { return Utils.setColorAlpha(Style.current.blue, 0.1); return "transparent"; } - + profileClickable: !root.isDiscordMessage + messageAttachments: root.messageAttachments timestampString: Utils.formatShortTime(timestamp, localAccountSensitiveSettings.is24hTimeFormat) @@ -548,6 +551,7 @@ Loader { messageDetails: StatusMessageDetails { contentType: delegate.contentType + messageOriginInfo: isDiscordMessage ? qsTr("Imported from discord") : "" messageText: root.messageText messageContent: { switch (delegate.contentType) @@ -557,6 +561,9 @@ Loader { case StatusMessage.ContentType.Image: return root.messageImage; } + if (root.isDiscordMessage && root.messageImage != "") { + return root.messageImage + } return ""; } @@ -571,9 +578,11 @@ Loader { width: 40 height: 40 name: root.senderIcon || "" + assetSettings.isImage: root.isDiscordMessage pubkey: root.senderId colorId: Utils.colorIdForPubkey(root.senderId) colorHash: Utils.getColorHashAsJson(root.senderId) + showRing: !root.isDiscordMessage } } @@ -602,6 +611,8 @@ Loader { width: 20 height: 20 name: delegate.replyMessage ? delegate.replyMessage.senderIcon : "" + assetSettings.isImage: delegate.replyMessage && delegate.replyMessage.messageContentType == Constants.discordMessageType + showRing: delegate.replyMessage && delegate.replyMessage.messageContentType != Constants.discordMessageType pubkey: delegate.replySenderId colorId: Utils.colorIdForPubkey(delegate.replySenderId) colorHash: Utils.getColorHashAsJson(delegate.replySenderId) diff --git a/ui/imports/utils/Constants.qml b/ui/imports/utils/Constants.qml index 04c93ddde5..4cb2e57b4e 100644 --- a/ui/imports/utils/Constants.qml +++ b/ui/imports/utils/Constants.qml @@ -232,6 +232,7 @@ QtObject { readonly property int communityInviteType: 9 readonly property int gapType: 10 readonly property int editType: 11 + readonly property int discordMessageType: 12 } readonly property QtObject profilePicturesVisibility: QtObject {