From b52dceb984b3bb1a15173cac925408bd11dbeb8b Mon Sep 17 00:00:00 2001 From: Jonathan Rainville Date: Tue, 25 May 2021 15:34:46 -0400 Subject: [PATCH] feat(Chat): add pinned messages feature --- src/app/chat/event_handling.nim | 5 ++ src/app/chat/signal_handling.nim | 2 +- src/app/chat/view.nim | 63 ++++++++++++- src/app/chat/views/message_list.nim | 41 ++++++++- src/status/chat.nim | 24 ++++- src/status/chat/message.nim | 1 + src/status/libstatus/chat.nim | 28 ++++++ src/status/libstatus/wallet.nim | 2 +- src/status/signals/messages.nim | 23 ++++- src/status/signals/types.nim | 1 + .../Chat/ChatColumn/ChatMessages.qml | 1 + ui/app/AppLayouts/Chat/ChatColumn/Message.qml | 13 ++- .../MessageComponents/ChatButtons.qml | 21 +++++ .../MessageComponents/CompactMessage.qml | 62 +++++++++++-- ui/app/AppLayouts/Chat/ChatColumn/TopBar.qml | 3 + .../Chat/components/MessageContextMenu.qml | 36 +++++++- .../Chat/components/PinnedMessagesPopup.qml | 90 +++++++++++++++++++ ui/app/img/pin.svg | 4 + ui/imports/Themes/DarkTheme.qml | 5 ++ ui/imports/Themes/LightTheme.qml | 5 ++ ui/imports/Themes/Theme.qml | 5 ++ ui/nim-status-client.pro | 1 + ui/shared/status/StatusChatInfo.qml | 53 +++++++++++ vendor/status-go | 2 +- 24 files changed, 469 insertions(+), 22 deletions(-) create mode 100644 ui/app/AppLayouts/Chat/components/PinnedMessagesPopup.qml create mode 100644 ui/app/img/pin.svg diff --git a/src/app/chat/event_handling.nim b/src/app/chat/event_handling.nim index 443d7fb3b0..12ceb349fa 100644 --- a/src/app/chat/event_handling.nim +++ b/src/app/chat/event_handling.nim @@ -18,6 +18,9 @@ proc handleChatEvents(self: ChatController) = # Display emoji reactions self.status.events.on("reactionsLoaded") do(e:Args): self.view.reactions.push(ReactionsLoadedArgs(e).reactions) + # Display already pinned messages + self.status.events.on("pinnedMessagesLoaded") do(e:Args): + self.view.pushPinnedMessages(MsgsLoadedArgs(e).messages) self.status.events.on("contactUpdate") do(e: Args): var evArgs = ContactUpdateArgs(e) @@ -40,6 +43,8 @@ proc handleChatEvents(self: ChatController) = self.view.communities.addCommunityToList(community) if (evArgs.communityMembershipRequests.len > 0): self.view.communities.addMembershipRequests(evArgs.communityMembershipRequests) + if (evArgs.pinnedMessages.len > 0): + self.view.addPinnedMessages(evArgs.pinnedMessages) self.status.events.on("channelUpdate") do(e: Args): var evArgs = ChatUpdateArgs(e) diff --git a/src/app/chat/signal_handling.nim b/src/app/chat/signal_handling.nim index d8d31fdd95..9d4c7cbee6 100644 --- a/src/app/chat/signal_handling.nim +++ b/src/app/chat/signal_handling.nim @@ -4,7 +4,7 @@ import proc handleSignals(self: ChatController) = self.status.events.on(SignalType.Message.event) do(e:Args): var data = MessageSignal(e) - self.status.chat.update(data.chats, data.messages, data.emojiReactions, data.communities, data.membershipRequests) + self.status.chat.update(data.chats, data.messages, data.emojiReactions, data.communities, data.membershipRequests, data.pinnedMessages) self.status.events.on(SignalType.DiscoverySummary.event) do(e:Args): ## Handle mailserver peers being added and removed diff --git a/src/app/chat/view.nim b/src/app/chat/view.nim index 14b327bc9b..d0e4309a4d 100644 --- a/src/app/chat/view.nim +++ b/src/app/chat/view.nim @@ -65,10 +65,17 @@ const asyncMessageLoadTask: Task = proc(argEncoded: string) {.gcsafe, nimcall.} if(reactionsCallSuccess): reactions = reactionsCallResult.parseJson()["result"] + var pinnedMessages: JsonNode + var pinnedMessagesCallSuccess: bool + let pinnedMessagesCallResult = rpcPinnedChatMessages(arg.chatId, newJString(""), 20, pinnedMessagesCallSuccess) + if(reactionsCallSuccess): + pinnedMessages = pinnedMessagesCallResult.parseJson()["result"] + let responseJson = %*{ "chatId": arg.chatId, "messages": messages, - "reactions": reactions + "reactions": reactions, + "pinnedMessages": pinnedMessages } arg.finish(responseJson) @@ -104,6 +111,7 @@ QtObject: currentSuggestions*: SuggestionsList callResult: string messageList*: OrderedTable[string, ChatMessageList] + pinnedMessagesList*: OrderedTable[string, ChatMessageList] reactions*: ReactionView stickers*: StickersView groups*: GroupsView @@ -129,11 +137,14 @@ QtObject: self.currentSuggestions.delete for msg in self.messageList.values: msg.delete + for msg in self.pinnedMessagesList.values: + msg.delete self.reactions.delete self.stickers.delete self.groups.delete self.transactions.delete self.messageList = initOrderedTable[string, ChatMessageList]() + self.pinnedMessagesList = initOrderedTable[string, ChatMessageList]() self.communities.delete self.channelOpenTime = initTable[string, int64]() self.QAbstractListModel.delete @@ -147,6 +158,7 @@ QtObject: result.contextChannel = newChatItemView(status) result.currentSuggestions = newSuggestionsList() result.messageList = initOrderedTable[string, ChatMessageList]() + result.pinnedMessagesList = initOrderedTable[string, ChatMessageList]() result.reactions = newReactionView(status, result.messageList.addr, result.activeChannel) result.stickers = newStickersView(status, result.activeChannel) result.groups = newGroupsView(status,result.activeChannel) @@ -431,6 +443,7 @@ QtObject: if not self.messageList.hasKey(channel): self.beginInsertRows(newQModelIndex(), self.messageList.len, self.messageList.len) self.messageList[channel] = newChatMessageList(channel, self.status, not chat.isNil and chat.chatType != ChatType.Profile) + self.pinnedMessagesList[channel] = newChatMessageList(channel, self.status, false) self.channelOpenTime[channel] = now().toTime.toUnix * 1000 self.endInsertRows(); @@ -451,6 +464,13 @@ QtObject: proc isAddedContact*(self: ChatsView, id: string): bool {.slot.} = result = self.status.contacts.isAdded(id) + proc pushPinnedMessages*(self:ChatsView, messages: var seq[Message]) = + for msg in messages.mitems: + self.upsertChannel(msg.chatId) + self.pinnedMessagesList[msg.chatId].add(msg) + # put the message as pinned in the message list + self.messageList[msg.chatId].changeMessagePinned(msg.id, true) + proc pushMessages*(self:ChatsView, messages: var seq[Message]) = for msg in messages.mitems: self.upsertChannel(msg.chatId) @@ -532,6 +552,14 @@ QtObject: read = getMessageList notify = activeChannelChanged + proc getPinnedMessagesList(self: ChatsView): QVariant {.slot.} = + self.upsertChannel(self.activeChannel.id) + return newQVariant(self.pinnedMessagesList[self.activeChannel.id]) + + QtProperty[QVariant] pinnedMessagesList: + read = getPinnedMessagesList + notify = activeChannelChanged + proc pushChatItem*(self: ChatsView, chatItem: Chat) = discard self.chats.addChatItemToList(chatItem) self.messagePushed(self.messageList[chatItem.id].messages.len - 1) @@ -599,6 +627,10 @@ QtObject: let reactions = parseReactionsResponse(rpcResponseObj["chatId"].getStr, rpcResponseObj["reactions"]) self.status.chat.chatReactions(rpcResponseObj["chatId"].getStr, true, reactions[0], reactions[1]) + if(rpcResponseObj["pinnedMessages"].kind != JNull): + let pinnedMessages = parseChatMessagesResponse(rpcResponseObj["chatId"].getStr, rpcResponseObj["pinnedMessages"]) + self.status.chat.pinnedMessagesByChatID(rpcResponseObj["chatId"].getStr, pinnedMessages[0], pinnedMessages[1]) + proc hideLoadingIndicator*(self: ChatsView) {.slot.} = self.loadingMessages = false self.loadingMessagesChanged(false) @@ -835,6 +867,35 @@ QtObject: if(id == msg.id): return idx return idx + proc addPinMessage*(self: ChatsView, messageId: string, chatId: string) = + self.upsertChannel(chatId) + self.messageList[chatId].changeMessagePinned(messageId, true) + self.pinnedMessagesList[chatId].add(self.messageList[chatId].getMessageById(messageId)) + + proc removePinMessage*(self: ChatsView, messageId: string, chatId: string) = + self.upsertChannel(chatId) + self.messageList[chatId].changeMessagePinned(messageId, false) + try: + self.pinnedMessagesList[chatId].remove(messageId) + except Exception as e: + error "Error removing ", msg = e.msg + + + proc pinMessage*(self: ChatsView, messageId: string, chatId: string) {.slot.} = + self.status.chat.setPinMessage(messageId, chatId, true) + self.addPinMessage(messageId, chatId) + + proc unPinMessage*(self: ChatsView, messageId: string, chatId: string) {.slot.} = + self.status.chat.setPinMessage(messageId, chatId, false) + self.removePinMessage(messageId, chatId) + + proc addPinnedMessages*(self: ChatsView, pinnedMessages: seq[Message]) = + for pinnedMessage in pinnedMessages: + if (pinnedMessage.isPinned): + self.addPinMessage(pinnedMessage.id, pinnedMessage.localChatId) + else: + self.removePinMessage(pinnedMessage.id, pinnedMessage.localChatId) + proc isActiveMailserverResult(self: ChatsView, resultEncoded: string) {.slot.} = let isActiveMailserverAvailable = decode[bool](resultEncoded) if isActiveMailserverAvailable: diff --git a/src/app/chat/views/message_list.nim b/src/app/chat/views/message_list.nim index cd3bd1e426..a19fc63c5e 100644 --- a/src/app/chat/views/message_list.nim +++ b/src/app/chat/views/message_list.nim @@ -1,4 +1,4 @@ -import NimQml, Tables, sets, json, sugar +import NimQml, Tables, sets, json, sugar, chronicles import ../../../status/status import ../../../status/accounts import ../../../status/chat @@ -37,8 +37,9 @@ type CommunityId = UserRole + 27 HasMention = UserRole + 28 StickerPackId = UserRole + 29 - GapFrom = UserRole + 30 - GapTo = UserRole + 31 + IsPinned = UserRole + 30 + GapFrom = UserRole + 31 + GapTo = UserRole + 32 QtObject: type @@ -124,6 +125,15 @@ QtObject: method rowCount(self: ChatMessageList, index: QModelIndex = nil): int = return self.messages.len + proc countChanged*(self: ChatMessageList) {.signal.} + + proc count*(self: ChatMessageList): int {.slot.} = + self.messages.len + + QtProperty[int] count: + read = count + notify = countChanged + proc getReactions*(self:ChatMessageList, messageId: string):string = if not self.messageReactions.hasKey(messageId): return "" result = self.messageReactions[messageId] @@ -161,6 +171,7 @@ QtObject: of ChatMessageRoles.LinkUrls: result = newQVariant(message.linkUrls) of ChatMessageRoles.CommunityId: result = newQVariant(message.communityId) of ChatMessageRoles.HasMention: result = newQVariant(message.hasMention) + of ChatMessageRoles.IsPinned: result = newQVariant(message.isPinned) # Pass the command parameters as a JSON string of ChatMessageRoles.CommandParameters: result = newQVariant($(%*{ "id": message.commandParameters.id, @@ -205,6 +216,7 @@ QtObject: ChatMessageRoles.CommunityId.int: "communityId", ChatMessageRoles.Alias.int:"alias", ChatMessageRoles.HasMention.int:"hasMention", + ChatMessageRoles.IsPinned.int:"isPinned", ChatMessageRoles.LocalName.int:"localName", ChatMessageRoles.StickerPackId.int:"stickerPackId", ChatMessageRoles.GapFrom.int:"gapFrom", @@ -237,9 +249,11 @@ QtObject: proc add*(self: ChatMessageList, message: Message) = if self.messageIndex.hasKey(message.id): return # duplicated msg + debug "New message", chatId = self.id, id = message.id, text = message.text self.beginInsertRows(newQModelIndex(), self.messages.len, self.messages.len) self.messageIndex[message.id] = self.messages.len self.messages.add(message) + self.countChanged() self.endInsertRows() proc add*(self: ChatMessageList, messages: seq[Message]) = @@ -248,8 +262,19 @@ QtObject: if self.messageIndex.hasKey(message.id): continue self.messageIndex[message.id] = self.messages.len self.messages.add(message) + self.countChanged() self.endInsertRows() + proc remove*(self: ChatMessageList, messageId: string) = + if not self.messageIndex.hasKey(messageId): return + + let index = self.getMessageIndex(messageId) + self.beginRemoveRows(newQModelIndex(), index, index) + self.messages.delete(index) + self.messageIndex.del(messageId) + self.countChanged() + self.endRemoveRows() + proc getMessageById*(self: ChatMessageList, messageId: string): Message = if (not self.messageIndex.hasKey(messageId)): return return self.messages[self.messageIndex[messageId]] @@ -269,6 +294,16 @@ QtObject: let bottomRight = self.createIndex(msgIdx, 0, nil) self.dataChanged(topLeft, bottomRight, @[ChatMessageRoles.EmojiReactions.int]) + proc changeMessagePinned*(self: ChatMessageList, messageId: string, pinned: bool) = + if not self.messageIndex.hasKey(messageId): return + let msgIdx = self.messageIndex[messageId] + var message = self.messages[msgIdx] + message.isPinned = pinned + self.messages[msgIdx] = message + let topLeft = self.createIndex(msgIdx, 0, nil) + let bottomRight = self.createIndex(msgIdx, 0, nil) + self.dataChanged(topLeft, bottomRight, @[ChatMessageRoles.IsPinned.int]) + proc markMessageAsSent*(self: ChatMessageList, messageId: string)= let topLeft = self.createIndex(0, 0, nil) let bottomRight = self.createIndex(self.messages.len, 0, nil) diff --git a/src/status/chat.nim b/src/status/chat.nim index b46887f252..2ee95951e2 100644 --- a/src/status/chat.nim +++ b/src/status/chat.nim @@ -24,6 +24,7 @@ type ChatUpdateArgs* = ref object of Args chats*: seq[Chat] messages*: seq[Message] + pinnedMessages*: seq[Message] contacts*: seq[Profile] emojiReactions*: seq[Reaction] communities*: seq[Community] @@ -56,6 +57,7 @@ type contacts*: Table[string, Profile] channels*: Table[string, Chat] msgCursor*: Table[string, string] + pinnedMsgCursor*: Table[string, string] emojiCursor*: Table[string, string] lastMessageTimestamps*: Table[string, int64] @@ -73,6 +75,7 @@ proc newChatModel*(events: EventEmitter): ChatModel = result.contacts = initTable[string, Profile]() result.channels = initTable[string, Chat]() result.msgCursor = initTable[string, string]() + result.pinnedMsgCursor = initTable[string, string]() result.emojiCursor = initTable[string, string]() result.lastMessageTimestamps = initTable[string, int64]() @@ -99,7 +102,7 @@ proc cleanSpamChatGroups(self: ChatModel, chats: seq[Chat], contacts: seq[Profil else: result.add(chat) -proc update*(self: ChatModel, chats: seq[Chat], messages: seq[Message], emojiReactions: seq[Reaction], communities: seq[Community], communityMembershipRequests: seq[CommunityMembershipRequest]) = +proc update*(self: ChatModel, chats: seq[Chat], messages: seq[Message], emojiReactions: seq[Reaction], communities: seq[Community], communityMembershipRequests: seq[CommunityMembershipRequest], pinnedMessages: seq[Message]) = var contacts = getAddedContacts() # Automatically decline chat group invitations if admin is not a contact @@ -118,7 +121,7 @@ proc update*(self: ChatModel, chats: seq[Chat], messages: seq[Message], emojiRea if self.lastMessageTimestamps[chatId] > ts: self.lastMessageTimestamps[chatId] = ts - self.events.emit("chatUpdate", ChatUpdateArgs(messages: messages, chats: chatList, contacts: @[], emojiReactions: emojiReactions, communities: communities, communityMembershipRequests: communityMembershipRequests)) + self.events.emit("chatUpdate", ChatUpdateArgs(messages: messages, chats: chatList, contacts: @[], emojiReactions: emojiReactions, communities: communities, communityMembershipRequests: communityMembershipRequests, pinnedMessages: pinnedMessages)) proc hasChannel*(self: ChatModel, chatId: string): bool = self.channels.hasKey(chatId) @@ -504,3 +507,20 @@ proc pendingRequestsToJoinForCommunity*(self: ChatModel, communityKey: string): proc myPendingRequestsToJoin*(self: ChatModel): seq[CommunityMembershipRequest] = result = status_chat.myPendingRequestsToJoin() + +proc setPinMessage*(self: ChatModel, messageId: string, chatId: string, pinned: bool) = + status_chat.setPinMessage(messageId, chatId, pinned) + +proc pinnedMessagesByChatID*(self: ChatModel, chatId: string): seq[Message] = + if not self.pinnedMsgCursor.hasKey(chatId): + self.pinnedMsgCursor[chatId] = ""; + + let messageTuple = status_chat.pinnedMessagesByChatID(chatId, self.pinnedMsgCursor[chatId]) + self.pinnedMsgCursor[chatId] = messageTuple[0]; + + result = messageTuple[1] + +proc pinnedMessagesByChatID*(self: ChatModel, chatId: string, cursor: string = "", pinnedMessages: seq[Message]) = + self.msgCursor[chatId] = cursor + + self.events.emit("pinnedMessagesLoaded", MsgsLoadedArgs(messages: pinnedMessages)) diff --git a/src/status/chat/message.nim b/src/status/chat/message.nim index 50a9e1530d..bd6261bf46 100644 --- a/src/status/chat/message.nim +++ b/src/status/chat/message.nim @@ -68,6 +68,7 @@ type Message* = object communityId*: string audioDurationMs*: int hasMention*: bool + isPinned*: bool type Reaction* = object id*: string diff --git a/src/status/libstatus/chat.nim b/src/status/libstatus/chat.nim index 8acd07463a..423e898657 100644 --- a/src/status/libstatus/chat.nim +++ b/src/status/libstatus/chat.nim @@ -454,3 +454,31 @@ proc banUserFromCommunity*(pubKey: string, communityId: string): string = "communityId": communityId, "user": pubKey }]) + +proc rpcPinnedChatMessages*(chatId: string, cursorVal: JsonNode, limit: int, success: var bool): string = + success = true + try: + result = callPrivateRPC("chatPinnedMessages".prefix, %* [chatId, cursorVal, limit]) + except RpcException as e: + success = false + result = e.msg + +proc pinnedMessagesByChatID*(chatId: string, cursor: string): (string, seq[Message]) = + var cursorVal: JsonNode + + if cursor == "": + cursorVal = newJNull() + else: + cursorVal = newJString(cursor) + + var success: bool + let callResult = rpcPinnedChatMessages(chatId, cursorVal, 20, success) + if success: + result = parseChatMessagesResponse(chatId, callResult.parseJson()["result"]) + +proc setPinMessage*(messageId: string, chatId: string, pinned: bool) = + discard callPrivateRPC("sendPinMessage".prefix, %*[{ + "message_id": messageId, + "pinned": pinned, + "chat_id": chatId + }]) \ No newline at end of file diff --git a/src/status/libstatus/wallet.nim b/src/status/libstatus/wallet.nim index 64677d9f59..d21d2bbb85 100644 --- a/src/status/libstatus/wallet.nim +++ b/src/status/libstatus/wallet.nim @@ -1,7 +1,7 @@ import json, json, options, json_serialization, stint, chronicles import core, types, utils, strutils, strformat import utils -from status_go import validateMnemonic, startWallet +from status_go import validateMnemonic#, startWallet import ../wallet/account import web3/ethtypes import ./types diff --git a/src/status/signals/messages.nim b/src/status/signals/messages.nim index 8de9b2dc9a..16fe9051c4 100644 --- a/src/status/signals/messages.nim +++ b/src/status/signals/messages.nim @@ -63,10 +63,29 @@ proc fromEvent*(event: JsonNode): Signal = signal.communities.add(jsonCommunity.toCommunity) if event["event"]{"requestsToJoinCommunity"} != nil: - debug "requests", event = event["event"]["requestsToJoinCommunity"] for jsonCommunity in event["event"]["requestsToJoinCommunity"]: signal.membershipRequests.add(jsonCommunity.toCommunityMembershipRequest) + if event["event"]{"pinMessages"} != nil: + for jsonPinnedMessage in event["event"]["pinMessages"]: + var contentType: ContentType + try: + contentType = ContentType(jsonPinnedMessage{"contentType"}.getInt) + except: + warn "Unknown content type received", type = jsonPinnedMessage{"contentType"}.getInt + contentType = ContentType.Message + signal.pinnedMessages.add(Message( + id: jsonPinnedMessage{"message_id"}.getStr, + chatId: jsonPinnedMessage{"chat_id"}.getStr, + localChatId: jsonPinnedMessage{"localChatId"}.getStr, + fromAuthor: jsonPinnedMessage{"from"}.getStr, + identicon: jsonPinnedMessage{"identicon"}.getStr, + alias: jsonPinnedMessage{"alias"}.getStr, + clock: jsonPinnedMessage{"clock"}.getInt, + isPinned: jsonPinnedMessage{"pinned"}.getBool, + contentType: contentType + )) + result = signal proc toChatMember*(jsonMember: JsonNode): ChatMember = @@ -206,8 +225,6 @@ proc toCommunity*(jsonCommunity: JsonNode): Community = name: chat{"name"}.getStr, canPost: chat{"canPost"}.getBool, chatType: ChatType.CommunityChat - # TODO get this from access - #chat{"permissions"}{"access"}.getInt, )) if jsonCommunity.hasKey("categories") and jsonCommunity["categories"].kind != JNull: diff --git a/src/status/signals/types.nim b/src/status/signals/types.nim index a145985531..053bebbbaa 100644 --- a/src/status/signals/types.nim +++ b/src/status/signals/types.nim @@ -29,6 +29,7 @@ type EnvelopeExpiredSignal* = ref object of Signal type MessageSignal* = ref object of Signal messages*: seq[Message] + pinnedMessages*: seq[Message] chats*: seq[Chat] contacts*: seq[Profile] installations*: seq[Installation] diff --git a/ui/app/AppLayouts/Chat/ChatColumn/ChatMessages.qml b/ui/app/AppLayouts/Chat/ChatColumn/ChatMessages.qml index 02bc9b71cf..4abcce6c91 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn/ChatMessages.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn/ChatMessages.qml @@ -335,6 +335,7 @@ ScrollView { communityId: model.communityId hasMention: model.hasMention stickerPackId: model.stickerPackId + pinnedMessage: model.isPinned gapFrom: model.gapFrom gapTo: model.gapTo prevMessageIndex: { diff --git a/ui/app/AppLayouts/Chat/ChatColumn/Message.qml b/ui/app/AppLayouts/Chat/ChatColumn/Message.qml index beb02d5d49..4776bcd734 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn/Message.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn/Message.qml @@ -29,6 +29,8 @@ Item { property bool hasMention: false property string linkUrls: "" property bool placeholderMessage: false + property bool pinnedMessage: false + property bool forceHoverHandler: false // Used to force the HoverHandler to be active (useful for messages in popups) property string communityId: "" property int stickerPackId: -1 property int gapFrom: 0 @@ -164,7 +166,7 @@ Item { } } - function clickMessage(isProfileClick, isSticker = false, isImage = false, image = null, emojiOnly = false) { + function clickMessage(isProfileClick, isSticker = false, isImage = false, image = null, emojiOnly = false, hideEmojiPicker = false) { if (isImage) { imageClick(image); return; @@ -176,13 +178,18 @@ Item { // Get contact nickname let nickname = appMain.getUserNickname(fromAuthor) + messageContextMenu.messageId = root.messageId messageContextMenu.linkUrls = root.linkUrls messageContextMenu.isProfile = !!isProfileClick messageContextMenu.isSticker = isSticker messageContextMenu.emojiOnly = emojiOnly - messageContextMenu.show(userName, fromAuthor, root.profileImageSource || identicon, "", nickname, emojiReactionsModel) + messageContextMenu.hideEmojiPicker = hideEmojiPicker + messageContextMenu.pinnedMessage = pinnedMessage + messageContextMenu.show(userName, fromAuthor, root.profileImageSource || identicon, plainText, nickname, emojiReactionsModel) // Position the center of the menu where the mouse is - messageContextMenu.x = messageContextMenu.x - messageContextMenu.width / 2 + if (messageContextMenu.x + messageContextMenu.width + Style.current.padding < root.width) { + messageContextMenu.x = messageContextMenu.x - messageContextMenu.width / 2 + } } Loader { diff --git a/ui/app/AppLayouts/Chat/ChatColumn/MessageComponents/ChatButtons.qml b/ui/app/AppLayouts/Chat/ChatColumn/MessageComponents/ChatButtons.qml index 7129f95f7b..81912d852e 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn/MessageComponents/ChatButtons.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn/MessageComponents/ChatButtons.qml @@ -96,5 +96,26 @@ Rectangle { text: qsTrId("message-reply") } } + + StatusIconButton { + id: otherBtn + icon.name: "dots-icon" + width: 32 + height: 32 + onClicked: { + if (typeof isMessageActive !== "undefined") { + isMessageActive = true + } + clickMessage(false, isSticker, false, null, false, true) + } + onHoveredChanged: { + buttonsContainer.hoverChanged(this.hovered) + } + + StatusToolTip { + visible: otherBtn.hovered + text: qsTr("More") + } + } } } diff --git a/ui/app/AppLayouts/Chat/ChatColumn/MessageComponents/CompactMessage.qml b/ui/app/AppLayouts/Chat/ChatColumn/MessageComponents/CompactMessage.qml index a2466bb19a..a90df2aa4e 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn/MessageComponents/CompactMessage.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn/MessageComponents/CompactMessage.qml @@ -67,13 +67,61 @@ Item { + (!chatName.visible && chatImageContent.active ? 6 : 0) + (emojiReactionLoader.active ? emojiReactionLoader.height: 0) + (retry.visible && !chatTime.visible ? Style.current.smallPadding : 0) + + (pinnedRectangleLoader.active ? Style.current.smallPadding : 0) width: parent.width - color: root.isHovered || isMessageActive ? (hasMention ? Style.current.mentionMessageHoverColor : Style.current.backgroundHoverLight) : + color: { + if (pinnedMessage) { + return root.isHovered || isMessageActive ? Style.current.pinnedMessageBackgroundHovered : Style.current.pinnedMessageBackground + } + + return root.isHovered || isMessageActive ? (hasMention ? Style.current.mentionMessageHoverColor : Style.current.backgroundHoverLight) : (hasMention ? Style.current.mentionMessageColor : Style.current.transparent) + } + + Loader { + id: pinnedRectangleLoader + active: pinnedMessage + anchors.left: chatName.left + anchors.top: parent.top + anchors.topMargin: active ? Style.current.halfPadding : 0 + + sourceComponent: Component { + Rectangle { + id: pinnedRectangle + height: 24 + width: childrenRect.width + Style.current.smallPadding + color: Style.current.pinnedRectangleBackground + radius: 12 + + SVGImage { + id: pinImage + source: "../../../../img/pin.svg" + anchors.left: parent.left + anchors.leftMargin: 3 + anchors.verticalCenter: parent.verticalCenter + + ColorOverlay { + anchors.fill: parent + source: parent + color: Style.current.pinnedMessageBorder + } + } + + StyledText { + text: qsTr("Pinned") + anchors.left: pinImage.right + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: 13 + } + } + } + } ChatReply { id: chatReply + anchors.top: pinnedRectangleLoader.active ? pinnedRectangleLoader.bottom : parent.top + anchors.topMargin: active ? 4 : 0 anchors.left: chatImage.left longReply: active && textFieldImplicitWidth > width container: root.container @@ -87,8 +135,9 @@ Item { active: isMessage && headerRepeatCondition anchors.left: parent.left anchors.leftMargin: Style.current.padding - anchors.top: chatReply.active ? chatReply.bottom : parent.top - anchors.topMargin: Style.current.smallPadding + anchors.top: chatReply.active ? chatReply.bottom : + pinnedRectangleLoader.active ? pinnedRectangleLoader.bottom : parent.top + anchors.topMargin: chatReply.active || pinnedRectangleLoader.active ? 4 : Style.current.smallPadding } UsernameLabel { @@ -245,14 +294,15 @@ Item { } Loader { - active: hasMention + active: hasMention || pinnedMessage height: messageContainer.height anchors.left: messageContainer.left + anchors.top: messageContainer.top sourceComponent: Component { Rectangle { id: mentionBorder - color: Style.current.mentionColor + color: pinnedMessage ? Style.current.pinnedMessageBorder : Style.current.mentionColor width: 2 height: parent.height } @@ -260,7 +310,7 @@ Item { } HoverHandler { - enabled: typeof messageContextMenu !== "undefined" && typeof profilePopupOpened !== "undefined" && !messageContextMenu.opened && !profilePopupOpened && !popupOpened + enabled: forceHoverHandler || (typeof messageContextMenu !== "undefined" && typeof profilePopupOpened !== "undefined" && !messageContextMenu.opened && !profilePopupOpened && !popupOpened) onHoveredChanged: setHovered(messageId, hovered) } diff --git a/ui/app/AppLayouts/Chat/ChatColumn/TopBar.qml b/ui/app/AppLayouts/Chat/ChatColumn/TopBar.qml index a2c5034832..342a4c6074 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn/TopBar.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn/TopBar.qml @@ -64,6 +64,9 @@ Item { } } + PinnedMessagesPopup { + id: pinnedMessagesPopup + } StatusContextMenuButton { id: moreActionsBtn anchors.verticalCenter: parent.verticalCenter diff --git a/ui/app/AppLayouts/Chat/components/MessageContextMenu.qml b/ui/app/AppLayouts/Chat/components/MessageContextMenu.qml index 8f8d9418bf..72f1245d67 100644 --- a/ui/app/AppLayouts/Chat/components/MessageContextMenu.qml +++ b/ui/app/AppLayouts/Chat/components/MessageContextMenu.qml @@ -8,9 +8,12 @@ import "../../../../shared/status" import "./" PopupMenu { + property string messageId property bool isProfile: false property bool isSticker: false property bool emojiOnly: false + property bool hideEmojiPicker: false + property bool pinnedMessage: false property string linkUrls: "" property alias emojiContainer: emojiContainer @@ -53,7 +56,7 @@ PopupMenu { Item { id: emojiContainer - visible: messageContextMenu.emojiOnly || !messageContextMenu.isProfile + visible: !hideEmojiPicker && (messageContextMenu.emojiOnly || !messageContextMenu.isProfile) width: emojiRow.width height: visible ? emojiRow.height : 0 @@ -134,6 +137,37 @@ PopupMenu { visible: !messageContextMenu.emojiOnly } + Action { + id: pinAction + text: pinnedMessage ? qsTr("Unpin") : + qsTr("Pin") + onTriggered: { + if (pinnedMessage) { + chatsModel.unPinMessage(messageId, chatsModel.activeChannel.id) + return + } + + chatsModel.pinMessage(messageId, chatsModel.activeChannel.id) + messageContextMenu.close() + } + icon.source: "../../../img/pin" + icon.width: 16 + icon.height: 16 + enabled: chatsModel.activeChannel.chatType !== Constants.chatTypePublic + } + + Action { + id: copyAction + text: qsTr("Copy") + onTriggered: { + chatsModel.copyToClipboard(messageContextMenu.text) + messageContextMenu.close() + } + icon.source: "../../../../shared/img/copy-to-clipboard-icon" + icon.width: 16 + icon.height: 16 + } + Action { id: copyLinkAction text: qsTr("Copy link") diff --git a/ui/app/AppLayouts/Chat/components/PinnedMessagesPopup.qml b/ui/app/AppLayouts/Chat/components/PinnedMessagesPopup.qml new file mode 100644 index 0000000000..46389020ff --- /dev/null +++ b/ui/app/AppLayouts/Chat/components/PinnedMessagesPopup.qml @@ -0,0 +1,90 @@ +import QtQuick 2.13 +import "../../../../imports" +import "../../../../shared" +import "../../../../shared/status" +import "../ChatColumn" + +ModalPopup { + id: popup + + header: Item { + height: childrenRect.height + width: parent.width + + StyledText { + id: title + text: qsTr("Pinned messages") + anchors.top: parent.top + anchors.left: parent.left + font.bold: true + font.pixelSize: 17 + } + + StyledText { + id: nbPinnedMessages + text: qsTr("%1 message").arg(pinnedMessageListView.count) + anchors.left: parent.left + anchors.top: title.bottom + anchors.topMargin: 2 + font.pixelSize: 15 + color: Style.current.secondaryText + } + + Separator { + anchors.top: nbPinnedMessages.bottom + anchors.topMargin: Style.current.padding + anchors.left: parent.left + anchors.leftMargin: -Style.current.padding + anchors.right: parent.right + anchors.rightMargin: -Style.current.padding + } + } + + ListView { + id: pinnedMessageListView + model: chatsModel.pinnedMessagesList + height: parent.height + anchors.left: parent.left + anchors.leftMargin: -Style.current.padding + anchors.right: parent.right + anchors.rightMargin: -Style.current.padding + clip: true + + delegate: Message { + fromAuthor: model.fromAuthor + chatId: model.chatId + userName: model.userName + alias: model.alias + localName: model.localName + message: model.message + plainText: model.plainText + identicon: model.identicon + isCurrentUser: model.isCurrentUser + timestamp: model.timestamp + sticker: model.sticker + contentType: model.contentType + outgoingStatus: model.outgoingStatus + responseTo: model.responseTo + imageClick: imagePopup.openPopup.bind(imagePopup) + messageId: model.messageId + emojiReactions: model.emojiReactions + linkUrls: model.linkUrls + communityId: model.communityId + hasMention: model.hasMention + stickerPackId: model.stickerPackId + timeout: model.timeout + pinnedMessage: true + forceHoverHandler: true + } + } + + footer: StatusRoundButton { + id: btnBack + anchors.left: parent.left + icon.name: "arrow-right" + icon.width: 20 + icon.height: 16 + rotation: 180 + onClicked: popup.close() + } +} diff --git a/ui/app/img/pin.svg b/ui/app/img/pin.svg new file mode 100644 index 0000000000..83f6a35a7e --- /dev/null +++ b/ui/app/img/pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/imports/Themes/DarkTheme.qml b/ui/imports/Themes/DarkTheme.qml index acc50cdbb9..ced04fe791 100644 --- a/ui/imports/Themes/DarkTheme.qml +++ b/ui/imports/Themes/DarkTheme.qml @@ -90,6 +90,11 @@ Theme { property color contextMenuButtonForegroundColor: midGrey property color contextMenuButtonBackgroundHoverColor: Qt.hsla(white.hslHue, white.hslSaturation, white.hslLightness, 0.05) + property color pinnedMessageBorder: "#FFA67B" + property color pinnedMessageBackground: "#1afe8f59" + property color pinnedMessageBackgroundHovered: "#33fe8f59" + property color pinnedRectangleBackground: "#1afe8f59" + property color roundedButtonForegroundColor: white property color roundedButtonBackgroundColor: buttonBackgroundColor property color roundedButtonSecondaryForegroundColor: black diff --git a/ui/imports/Themes/LightTheme.qml b/ui/imports/Themes/LightTheme.qml index db681abb61..b5c93ade8d 100644 --- a/ui/imports/Themes/LightTheme.qml +++ b/ui/imports/Themes/LightTheme.qml @@ -90,6 +90,11 @@ Theme { property color contextMenuButtonForegroundColor: black property color contextMenuButtonBackgroundHoverColor: Qt.hsla(black.hslHue, black.hslSaturation, black.hslLightness, 0.1) + property color pinnedMessageBorder: "#FE8F59" + property color pinnedMessageBackground: "#1aFF9F0F" + property color pinnedMessageBackgroundHovered: "#33FF9F0F" + property color pinnedRectangleBackground: "#1affffff" + property color roundedButtonForegroundColor: buttonForegroundColor property color roundedButtonBackgroundColor: secondaryBackground property color roundedButtonSecondaryForegroundColor: grey2 diff --git a/ui/imports/Themes/Theme.qml b/ui/imports/Themes/Theme.qml index e14be61e89..dfe597ecb5 100644 --- a/ui/imports/Themes/Theme.qml +++ b/ui/imports/Themes/Theme.qml @@ -72,6 +72,11 @@ QtObject { property color tooltipBackgroundColor property color tooltipForegroundColor + property color pinnedMessageBorder + property color pinnedMessageBackground + property color pinnedMessageBackgroundHovered + property color pinnedRectangleBackground + property int xlPadding: 32 property int bigPadding: 24 property int padding: 16 diff --git a/ui/nim-status-client.pro b/ui/nim-status-client.pro index 08c39fa749..6e428c0f4f 100644 --- a/ui/nim-status-client.pro +++ b/ui/nim-status-client.pro @@ -162,6 +162,7 @@ DISTFILES += \ app/AppLayouts/Chat/components/EmojiReaction.qml \ app/AppLayouts/Chat/components/LeftTabBottomButtons.qml \ app/AppLayouts/Chat/components/NoFriendsRectangle.qml \ + app/AppLayouts/Chat/components/PinnedMessagesPopup.qml \ app/AppLayouts/Chat/components/ProfilePopup.qml \ app/AppLayouts/Chat/components/EmojiSection.qml \ app/AppLayouts/Chat/components/InviteFriendsPopup.qml \ diff --git a/ui/shared/status/StatusChatInfo.qml b/ui/shared/status/StatusChatInfo.qml index 2a6e16ae61..5304f6aa5d 100644 --- a/ui/shared/status/StatusChatInfo.qml +++ b/ui/shared/status/StatusChatInfo.qml @@ -137,6 +137,59 @@ Item { font.pixelSize: 12 anchors.top: chatName.bottom } + + Item { + property bool hovered: false + + id: pinnedMessagesGroup + visible: chatType !== Constants.chatTypePublic && chatsModel.pinnedMessagesList.count > 0 + width: childrenRect.width + height: vertiSep.height + anchors.left: chatInfo.right + anchors.leftMargin: 4 + anchors.verticalCenter: chatInfo.verticalCenter + + Rectangle { + id: vertiSep + height: 12 + width: 1 + color: Style.current.border + } + + SVGImage { + id: pinImg + source: "../../app/img/pin.svg" + height: 14 + width: 14 + anchors.left: vertiSep.right + anchors.leftMargin: 4 + anchors.verticalCenter: vertiSep.verticalCenter + + ColorOverlay { + anchors.fill: parent + source: parent + color: pinnedMessagesGroup.hovered ? Style.current.textColor : Style.current.secondaryText + } + } + + StyledText { + id: nbPinnedMessagesText + color: pinnedMessagesGroup.hovered ? Style.current.textColor : Style.current.secondaryText + text: chatsModel.pinnedMessagesList.count + font.pixelSize: 12 + font.underline: pinnedMessagesGroup.hovered + anchors.left: pinImg.right + anchors.verticalCenter: vertiSep.verticalCenter + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: pinnedMessagesGroup.hovered = true + onExited: pinnedMessagesGroup.hovered = false + onClicked: pinnedMessagesPopup.open() + } + } } } diff --git a/vendor/status-go b/vendor/status-go index 71f66f6806..e9a42bfa2b 160000 --- a/vendor/status-go +++ b/vendor/status-go @@ -1 +1 @@ -Subproject commit 71f66f68064e9897cd17b6bcecba426a91405034 +Subproject commit e9a42bfa2be93d9ee09a82e0893d8019c4bcdd3d