diff --git a/src/app/chat/view.nim b/src/app/chat/view.nim index 27fc5df86a..20eb82d1af 100644 --- a/src/app/chat/view.nim +++ b/src/app/chat/view.nim @@ -1,4 +1,4 @@ -import NimQml, Tables, json, sequtils, chronicles, times, strutils +import NimQml, Tables, json, sequtils, chronicles, times, re, sugar, strutils import ../../status/status import ../../status/accounts as status_accounts @@ -11,7 +11,7 @@ import ../../status/profile/profile import ../../status/threads -import views/channels_list, views/message_list, views/chat_item, views/sticker_pack_list, views/sticker_list +import views/channels_list, views/message_list, views/chat_item, views/sticker_pack_list, views/sticker_list, views/suggestions_list logScope: topics = "chats-view" @@ -21,6 +21,7 @@ QtObject: ChatsView* = ref object of QAbstractListModel status: Status chats*: ChannelsList + currentSuggestions*: SuggestionsList callResult: string messageList: Table[string, ChatMessageList] activeChannel*: ChatItemView @@ -35,6 +36,7 @@ QtObject: proc delete(self: ChatsView) = self.chats.delete self.activeChannel.delete + self.currentSuggestions.delete for msg in self.messageList.values: msg.delete self.messageList = initTable[string, ChatMessageList]() @@ -47,6 +49,7 @@ QtObject: result.connected = false result.chats = newChannelsList(status) result.activeChannel = newChatItemView(status) + result.currentSuggestions = newSuggestionsList() result.messageList = initTable[string, ChatMessageList]() result.stickerPacks = newStickerPackList() result.recentStickers = newStickerList() @@ -70,8 +73,30 @@ QtObject: proc getChannelColor*(self: ChatsView, channel: string): string {.slot.} = self.chats.getChannelColor(channel) + proc replaceMentionsWithPubKeys(self: ChatsView, mentions: seq[string], contacts: seq[Profile], message: string, predicate: proc (contact: Profile): string): string = + result = message + for mention in mentions: + let matches = contacts.filter(c => "@" & predicate(c) == mention).map(c => c.address) + if matches.len > 0: + let pubKey = matches[0] + result = message.replace(mention, "@" & pubKey) + proc sendMessage*(self: ChatsView, message: string, replyTo: string) {.slot.} = - self.status.chat.sendMessage(self.activeChannel.id, message, replyTo) + let aliasPattern = re(r"(@[A-z][a-z]* [A-z][a-z]* [A-z][a-z]*)", flags = {reStudy, reIgnoreCase}) + let ensPattern = re(r"(@\w*(?=\.stateofus\.eth))", flags = {reStudy, reIgnoreCase}) + let namePattern = re(r"(@\w*)", flags = {reStudy, reIgnoreCase}) + + let contacts = self.status.contacts.getContacts() + + let aliasMentions = findAll(message, aliasPattern) + let ensMentions = findAll(message, ensPattern) + let nameMentions = findAll(message, namePattern) + + var m = self.replaceMentionsWithPubKeys(aliasMentions, contacts, message, (c => c.alias)) + m = self.replaceMentionsWithPubKeys(ensMentions, contacts, m, (c => c.ensName)) + m = self.replaceMentionsWithPubKeys(nameMentions, contacts, m, (c => c.ensName.split(".")[0])) + + self.status.chat.sendMessage(self.activeChannel.id, m, replyTo) proc activeChannelChanged*(self: ChatsView) {.signal.} @@ -93,6 +118,7 @@ QtObject: self.activeChannel.setChatItem(selectedChannel) self.status.chat.setActiveChannel(selectedChannel.id) + self.currentSuggestions.setNewData(self.status.contacts.getContacts()) self.activeChannelChanged() proc getActiveChannelIdx(self: ChatsView): QVariant {.slot.} = @@ -122,6 +148,7 @@ QtObject: proc setActiveChannel*(self: ChatsView, channel: string) {.slot.} = if(channel == ""): return self.activeChannel.setChatItem(self.chats.getChannel(self.chats.chats.findIndexById(channel))) + self.currentSuggestions.setNewData(self.status.contacts.getContacts()) self.activeChannelChanged() proc getActiveChannel*(self: ChatsView): QVariant {.slot.} = @@ -132,6 +159,13 @@ QtObject: write = setActiveChannel notify = activeChannelChanged + + proc getCurrentSuggestions(self: ChatsView): QVariant {.slot.} = + return newQVariant(self.currentSuggestions) + + QtProperty[QVariant] suggestionList: + read = getCurrentSuggestions + proc upsertChannel(self: ChatsView, channel: string) = if not self.messageList.hasKey(channel): self.messageList[channel] = newChatMessageList(channel, self.status) @@ -222,6 +256,7 @@ QtObject: self.chats.updateChat(chat) if(self.activeChannel.id == chat.id): self.activeChannel.setChatItem(chat) + self.currentSuggestions.setNewData(self.status.contacts.getContacts()) self.activeChannelChanged() proc renameGroup*(self: ChatsView, newName: string) {.slot.} = diff --git a/src/app/chat/views/suggestions_list.nim b/src/app/chat/views/suggestions_list.nim new file mode 100644 index 0000000000..6d623525f2 --- /dev/null +++ b/src/app/chat/views/suggestions_list.nim @@ -0,0 +1,73 @@ +import NimQml, tables +import ../../../status/profile/profile + +type + SuggestionRoles {.pure.} = enum + Alias = UserRole + 1, + Identicon = UserRole + 2, + Address = UserRole + 3, + EnsName = UserRole + 4, + EnsVerified = UserRole + 5 + +QtObject: + type SuggestionsList* = ref object of QAbstractListModel + suggestions*: seq[Profile] + + proc setup(self: SuggestionsList) = self.QAbstractListModel.setup + + proc delete(self: SuggestionsList) = + self.suggestions = @[] + self.QAbstractListModel.delete + + proc newSuggestionsList*(): SuggestionsList = + new(result, delete) + result.suggestions = @[] + result.setup + + proc rowData(self: SuggestionsList, index: int, column: string): string {.slot.} = + if (index >= self.suggestions.len - 1): + return + let suggestion = self.suggestions[index] + case column: + of "alias": result = suggestion.alias + of "ensName": result = suggestion.ensName + of "address": result = suggestion.address + of "identicon": result = suggestion.identicon + + method rowCount(self: SuggestionsList, index: QModelIndex = nil): int = + return self.suggestions.len + + method data(self: SuggestionsList, index: QModelIndex, role: int): QVariant = + if not index.isValid: + return + if index.row < 0 or index.row >= self.suggestions.len: + return + let suggestion = self.suggestions[index.row] + let suggestionRole = role.SuggestionRoles + case suggestionRole: + of SuggestionRoles.Alias: result = newQVariant(suggestion.alias) + of SuggestionRoles.Identicon: result = newQVariant(suggestion.identicon) + of SuggestionRoles.Address: result = newQVariant(suggestion.address) + of SuggestionRoles.EnsName: result = newQVariant(suggestion.ensName) + of SuggestionRoles.EnsVerified: result = newQVariant(suggestion.ensVerified) + + method roleNames(self: SuggestionsList): Table[int, string] = + { SuggestionRoles.Alias.int:"alias", + SuggestionRoles.Identicon.int:"identicon", + SuggestionRoles.Address.int:"address", + SuggestionRoles.EnsName.int:"ensName", + SuggestionRoles.EnsVerified.int:"ensVerified" }.toTable + + proc addSuggestionToList*(self: SuggestionsList, profile: Profile) = + self.beginInsertRows(newQModelIndex(), self.suggestions.len, self.suggestions.len) + self.suggestions.add(profile) + self.endInsertRows() + + proc setNewData*(self: SuggestionsList, suggestionsList: seq[Profile]) = + self.beginResetModel() + self.suggestions = suggestionsList + self.endResetModel() + + proc forceUpdate*(self: SuggestionsList) = + self.beginResetModel() + self.endResetModel() diff --git a/ui/app/AppLayouts/Chat/ChatColumn.qml b/ui/app/AppLayouts/Chat/ChatColumn.qml index 524a8deb2e..d27385afe9 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn.qml @@ -108,6 +108,57 @@ StackLayout { } } + ListModel { + id: suggestions + } + + Connections { + target: chatsModel + onActiveChannelChanged: { + suggestions.clear() + for (let i = 0; i < chatsModel.suggestionList.rowCount(); i++) { + suggestions.append({ + alias: chatsModel.suggestionList.rowData(i, "alias"), + ensName: chatsModel.suggestionList.rowData(i, "ensName"), + address: chatsModel.suggestionList.rowData(i, "address"), + identicon: chatsModel.suggestionList.rowData(i, "identicon"), + ensVerified: chatsModel.suggestionList.rowData(i, "ensVerified") + }); + } + } + } + + SuggestionBox { + id: suggestionsBox + model: suggestions + width: chatContainer.width + anchors.bottom: inputArea.top + anchors.left: inputArea.left + filter: chatInput.textInput.text + property: "ensName, alias" + onItemSelected: function (item) { + let currentText = chatInput.textInput.text + let lastAt = currentText.lastIndexOf("@") + let aliasName = item[suggestionsBox.property.split(",").map(p => p.trim()).find(p => !!item[p])] + let nameLen = aliasName.length + 2 // We're doing a +2 here because of the `@` and the trailing whitespace + let position = 0; + let text = "" + + if (currentText.length == 1) { + position = nameLen + text = "@" + aliasName + " " + } else { + let left = currentText.slice(0, lastAt) + position = left.length + nameLen + text = left + "@" + aliasName + " " + } + + chatInput.textInput.text = text + chatInput.textInput.cursorPosition = position + suggestionsBox.suggestionsModel.clear() + } + } + Rectangle { id: inputArea color: Style.current.background @@ -126,6 +177,7 @@ StackLayout { } ChatInput { + id: chatInput height: 40 anchors.top: !isReply ? inputArea.top : replyAreaContainer.bottom anchors.topMargin: 4 diff --git a/ui/app/AppLayouts/Chat/ChatColumn/ChatInput.qml b/ui/app/AppLayouts/Chat/ChatColumn/ChatInput.qml index 736da43bd7..da924a7eee 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn/ChatInput.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn/ChatInput.qml @@ -8,6 +8,7 @@ import "../../../../imports" Rectangle { id: rectangle + property alias textInput: txtData border.width: 0 height: 52 color: Style.current.transparent @@ -31,12 +32,14 @@ Rectangle { } function onEnter(event){ + if (event.modifiers === Qt.NoModifier && (event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if(txtData.text.trim().length > 0){ let msg = interpretMessage(txtData.text.trim()) chatsModel.sendMessage(msg, chatColumn.isReply ? SelectedMessage.messageId : ""); txtData.text = ""; - event.accepted = true; + event.accepted = true sendMessageSound.stop() Qt.callLater(sendMessageSound.play); } diff --git a/ui/app/AppLayouts/Chat/ChatColumn/SuggestionBox.qml b/ui/app/AppLayouts/Chat/ChatColumn/SuggestionBox.qml new file mode 100644 index 0000000000..da9f8424ed --- /dev/null +++ b/ui/app/AppLayouts/Chat/ChatColumn/SuggestionBox.qml @@ -0,0 +1,137 @@ +/* + Copyright (C) 2011 Jocelyn Turcotte + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this program; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +import QtQuick 2.13 +import QtQuick.Controls 2.13 +import QtGraphicalEffects 1.13 +import "../../../../imports" +import "../../../../shared" + +Rectangle { + id: container + + property QtObject model: undefined + property Item delegate + property alias suggestionsModel: filterItem.model + property alias filter: filterItem.filter + property alias property: filterItem.property + signal itemSelected(var item) + + + z: parent.z + 100 + visible: filter.length > 0 && suggestionsModel.count > 0 + height: visible ? childrenRect.height + (Style.current.padding * 2) : 0 + opacity: visible ? 1.0 : 0 + Behavior on opacity { + NumberAnimation { } + } + + // --- defaults + color: Style.current.white2 + radius: 16 + layer.enabled: true + layer.effect: DropShadow{ + width: container.width + height: container.height + x: container.x + y: container.y + 10 + visible: container.visible + source: container + horizontalOffset: 0 + verticalOffset: 2 + radius: 10 + samples: 15 + color: "#22000000" + } + + SuggestionFilter { + id: filterItem + sourceModel: container.model + } + + + ScrollView { + id: popup + height: items.height >= 400 ? 400 : items.height + width: parent.width + anchors.centerIn: parent + clip: true + + property int selectedIndex + property var selectedItem: selectedIndex == -1 ? null : model[selectedIndex] + signal suggestionClicked(var item) + ScrollBar.vertical.policy: items.contentHeight > items.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + Column { + id: items + clip: true + height: childrenRect.height + width: parent.width + Repeater { + id: repeater + model: container.suggestionsModel + delegate: Rectangle { + id: delegateItem + property var suggestion: model + property bool hovered + + height: 50 + width: container.width + color: hovered ? Style.current.blue : "white" + + Identicon { + id: accountImage + anchors.left: parent.left + anchors.leftMargin: Style.current.smallPadding + anchors.verticalCenter: parent.verticalCenter + source: suggestion.identicon + } + + Text { + id: textComponent + color: delegateItem.hovered ? Style.current.white : Style.current.black + text: suggestion[container.property.split(",").map(p => p.trim()).find(p => !!suggestion[p])] + width: parent.width + height: parent.height + anchors.left: accountImage.right + anchors.leftMargin: Style.current.padding + verticalAlignment: Text.AlignVCenter + font.pixelSize: 15 + } + MouseArea { + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + hoverEnabled: true + onEntered: { + delegateItem.hovered = true + } + onExited: { + delegateItem.hovered = false + } + onClicked: container.itemSelected(delegateItem.suggestion) + } + } + } + } + } +} + + + diff --git a/ui/app/AppLayouts/Chat/ChatColumn/SuggestionFilter.qml b/ui/app/AppLayouts/Chat/ChatColumn/SuggestionFilter.qml new file mode 100644 index 0000000000..f135076011 --- /dev/null +++ b/ui/app/AppLayouts/Chat/ChatColumn/SuggestionFilter.qml @@ -0,0 +1,79 @@ +import QtQuick 2.13 + +Item { + id: component + property alias model: filterModel + + property QtObject sourceModel: undefined + property string filter: "" + property string property: "" + + Connections { + onFilterChanged: invalidateFilter() + onPropertyChanged: invalidateFilter() + onSourceModelChanged: invalidateFilter() + } + + Component.onCompleted: invalidateFilter() + + ListModel { + id: filterModel + } + + function invalidateFilter() { + if (sourceModel === undefined) + return; + + filterModel.clear(); + + if (!isFilteringPropertyOk()) + return + + var length = sourceModel.count + for (var i = 0; i < length; ++i) { + var item = sourceModel.get(i); + if (isAcceptedItem(item)) { + filterModel.append(item) + } + } + } + + + function isAcceptedItem(item) { + let properties = this.property.split(",") + .map(p => p.trim()) + .filter(p => !!item[p]) + + if (properties.length == 0) { + return false + } + + if (this.filter.endsWith("@")) { + return true + } + + let lastAt = this.filter.lastIndexOf("@") + + if (lastAt == -1) { + return false + } + + let filterWithoutAt = this.filter.substring(lastAt+1) + + if (filterWithoutAt == "") { + return true + } + + return !properties.every(p => item[p].toLowerCase().match(filterWithoutAt.toLowerCase()) == null) + } + + function isFilteringPropertyOk() { + if(this.property === undefined || this.property === "") { + return false + } + return true + } +} + + + diff --git a/ui/app/AppLayouts/Chat/qmldir b/ui/app/AppLayouts/Chat/qmldir index 13a4a747c5..aae7d508e4 100644 --- a/ui/app/AppLayouts/Chat/qmldir +++ b/ui/app/AppLayouts/Chat/qmldir @@ -1,2 +1,4 @@ ContactsColumn 1.0 ContactsColumn.qml ChatColumn 1.0 ChatColumn.qml +SuggestionBox 1.0 SuggestionBox.qml +SuggestionFilter 1.0 SuggestionFilter.qml