From 961139e7785153125735f92561948186c22464b3 Mon Sep 17 00:00:00 2001 From: Richard Ramos Date: Thu, 25 Jun 2020 09:26:58 -0400 Subject: [PATCH] feat: start chats with ENS usernames --- src/app/chat/view.nim | 7 ++ src/nim_status_client.nim | 5 +- src/status/contacts.nim | 8 +- src/status/ens.nim | 66 +++++++++++++ ui/app/AppLayouts/Chat/components/Contact.qml | 23 ++++- .../Chat/components/PrivateChatPopup.qml | 99 ++++++++++++++----- ui/app/img/list-next.svg | 3 + ui/imports/Utils.qml | 11 +++ 8 files changed, 195 insertions(+), 27 deletions(-) create mode 100644 ui/app/img/list-next.svg diff --git a/src/app/chat/view.nim b/src/app/chat/view.nim index 786d347b8f..114a875a6d 100644 --- a/src/app/chat/view.nim +++ b/src/app/chat/view.nim @@ -3,6 +3,7 @@ import NimQml, Tables, json, sequtils, chronicles import ../../status/status import ../../status/chat as status_chat import ../../status/contacts as status_contacts +import ../../status/ens as status_ens import ../../status/chat/[chat, message] import ../../status/libstatus/types import ../../status/profile/profile @@ -228,3 +229,9 @@ QtObject: proc isEnsVerified*(self: ChatsView, id: string): bool {.slot.} = if id == "": return false self.status.contacts.getContactByID(id).ensVerified + + proc resolveENS*(self: ChatsView, ens: string): string {.slot.} = + result = status_ens.pubkey(ens) + + proc formatENSUsername*(self: ChatsView, username: string): string {.slot.} = + result = status_ens.addDomain(username) diff --git a/src/nim_status_client.nim b/src/nim_status_client.nim index 5012dcffd0..0822046760 100644 --- a/src/nim_status_client.nim +++ b/src/nim_status_client.nim @@ -47,9 +47,10 @@ proc mainProc() = status.events.once("login") do(a: Args): var args = AccountArgs(a) status.startMessenger() - chat.init() - wallet.init() profile.init(args.account) + wallet.init() + chat.init() + var login = login.newController(status) var onboarding = onboarding.newController(status) diff --git a/src/status/contacts.nim b/src/status/contacts.nim index 496ab0fcf4..95f09acbc1 100644 --- a/src/status/contacts.nim +++ b/src/status/contacts.nim @@ -16,7 +16,12 @@ proc newContactModel*(events: EventEmitter): ContactModel = proc getContactByID*(self: ContactModel, id: string): Profile = let response = status_contacts.getContactByID(id) - toProfileModel(parseJSON($response)["result"]) + # TODO: change to options + let responseResult = parseJSON($response)["result"] + if responseResult.kind == JNull: + result = nil + else: + result = toProfileModel(parseJSON($response)["result"]) proc blockContact*(self: ContactModel, id: string): string = var contact = self.getContactByID(id) @@ -41,4 +46,5 @@ proc removeContact*(self: ContactModel, id: string) = proc isAdded*(self: ContactModel, id: string): bool = var contact = self.getContactByID(id) + if contact.isNil: return false contact.systemTags.contains(":contact/added") diff --git a/src/status/ens.nim b/src/status/ens.nim index fd1c985924..e344f09634 100644 --- a/src/status/ens.nim +++ b/src/status/ens.nim @@ -1,5 +1,15 @@ import strutils import profile/profile +import nimcrypto +import strmisc +import json +import strformat +import libstatus/core +import stew/byteutils +import sequtils +import unicode +import algorithm +import libstatus/settings as status_settings let domain* = ".stateofus.eth" @@ -12,8 +22,64 @@ proc userName*(ensName: string, removeSuffix: bool = false): string = else: result = ensName +proc addDomain*(username: string): string = + if username.endsWith(".eth"): + return username + else: + return username & domain + proc userNameOrAlias*(contact: Profile): string = if(contact.ensName != "" and contact.ensVerified): result = "@" & userName(contact.ensName, true) else: result = contact.alias + +proc namehash*(ensName:string): string = + let name = ensName.toLower() + var node:array[32, byte] + + node.fill(0) + var parts = name.split(".") + for i in countdown(parts.len - 1,0): + let elem = keccak_256.digest(parts[i]).data + var concatArrays: array[64, byte] + concatArrays[0..31] = node + concatArrays[32..63] = elem + node = keccak_256.digest(concatArrays).data + + result = "0x" & node.toHex() + +const registry = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" +const resolver_signature = "0x0178b8bf" +proc resolver*(usernameHash: string): string = + let payload = %* [{ + "to": registry, + "from": "0x0000000000000000000000000000000000000000", + "data": fmt"{resolver_signature}{userNameHash}" + }, "latest"] + let response = callPrivateRPC("eth_call", payload) + # TODO: error handling + var resolverAddr = response.parseJson["result"].getStr + resolverAddr.removePrefix("0x000000000000000000000000") + result = "0x" & resolverAddr + + +const pubkey_signature = "0xc8690233" # pubkey(bytes32 node) +proc pubkey*(username: string): string = + var userNameHash = namehash(addDomain(username)) + userNameHash.removePrefix("0x") + let ensResolver = resolver(userNameHash) + echo ensResolver + let payload = %* [{ + "to": ensResolver, + "from": "0x0000000000000000000000000000000000000000", + "data": fmt"{pubkey_signature}{userNameHash}" + }, "latest"] + let response = callPrivateRPC("eth_call", payload) + # TODO: error handling + var pubkey = response.parseJson["result"].getStr + if pubkey == "0x" or pubkey == "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000": + result = "" + else: + pubkey.removePrefix("0x") + result = "0x04" & pubkey diff --git a/ui/app/AppLayouts/Chat/components/Contact.qml b/ui/app/AppLayouts/Chat/components/Contact.qml index 8759d7cc5a..7876c18d5a 100644 --- a/ui/app/AppLayouts/Chat/components/Contact.qml +++ b/ui/app/AppLayouts/Chat/components/Contact.qml @@ -17,6 +17,7 @@ Rectangle { property bool showCheckbox: true property bool isChecked: false + property bool showListSelector: false property var onItemChecked: (function(pubKey, itemChecked) { console.log(pubKey, itemChecked) }) @@ -47,9 +48,29 @@ Rectangle { anchors.leftMargin: Theme.padding } + SVGImage { + id: image + visible: showListSelector && !showCheckbox + height: 24 + width: 24 + anchors.top: accountImage.top + anchors.topMargin: 6 + anchors.right: parent.right + anchors.rightMargin: Theme.padding + fillMode: Image.PreserveAspectFit + source: "../../../img/list-next.svg" + MouseArea { + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + onClicked: { + onItemChecked(pubKey, isChecked) + } + } + } + CheckBox { id: assetCheck - visible: showCheckbox && !isUser + visible: !showListSelector && showCheckbox && !isUser anchors.top: accountImage.top anchors.topMargin: 6 anchors.right: parent.right diff --git a/ui/app/AppLayouts/Chat/components/PrivateChatPopup.qml b/ui/app/AppLayouts/Chat/components/PrivateChatPopup.qml index 3969abdee0..e179f2fecc 100644 --- a/ui/app/AppLayouts/Chat/components/PrivateChatPopup.qml +++ b/ui/app/AppLayouts/Chat/components/PrivateChatPopup.qml @@ -3,34 +3,47 @@ import QtQuick.Controls 2.13 import QtQuick.Layouts 1.13 import "../../../../imports" import "../../../../shared" -import "../../Profile/Sections/Contacts/" import "./" ModalPopup { property string validationError: "" + property string pubKey : ""; + property string ensUsername : ""; + function validate() { - // TODO change this when we support ENS names - if (!Utils.isChatKey(chatKey.text)) { - validationError = "This needs to be a valid chat key" + if (!Utils.isChatKey(chatKey.text) && !Utils.isValidETHNamePrefix(chatKey.text)) { + validationError = "This needs to be a valid chat key or ENS username" + pubKey = ""; + ensUsername = ""; } else { validationError = "" } return validationError === "" } - function doJoin() { - if (chatKey.text !== "") { - if (!validate()) { - return - } + function onKeyReleased(){ + ensUsername.text = ""; + if (!validate()) return; + + chatKey.text = chatKey.text.trim(); - chatsModel.joinChat(chatKey.text, Constants.chatTypeOneToOne); - } else if (contactListView.selectedContact.checked) { - chatsModel.joinChat(contactListView.selectedContact.parent.address, Constants.chatTypeOneToOne); - } else { + if(Utils.isChatKey(chatKey.text)){ + pubKey = chatKey.text; return; } + + pubKey = chatsModel.resolveENS(chatKey.text) + if(pubKey == ""){ + ensUsername.text = qsTr("User not found"); + } else { + ensUsername.text = chatsModel.formatENSUsername(chatKey.text) + " • " + Utils.compactAddress(pubKey, 4) + } + } + + function doJoin() { + if (!validate() || pubKey.trim() === "") return; + chatsModel.joinChat(pubKey, Constants.chatTypeOneToOne); popup.close(); } @@ -39,10 +52,9 @@ ModalPopup { onOpened: { chatKey.text = ""; + pubKey = ""; + ensUsername = ""; chatKey.forceActiveFocus(Qt.MouseFocusReason) - if (contactListView.selectedContact) { - contactListView.selectedContact.checked = false - } } Input { @@ -51,15 +63,56 @@ ModalPopup { Keys.onEnterPressed: doJoin() Keys.onReturnPressed: doJoin() validationError: popup.validationError - textField.onEditingFinished: { - validate() + Keys.onReleased: { + onKeyReleased(); } } + + Text { + id: ensUsername + anchors.top: chatKey.bottom + anchors.topMargin: Theme.padding + color: Theme.darkGrey + font.pixelSize: 12 + } - ContactList { - id: contactListView - contacts: profileModel.contactList - selectable: true + Item { + anchors.top: ensUsername.bottom + anchors.topMargin: 32 + anchors.fill: parent + + ScrollView { + anchors.fill: parent + anchors.topMargin: 50 + anchors.top: searchBox.bottom + Layout.fillWidth: true + Layout.fillHeight: true + + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: groupMembers.contentHeight > groupMembers.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + + ListView { + anchors.fill: parent + spacing: 0 + clip: true + id: contactListView + model: profileModel.contactList + delegate: Contact { + showCheckbox: false + pubKey: model.pubKey + isContact: model.isContact + isUser: model.isUser + name: model.name + address: model.address + identicon: model.identicon + showListSelector: true + onItemChecked: function(pubKey, itemChecked){ + chatsModel.joinChat(pubKey, Constants.chatTypeOneToOne); + popup.close() + } + } + } + } } footer: Button { @@ -68,7 +121,7 @@ ModalPopup { anchors.bottom: parent.bottom anchors.right: parent.right SVGImage { - source: chatKey.text == "" ? "../../../img/arrow-button-inactive.svg" : "../../../img/arrow-btn-active.svg" + source: pubKey === "" ? "../../../img/arrow-button-inactive.svg" : "../../../img/arrow-btn-active.svg" width: 50 height: 50 } diff --git a/ui/app/img/list-next.svg b/ui/app/img/list-next.svg new file mode 100644 index 0000000000..cd30fa38c3 --- /dev/null +++ b/ui/app/img/list-next.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/imports/Utils.qml b/ui/imports/Utils.qml index 58f1eca0a0..061b801c88 100644 --- a/ui/imports/Utils.qml +++ b/ui/imports/Utils.qml @@ -15,6 +15,10 @@ QtObject { return startsWith0x(value) && isHex(value) && value.length === 132 } + function isValidETHNamePrefix(value) { + return !(value.trim() === "" || value.endsWith(".") || value.indexOf("..") > -1) + } + function isAddress(value) { return startsWith0x(value) && isHex(value) && value.length === 42 } @@ -28,4 +32,11 @@ QtObject { // Do we support other length than 12? return value.split(/\s|,/).length === 12 } + + function compactAddress(addr, numberOfChars) { + if(addr.length <= 5 + (numberOfChars * 2)){ // 5 represents these chars 0x... + return addr; + } + return addr.substring(0, 2 + numberOfChars) + "..." + addr.substring(addr.length - numberOfChars); + } }