feat(chat): implement mention auto complete

Closes #515
This commit is contained in:
Pascal Precht 2020-07-14 13:40:58 +02:00 committed by Iuri Matias
parent 00f10f600a
commit 1e39cf4821
7 changed files with 385 additions and 4 deletions

View File

@ -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.} =

View File

@ -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()

View File

@ -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

View File

@ -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);
}

View File

@ -0,0 +1,137 @@
/*
Copyright (C) 2011 Jocelyn Turcotte <turcotte.j@gmail.com>
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)
}
}
}
}
}
}

View File

@ -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
}
}

View File

@ -1,2 +1,4 @@
ContactsColumn 1.0 ContactsColumn.qml
ChatColumn 1.0 ChatColumn.qml
SuggestionBox 1.0 SuggestionBox.qml
SuggestionFilter 1.0 SuggestionFilter.qml