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 {