From 89eb6b11fe4e97bf433e15e238a32942531a9cba Mon Sep 17 00:00:00 2001 From: prolic Date: Fri, 11 Oct 2024 02:05:18 -0300 Subject: [PATCH] Improve UI and add search functionality - Refactor App.qml to use a Loader for screens - Update HomeScreen.ui.qml with improved layout and search functionality - Rename and update Profile components for better reusability - Add search functionality in Futr.hs - Implement Bech32 encoding/decoding in new Nostr.Bech32 module - Update AppState to Types and add currentProfile - Enhance UI.hs with new profile properties and search method --- futr.cabal | 3 +- resources/qml/content/App.qml | 51 +-- resources/qml/content/HomeScreen.ui.qml | 254 ++++++------ resources/qml/content/KeyMgmtScreen.ui.qml | 376 ++++++++++-------- ...ditMyProfile.ui.qml => EditProfile.ui.qml} | 13 +- .../qml/content/Profile/MyProfile.ui.qml | 176 -------- resources/qml/content/Profile/Profile.ui.qml | 168 ++++++++ resources/qml/content/Profile/qmldir | 4 +- src/Futr.hs | 136 ++++++- src/Main.hs | 6 +- src/Nostr/Bech32.hs | 201 ++++++++++ src/Nostr/Effects/RelayPool.hs | 2 +- src/Nostr/Effects/Subscription.hs | 76 ++++ src/Nostr/Keys.hs | 44 -- src/Nostr/Types.hs | 119 ++++-- src/Presentation/KeyMgmt.hs | 1 + src/{AppState.hs => Types.hs} | 6 +- src/UI.hs | 75 +++- 18 files changed, 1075 insertions(+), 636 deletions(-) rename resources/qml/content/Profile/{EditMyProfile.ui.qml => EditProfile.ui.qml} (95%) delete mode 100644 resources/qml/content/Profile/MyProfile.ui.qml create mode 100644 resources/qml/content/Profile/Profile.ui.qml create mode 100644 src/Nostr/Bech32.hs create mode 100644 src/Nostr/Effects/Subscription.hs rename src/{AppState.hs => Types.hs} (93%) diff --git a/futr.cabal b/futr.cabal index 653c60c..ce43ec1 100755 --- a/futr.cabal +++ b/futr.cabal @@ -29,9 +29,9 @@ executable futr hs-source-dirs: src other-modules: - AppState EffectfulQML Futr + Nostr.Bech32 Nostr.Effects.CurrentTime Nostr.Effects.IDGen Nostr.Effects.Logging @@ -44,6 +44,7 @@ executable futr Nostr.Profile Nostr.Types Presentation.KeyMgmt + Types TimeFormatter UI diff --git a/resources/qml/content/App.qml b/resources/qml/content/App.qml index 2d136a4..bc8174c 100644 --- a/resources/qml/content/App.qml +++ b/resources/qml/content/App.qml @@ -26,48 +26,19 @@ ApplicationWindow { id: clipboard } - Rectangle { - width: 900 - height: parent.height - anchors.horizontalCenter: parent.horizontalCenter - anchors.margins: 10 - - Rectangle { - width: 1 - height: parent.height - color: Material.dividerColor - anchors.left: parent.left - } - - Rectangle { - width: 1 - height: parent.height - color: Material.dividerColor - anchors.right: parent.right - } - - Rectangle { - anchors.fill: parent - anchors.leftMargin: 1 - anchors.rightMargin: 1 - color: Material.backgroundColor - - KeyMgmtScreen { - anchors.margins: 10 - anchors.fill: parent - visible: currentScreen == "KeyMgmt" - } - - Loader { - id: myHomeScreenLoader - active: currentScreen == "Home" - anchors.fill: parent - sourceComponent: HomeScreen { - anchors.margins: 10 - anchors.fill: parent - } + Loader { + id: screenLoader + anchors.fill: parent + source: { + if (currentScreen === "Home") { + return "HomeScreen.ui.qml"; + } else if (currentScreen === "KeyMgmt") { + return "KeyMgmtScreen.ui.qml"; + } else { + return ""; } } + active: currentScreen === "Home" || currentScreen === "KeyMgmt" } Button { diff --git a/resources/qml/content/HomeScreen.ui.qml b/resources/qml/content/HomeScreen.ui.qml index cde5753..fc8a924 100644 --- a/resources/qml/content/HomeScreen.ui.qml +++ b/resources/qml/content/HomeScreen.ui.qml @@ -12,11 +12,22 @@ Item { width: parent.width height: parent.height + Component.onCompleted: { + setCurrentProfile(mynpub) + profileLoader.setSource( + "Profile/Profile.ui.qml", + { + "profileData": currentProfile, + "npub": mynpub + } + ) + } + ColumnLayout { anchors.fill: parent spacing: 10 - // Top row with profile button + // Top row with profile button and search Item { Layout.fillWidth: true height: 80 @@ -24,6 +35,7 @@ Item { RoundButton { id: profileButton anchors.right: parent.right + anchors.rightMargin: 100 anchors.verticalCenter: parent.verticalCenter width: 75 height: 75 @@ -54,22 +66,14 @@ Item { id: profileMenu y: profileButton.height - onClosed: { - if (!profileLoader.item || !profileLoader.item.visible) { - profileCard.visible = false - profileLoader.source = "" - } - } - MenuItem { text: qsTr("My Profile") onTriggered: { - var profile = JSON.parse(getProfile(mynpub)) + setCurrentProfile(mynpub) profileLoader.setSource( - "Profile/MyProfile.ui.qml", - { "profileData": profile } + "Profile/Profile.ui.qml", + { "profileData": currentProfile, "npub": mynpub } ) - profileCard.visible = true profileMenu.close() } } @@ -92,39 +96,32 @@ Item { } } } - } - // Search row - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: 10 - - TextField { - id: searchInput - placeholderText: qsTr("Enter npub to search") - Layout.preferredWidth: 300 - } + // Search row + RowLayout { + anchors.centerIn: parent + spacing: 10 - Button { - text: qsTr("Search") - onClicked: { - var npub = searchInput.text.trim() - if (npub.length > 0) { - profileLoader.setSource( - "Profile/ViewProfile.ui.qml", - { "npub": npub } - ) - } + TextField { + id: searchInput + placeholderText: qsTr("Enter npub or nprofile") + Layout.preferredWidth: 300 + onAccepted: searchButton.clicked() } - } - Button { - text: qsTr("Follow") - onClicked: { - var npub = searchInput.text.trim() - if (npub.length > 0) { - follow(npub) - searchInput.text = "" + Button { + id: searchButton + text: qsTr("Search") + onClicked: { + var input = searchInput.text.trim() + var result = JSON.parse(search(input)) + if (result && result.npub) { + setCurrentProfile(result.npub) + profileLoader.setSource("Profile/Profile.ui.qml", { + "profileData": currentProfile, + "npub": result.npub + }) + } } } } @@ -136,6 +133,7 @@ Item { Row { anchors.fill: parent + anchors.margins: 10 spacing: 10 // Left column: Follows list @@ -143,9 +141,13 @@ Item { width: parent.width * 0.3 - (parent.spacing * 2 / 3) height: parent.height color: Material.backgroundColor + border.color: Material.dividerColor + border.width: 1 + radius: 5 ColumnLayout { anchors.fill: parent + anchors.margins: 10 spacing: 10 // Filter input @@ -159,6 +161,8 @@ Item { RowLayout { anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 spacing: 10 Image { @@ -205,77 +209,90 @@ Item { } // Follows list - ListView { - id: followsView + Rectangle { Layout.fillWidth: true Layout.fillHeight: true - clip: true - spacing: 5 + color: "transparent" - model: AutoListModel { - id: followsModel - source: follows - } + ListView { + id: followsView + anchors.fill: parent + clip: true + spacing: 5 - delegate: Rectangle { - id: followItem - property bool mouseHover: false - height: visible ? 80 : 0 - width: parent ? parent.width : 200 - visible: { - if (filterInput.text === "") return true; - var searchText = filterInput.text.toLowerCase(); - return modelData.pubkey.toLowerCase().includes(searchText) || - (modelData.displayName && modelData.displayName.toLowerCase().includes(searchText)); + model: AutoListModel { + id: followsModel + source: follows } - color: mouseHover ? Material.accentColor : Material.backgroundColor - border.color: Material.dividerColor - radius: 5 - - RowLayout { - anchors.fill: parent - anchors.margins: 10 - - Image { - source: Util.getProfilePicture(modelData.picture, modelData.pubkey) - Layout.preferredWidth: 50 - Layout.preferredHeight: 50 - Layout.alignment: Qt.AlignVCenter - smooth: true - fillMode: Image.PreserveAspectCrop - } - ColumnLayout { - Layout.fillWidth: true - spacing: 5 + ScrollBar.vertical: ScrollBar { + active: true + policy: ScrollBar.AsNeeded + } - Text { - text: modelData.displayName !== "" ? modelData.displayName : modelData.pubkey - font: Constants.font - color: Material.primaryTextColor - elide: Text.ElideRight - Layout.fillWidth: true + delegate: Rectangle { + id: followItem + property bool mouseHover: false + height: visible ? 80 : 0 + width: followsView.width - followsView.ScrollBar.vertical.width + visible: { + if (filterInput.text === "") return true; + var searchText = filterInput.text.toLowerCase(); + return modelData.pubkey.toLowerCase().includes(searchText) || + (modelData.displayName && modelData.displayName.toLowerCase().includes(searchText)); + } + color: mouseHover ? Material.accentColor : Material.backgroundColor + border.color: Material.dividerColor + radius: 5 + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + + Image { + source: Util.getProfilePicture(modelData.picture, modelData.pubkey) + Layout.preferredWidth: 50 + Layout.preferredHeight: 50 + Layout.alignment: Qt.AlignVCenter + smooth: true + fillMode: Image.PreserveAspectCrop } - Text { - text: modelData.pubkey - elide: Text.ElideRight + ColumnLayout { Layout.fillWidth: true - font: Constants.smallFont - color: Material.secondaryTextColor - visible: modelData.displayName !== "" + spacing: 5 + + Text { + text: modelData.displayName || modelData.name || modelData.pubkey + font: Constants.font + color: Material.primaryTextColor + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: modelData.name || modelData.pubkey + elide: Text.ElideRight + Layout.fillWidth: true + font: Constants.smallFont + color: Material.secondaryTextColor + visible: modelData.displayName !== "" || modelData.name !== "" + } } } - } - MouseArea { - anchors.fill: parent - hoverEnabled: true - onEntered: followItem.mouseHover = true - onExited: followItem.mouseHover = false - onClicked: { - chatLoader.setSource("Chat/ChatWindow.ui.qml", { "pubkey": modelData.pubkey }) - rightProfileLoader.setSource("Profile/ViewProfile.ui.qml", { "npub": modelData.pubkey }) + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: followItem.mouseHover = true + onExited: followItem.mouseHover = false + onClicked: { + setCurrentProfile(modelData.pubkey) + profileLoader.setSource("Profile/Profile.ui.qml", { + "profileData": currentProfile, + "npub": modelData.pubkey + }) + } } } } @@ -302,46 +319,13 @@ Item { color: Material.backgroundColor Loader { - id: rightProfileLoader + id: profileLoader anchors.fill: parent + anchors.rightMargin: 10 + anchors.bottomMargin: 5 } } } } } - - // Profile card - Pane { - id: profileCard - width: 400 - padding: 0 - visible: false - Material.elevation: 6 - - anchors { - right: parent.right - top: parent.top - margins: 20 - } - - Loader { - id: profileLoader - onLoaded: { - if (item && typeof item.closeRequested === "function") { - item.closeRequested.connect(function() { - profileCard.visible = false - profileLoader.source = "" - }) - } - } - } - - Behavior on opacity { - NumberAnimation { duration: 150 } - } - - onVisibleChanged: { - opacity = visible ? 1 : 0 - } - } } diff --git a/resources/qml/content/KeyMgmtScreen.ui.qml b/resources/qml/content/KeyMgmtScreen.ui.qml index 16fb711..9422144 100644 --- a/resources/qml/content/KeyMgmtScreen.ui.qml +++ b/resources/qml/content/KeyMgmtScreen.ui.qml @@ -21,219 +21,249 @@ Rectangle { } function loginCallback(success, message) { - connectingModal.close() - if (success) { - currentScreen = "Home" - } else { + if (connectingModal) { + connectingModal.close() + } + + if (! success) { loginErrorDialog.errorMessage = message loginErrorDialog.open() } } - ColumnLayout { - anchors.fill: parent - spacing: 20 + Rectangle { + width: 900 + height: parent.height + anchors.horizontalCenter: parent.horizontalCenter + anchors.margins: 10 + + Rectangle { + width: 1 + height: parent.height + color: Material.dividerColor + anchors.left: parent.left + } Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: welcomeColumn.implicitHeight + 20 - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: 10 - Layout.leftMargin: 10 - Layout.rightMargin: 10 + width: 1 + height: parent.height + color: Material.dividerColor + anchors.right: parent.right + } + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 1 + anchors.rightMargin: 1 color: Material.backgroundColor - border.color: Material.dividerColor - border.width: 1 - radius: 5 ColumnLayout { - id: welcomeColumn anchors.fill: parent anchors.margins: 10 - spacing: 5 + spacing: 20 - Label { + Rectangle { Layout.fillWidth: true - text: qsTr("Welcome to Futr") - font: Constants.largeFont - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - } - - Label { - Layout.fillWidth: true - text: qsTr("Your gateway to the future - global, decentralized, censorship-resistant") - font: Constants.font - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - } - } - } + Layout.preferredHeight: welcomeColumn.implicitHeight + 20 + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 10 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + color: Material.backgroundColor + border.color: Material.dividerColor + border.width: 1 + radius: 5 - Text { - text: qsTr("Select an account from the list below:") - font: Constants.font - Layout.alignment: Qt.AlignLeft - color: Material.primaryTextColor - } + ColumnLayout { + id: welcomeColumn + anchors.fill: parent + anchors.margins: 10 + spacing: 5 - ScrollView { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignHCenter + Label { + Layout.fillWidth: true + text: qsTr("Welcome to Futr") + font: Constants.largeFont + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } - ListView { - id: accountsView - focus: true - clip: true - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - spacing: 5 + Label { + Layout.fillWidth: true + text: qsTr("Your gateway to the future - global, decentralized, censorship-resistant") + font: Constants.font + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + } - model: AutoListModel { - source: ctxKeyMgmt.accounts - mode: AutoListModel.ByKey + Text { + text: qsTr("Select an account from the list below:") + font: Constants.font + Layout.alignment: Qt.AlignLeft + color: Material.primaryTextColor } - delegate: Rectangle { - property bool mouseHover: false + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter - height: 60 - color: mouseHover ? Material.accentColor : Material.backgroundColor - border.color: Material.dividerColor - radius: 5 - width: ListView.view.width + ListView { + id: accountsView + focus: true + clip: true + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + spacing: 5 - RowLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 10 + model: AutoListModel { + source: ctxKeyMgmt.accounts + mode: AutoListModel.ByKey + } - Image { - source: Util.getProfilePicture(modelData.picture, modelData.npub) - Layout.preferredWidth: 40 - Layout.preferredHeight: 40 - Layout.alignment: Qt.AlignVCenter - smooth: true - fillMode: Image.PreserveAspectCrop + delegate: Rectangle { + property bool mouseHover: false - MouseArea { + height: 60 + color: mouseHover ? Material.accentColor : Material.backgroundColor + border.color: Material.dividerColor + radius: 5 + width: ListView.view.width + + RowLayout { anchors.fill: parent - hoverEnabled: true - onEntered: parent.parent.parent.mouseHover = true - onExited: parent.parent.parent.mouseHover = false - - onClicked: { - connectingModal.open() - delayLogin.npub = modelData.npub - delayLogin.start() + anchors.margins: 10 + spacing: 10 + + Image { + source: Util.getProfilePicture(modelData.picture, modelData.npub) + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignVCenter + smooth: true + fillMode: Image.PreserveAspectCrop + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: parent.parent.parent.mouseHover = true + onExited: parent.parent.parent.mouseHover = false + + onClicked: { + connectingModal.open() + delayLogin.npub = modelData.npub + delayLogin.start() + } + } } - } - } - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 2 - - Item { - Layout.fillHeight: true - Layout.fillWidth: true - - Text { - anchors.left: parent.left - anchors.bottom: parent.verticalCenter - font: Constants.font - text: modelData.displayName - elide: Text.ElideRight - width: parent.width - color: Material.primaryTextColor + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 2 + + Item { + Layout.fillHeight: true + Layout.fillWidth: true + + Text { + anchors.left: parent.left + anchors.bottom: parent.verticalCenter + font: Constants.font + text: modelData.displayName + elide: Text.ElideRight + width: parent.width + color: Material.primaryTextColor + } + + Text { + anchors.left: parent.left + anchors.top: parent.verticalCenter + text: modelData.npub + font.pixelSize: Constants.font.pixelSize * 0.8 + elide: Text.ElideRight + width: parent.width + color: Material.secondaryTextColor + } + } + + MouseArea { + x: 0 + y: 0 + width: parent.width + height: parent.height + hoverEnabled: true + onEntered: parent.parent.parent.mouseHover = true + onExited: parent.parent.parent.mouseHover = false + + onClicked: { + connectingModal.open() + delayLogin.npub = modelData.npub + delayLogin.start() + } + } } - Text { - anchors.left: parent.left - anchors.top: parent.verticalCenter - text: modelData.npub - font.pixelSize: Constants.font.pixelSize * 0.8 - elide: Text.ElideRight - width: parent.width - color: Material.secondaryTextColor - } - } + RoundButton { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: 20 + + icon.source: "qrc:/icons/delete.svg" + icon.width: 34 + icon.height: 34 - MouseArea { - x: 0 - y: 0 - width: parent.width - height: parent.height - hoverEnabled: true - onEntered: parent.parent.parent.mouseHover = true - onExited: parent.parent.parent.mouseHover = false - - onClicked: { - connectingModal.open() - delayLogin.npub = modelData.npub - delayLogin.start() + onClicked: { + confirmRemoveAccount.accountToRemove = modelData.npub + } } } } + } + } - RoundButton { - Layout.preferredWidth: 40 - Layout.preferredHeight: 40 - Layout.alignment: Qt.AlignVCenter - Layout.rightMargin: 20 + Text { + text: qsTr("Or:") + font: Constants.font + Layout.alignment: Qt.AlignLeft + color: Material.primaryTextColor + } - icon.source: "qrc:/icons/delete.svg" - icon.width: 34 - icon.height: 34 + Row { + height: 320 + spacing: 30 + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter - onClicked: { - confirmRemoveAccount.accountToRemove = modelData.npub - } + Button { + text: qsTr("Import Account") + font: Constants.font + highlighted: true + Layout.alignment: Qt.AlignLeft + width: implicitWidth + 80 + + onClicked: { + importAccountDialog.visible = true + ctxKeyMgmt.errorMsg = "" } } - } - } - } - Text { - text: qsTr("Or:") - font: Constants.font - Layout.alignment: Qt.AlignLeft - color: Material.primaryTextColor - } - - Row { - height: 320 - spacing: 30 - Layout.fillHeight: true - Layout.alignment: Qt.AlignHCenter - - Button { - text: qsTr("Import Account") - font: Constants.font - highlighted: true - Layout.alignment: Qt.AlignLeft - width: implicitWidth + 80 - - onClicked: { - importAccountDialog.visible = true - ctxKeyMgmt.errorMsg = "" - } - } + Button { + id: generatebutton + text: qsTr("Generate new keys") + font: Constants.font + highlighted: true + Layout.alignment: Qt.AlignRight + width: implicitWidth + 80 - Button { - id: generatebutton - text: qsTr("Generate new keys") - font: Constants.font - highlighted: true - Layout.alignment: Qt.AlignRight - width: implicitWidth + 80 - - onClicked: function () { - ctxKeyMgmt.generateSeedphrase() - keysGeneratedDialog.visible = true + onClicked: function () { + ctxKeyMgmt.generateSeedphrase() + keysGeneratedDialog.visible = true + } + } } } } diff --git a/resources/qml/content/Profile/EditMyProfile.ui.qml b/resources/qml/content/Profile/EditProfile.ui.qml similarity index 95% rename from resources/qml/content/Profile/EditMyProfile.ui.qml rename to resources/qml/content/Profile/EditProfile.ui.qml index 64b2c3f..6413034 100644 --- a/resources/qml/content/Profile/EditMyProfile.ui.qml +++ b/resources/qml/content/Profile/EditProfile.ui.qml @@ -32,12 +32,13 @@ Rectangle { BackButton { id: backButton + Layout.alignment: Qt.AlignLeft onClicked: { - var profile = JSON.parse(getProfile(mynpub)) + setCurrentProfile(mynpub) profileLoader.setSource( - "MyProfile.ui.qml", - { "profileData": profile } + "Profile.ui.qml", + { "profileData": currentProfile, "npub": mynpub } ) } } @@ -50,15 +51,15 @@ Rectangle { text: "Edit Profile" font: Constants.largeFont color: Material.primaryTextColor + Layout.alignment: Qt.AlignCenter } Item { Layout.fillWidth: true } - CloseButton { - id: closeButton - target: profileCard + Item { + Layout.preferredWidth: backButton.width } } diff --git a/resources/qml/content/Profile/MyProfile.ui.qml b/resources/qml/content/Profile/MyProfile.ui.qml deleted file mode 100644 index a5a70ee..0000000 --- a/resources/qml/content/Profile/MyProfile.ui.qml +++ /dev/null @@ -1,176 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Controls.Material 2.15 -import QtQuick.Layouts 1.15 - -import Buttons 1.0 -import Futr 1.0 - -Rectangle { - color: Material.backgroundColor - radius: 5 - width: 400 - implicitHeight: content.implicitHeight - border.color: Material.dividerColor - border.width: 1 - - property var profileData - - ColumnLayout { - id: content - anchors.fill: parent - anchors.margins: 1 - spacing: 10 - - RowLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignRight - Layout.rightMargin: 2 - Layout.topMargin: 2 - - CloseButton { - id: closeButton - target: profileCard - } - } - - RowLayout { - width: parent.width - - ColumnLayout { - spacing: 10 - width: parent.width - - Rectangle { - Layout.fillWidth: true - height: 80 - visible: profileData !== null && profileData.banner !== null && profileData.banner !== "" - - Image { - source: profileData.banner ?? "" - width: parent.width - height: 80 - fillMode: Image.PreserveAspectCrop - clip: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 10 - - Rectangle { - width: 60 - height: 60 - Layout.leftMargin: 10 - Layout.fillHeight: true - color: Material.backgroundColor - - Image { - id: profileImage - source: Util.getProfilePicture(profileData.picture, mynpub) - width: 60 - height: 60 - fillMode: Image.PreserveAspectCrop - clip: true - } - } - - ColumnLayout { - spacing: 10 - Layout.fillWidth: true - - Text { - text: profileData.display_name ?? "" - font: Constants.font - color: Material.primaryTextColor - } - - Text { - text: profileData.name ?? "" - font: Constants.font - color: Material.primaryTextColor - } - - RowLayout { - Text { - text: mynpub - elide: Text.ElideRight - Layout.fillWidth: true - font: Constants.font - color: Material.primaryTextColor - } - - Button { - id: copyButton - icon.source: "qrc:/icons/content_copy.svg" - flat: true - Layout.preferredWidth: 50 - Layout.preferredHeight: 50 - Layout.rightMargin: 10 - - ToolTip.visible: hovered - ToolTip.text: qsTr("Copy to clipboard") - - onClicked: { - clipboard.copyText(mynpub) - } - } - } - - Text { - text: profileData.about ?? "" - Layout.fillWidth: true - wrapMode: Text.Wrap - font: Constants.font - color: Material.primaryTextColor - } - - ExternalIdentity { - Layout.fillWidth: true - icon: ExternalIdentityIcons.github - link: profileData.githubLink ?? "" - proof: profileData.githubProof ?? "" - value: profileData.githubUsername ?? "" - } - - ExternalIdentity { - Layout.fillWidth: true - icon: ExternalIdentityIcons.telegram - link: profileData.telegramLink ?? "" - proof: profileData.telegramProof ?? "" - value: profileData.telegramUsername ?? "" - } - - ExternalIdentity { - Layout.fillWidth: true - icon: ExternalIdentityIcons.x_twitter - link: profileData.twitterLink ?? "" - proof: profileData.twitterProof ?? "" - value: profileData.twitterUsername ?? "" - } - } - } - } - } - - RowLayout { - width: parent.width - Layout.alignment: Qt.AlignRight - Layout.rightMargin: 10 - Layout.bottomMargin: 10 - - EditButton { - id: editButton - - onClicked: { - var profile = JSON.parse(getProfile(mynpub)) - profileLoader.setSource( - "EditMyProfile.ui.qml", - { "profileData": profile } - ) - } - } - } - } -} diff --git a/resources/qml/content/Profile/Profile.ui.qml b/resources/qml/content/Profile/Profile.ui.qml new file mode 100644 index 0000000..5ff531c --- /dev/null +++ b/resources/qml/content/Profile/Profile.ui.qml @@ -0,0 +1,168 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 + +import Buttons 1.0 +import Futr 1.0 + +Rectangle { + color: Material.backgroundColor + radius: 5 + width: 400 + border.color: Material.dividerColor + border.width: 1 + + property var profileData + property var npub + + ColumnLayout { + id: content + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 1 + spacing: 0 + + Rectangle { + Layout.fillWidth: true + height: 80 + visible: profileData !== null && profileData.banner !== null && profileData.banner !== "" + + Image { + source: profileData.banner ?? "" + width: parent.width + height: 80 + fillMode: Image.PreserveAspectCrop + clip: true + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 10 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + + Rectangle { + width: 60 + height: 60 + color: Material.backgroundColor + + Image { + id: profileImage + source: Util.getProfilePicture(profileData.picture, npub) + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + clip: true + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + + Text { + Layout.fillWidth: true + text: profileData.displayName || profileData.name || npub + font: Constants.largeFont + color: Material.primaryTextColor + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: profileData.name || "" + font: Constants.font + color: Material.primaryTextColor + visible: profileData.displayName + elide: Text.ElideRight + } + } + + Item { + Layout.fillWidth: true + } + + EditButton { + id: editButton + visible: npub === mynpub + + onClicked: { + setCurrentProfile(mynpub) + profileLoader.setSource( + "EditProfile.ui.qml", + { "profileData": currentProfile } + ) + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: 10 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + + Text { + text: npub + elide: Text.ElideRight + Layout.fillWidth: true + font: Constants.font + color: Material.primaryTextColor + } + + Button { + id: copyButton + icon.source: "qrc:/icons/content_copy.svg" + flat: true + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + + ToolTip.visible: hovered + ToolTip.text: qsTr("Copy to clipboard") + + onClicked: { + clipboard.copyText(npub) + } + } + } + + Text { + text: profileData.about ?? "" + Layout.fillWidth: true + wrapMode: Text.Wrap + font: Constants.font + color: Material.primaryTextColor + } + + ExternalIdentity { + Layout.fillWidth: true + icon: ExternalIdentityIcons.github + link: profileData.githubLink ?? "" + proof: profileData.githubProof ?? "" + value: profileData.githubUsername ?? "" + } + + ExternalIdentity { + Layout.fillWidth: true + icon: ExternalIdentityIcons.telegram + link: profileData.telegramLink ?? "" + proof: profileData.telegramProof ?? "" + value: profileData.telegramUsername ?? "" + } + + ExternalIdentity { + Layout.fillWidth: true + icon: ExternalIdentityIcons.x_twitter + link: profileData.twitterLink ?? "" + proof: profileData.twitterProof ?? "" + value: profileData.twitterUsername ?? "" + } + } + } +} diff --git a/resources/qml/content/Profile/qmldir b/resources/qml/content/Profile/qmldir index 955c802..7ab4d09 100644 --- a/resources/qml/content/Profile/qmldir +++ b/resources/qml/content/Profile/qmldir @@ -1,4 +1,4 @@ module Profile -EditMyProfile 1.0 EditMyProfile.ui.qml +EditProfile 1.0 EditProfile.ui.qml ExternalIdentity 1.0 ExternalIdentity.ui.qml -MyProfile 1.0 MyProfile.ui.qml +Profile 1.0 Profile.ui.qml diff --git a/src/Futr.hs b/src/Futr.hs index 8ea0252..6a03dab 100644 --- a/src/Futr.hs +++ b/src/Futr.hs @@ -1,13 +1,16 @@ {-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} module Futr where -import Data.Aeson (eitherDecode) +import Data.Aeson (ToJSON, eitherDecode, pairs, toEncoding, (.=)) import Data.ByteString.Lazy qualified as BSL -import Control.Monad (forM, void, unless, when) +import Control.Monad (forM, forM_, void, unless, when) +import Data.Maybe (listToMaybe) import Data.Map.Strict qualified as Map import Data.Proxy (Proxy(..)) -import Data.Text (Text, pack) +import Data.Text (Text, isPrefixOf, pack) import Data.Text.Encoding qualified as TE import Data.Typeable (Typeable) import Effectful @@ -18,23 +21,47 @@ import Effectful.Dispatch.Dynamic (interpret) import Effectful.State.Static.Shared (State, get, gets, modify) import Effectful.TH import EffectfulQML +import GHC.Generics (Generic) import Graphics.QML hiding (fireSignal, runEngineLoop) import Graphics.QML qualified as QML -import AppState +import Nostr.Bech32 import Nostr.Effects.CurrentTime import Nostr.Effects.Logging import Nostr.Effects.RelayPool -import Nostr.Keys (keyPairToPubKeyXO, secKeyToKeyPair) -import Nostr.Types ( Event(..), EventId(..), Filter(..), Kind(..), Tag(..), +import Nostr.Keys (PubKeyXO, keyPairToPubKeyXO, secKeyToKeyPair) +import Nostr.Types ( Event(..), EventId(..), Filter(..), Kind(..), Tag(..), RelayURI, Response(..), Relay(..), relayName, relayURIToText) import Presentation.KeyMgmt qualified as PKeyMgmt +import Types +-- | Signal key class for LoginStatusChanged. +data LoginStatusChanged deriving Typeable + +instance SignalKeyClass LoginStatusChanged where + type SignalParams LoginStatusChanged = Bool -> Text -> IO () + + +-- | Search result. +data SearchResult + = ProfileResult { npub :: Text, relayUri :: Maybe Text } + | NoResult + deriving (Eq, Generic, Show) - -- | Futr Effect for managing the application state. +instance ToJSON SearchResult where + toEncoding (ProfileResult npub' relayUri') = pairs + ( "npub" .= npub' + <> "relayUri" .= relayUri' + ) + toEncoding NoResult = pairs ( "result" .= ("no_result" :: Text) ) + + +-- | Futr Effects. data Futr :: Effect where Login :: ObjRef () -> Text -> Futr m Bool + Search :: ObjRef () -> Text -> Futr m SearchResult + SetCurrentProfile :: Text -> Futr m () Logout :: ObjRef () -> Futr m () @@ -45,15 +72,7 @@ type instance DispatchOf Futr = Dynamic makeEffect ''Futr --- | Signal key class for LoginStatusChanged. -data LoginStatusChanged deriving Typeable - - -instance SignalKeyClass LoginStatusChanged where - type SignalParams LoginStatusChanged = Bool -> Text -> IO () - - --- | Futr Effect +-- | Effectful type for Futr. type FutrEff es = ( State AppState :> es , PKeyMgmt.KeyMgmt :> es , PKeyMgmt.KeyMgmtUI :> es @@ -79,6 +98,65 @@ runFutr = interpret $ \_ -> \case return success Nothing -> return False + Search _ input -> do + logInfo $ "Searching for: " <> input + st <- get @AppState + let myPubKey = keyPairToPubKeyXO <$> keyPair st + + case input of + _ | "nprofile" `isPrefixOf` input || "npub" `isPrefixOf` input -> do + case parseNprofileOrNpub input of + Just (pubkey', maybeRelay) -> do + case myPubKey of + Just myKey | myKey == pubkey' -> do + return $ ProfileResult (pubKeyXOToBech32 pubkey') (relayURIToText <$> maybeRelay) + + _ -> do + let userFollows = maybe [] (flip (Map.findWithDefault []) (followList $ follows st)) myPubKey + if any (\(Follow pk _ _) -> pk == pubkey') userFollows + then do + return $ ProfileResult (pubKeyXOToBech32 pubkey') (relayURIToText <$> maybeRelay) + else do + case maybeRelay of + Just relay' -> return $ ProfileResult (pubKeyXOToBech32 pubkey') (Just $ relayURIToText relay') + Nothing -> do + relays' <- gets @RelayPoolState relays + let relaysToSearch = Map.keys relays' + logInfo $ "Searching in relays: " <> pack (show relaysToSearch) + forM_ relaysToSearch $ \relay' -> do + void $ async $ do + maybeSubInfo <- startSubscription relay' [MetadataFilter [pubkey']] + case maybeSubInfo of + Just (subId, queue) -> do + handleSearchSubscription relay' queue + stopSubscription subId + Nothing -> + logWarning $ "Failed to start search subscription for relay: " <> relayURIToText relay' + + return $ ProfileResult (pubKeyXOToBech32 pubkey') Nothing + + Nothing -> do + logWarning $ "Failed to parse nprofile or npub: " <> input + return NoResult + + _ -> do + logWarning $ "Unsupported search input: " <> input + return NoResult + + SetCurrentProfile npub' -> do + case bech32ToPubKeyXO npub' of + Just pk -> do + modify @AppState $ \st -> st { currentProfile = Just pk } + obj <- gets @AppState profileObjRef + case obj of + Just obj' -> fireSignal obj' + Nothing -> do + logError "No objRef for current profile" + return () + Nothing -> do + logError $ "Invalid npub, cannot set current profile: " <> npub' + return () + Logout obj -> do modify @AppState $ \st -> st { keyPair = Nothing @@ -99,6 +177,16 @@ runFutr = interpret $ \_ -> \case logInfo "User logged out successfully" +-- Helper function to parse nprofile or npub +parseNprofileOrNpub :: Text -> Maybe (PubKeyXO, Maybe RelayURI) +parseNprofileOrNpub input = + case bech32ToPubKeyXO input of + Just pubkey' -> Just (pubkey', Nothing) -- For npub + Nothing -> case nprofileToPubKeyXO input of + Just (pubkey', relays') -> Just (pubkey', listToMaybe relays') -- For nprofile + Nothing -> Nothing + + -- | Login with an account. loginWithAccount :: FutrEff es => ObjRef () -> PKeyMgmt.Account -> Eff es Bool loginWithAccount obj a = do @@ -117,6 +205,7 @@ loginWithAccount obj a = do then do logDebug $ "Connected to relay: " <> relayName relay' modify @AppState $ \st -> st { keyPair = Just kp, currentScreen = Home } + fireSignal obj liftIO $ QML.fireSignal (Proxy :: Proxy LoginStatusChanged) obj True "" -- Initial subscription (until EOSE) @@ -134,6 +223,7 @@ loginWithAccount obj a = do void $ async $ do handleResponsesUntilEOSE (uri relay') queue stopSubscription subId' + fireSignal obj -- Start the main subscription after EOSE st <- get @AppState @@ -174,7 +264,6 @@ handleResponsesUntilEOSE relayURI' queue = do msg <- atomically $ readTQueue queue msgs <- atomically $ flushTQueue queue stopped <- processResponsesUntilEOSE relayURI' (msg : msgs) - notifyUI unless stopped $ handleResponsesUntilEOSE relayURI' queue @@ -214,6 +303,10 @@ notifyUI = do Just obj' -> fireSignal obj' Nothing -> logWarning "No objRef for follows in AppState" + case profileObjRef st of + Just obj' -> fireSignal obj' + Nothing -> logWarning "No objRef for profile in AppState" + -- | Process responses. processResponses :: FutrEff es => RelayURI -> [Response] -> Eff es Bool @@ -286,3 +379,12 @@ handleConfirmation eventId' accepted' msg relayURI' st = eventId' (confirmations st) } + + +-- | Handle the search subscription, updating profiles and stopping on EOSE. +handleSearchSubscription :: FutrEff es => RelayURI -> TQueue Response -> Eff es () +handleSearchSubscription relayURI' queue = do + msg <- atomically $ readTQueue queue + msgs <- atomically $ flushTQueue queue + void $ processResponses relayURI' (msg : msgs) + notifyUI diff --git a/src/Main.hs b/src/Main.hs index c39163a..49161b4 100755 --- a/src/Main.hs +++ b/src/Main.hs @@ -8,7 +8,6 @@ import EffectfulQML import Graphics.QML qualified as QML import System.Environment (setEnv) -import AppState qualified as AppState import Futr qualified as Futr import Nostr.Effects.CurrentTime (runCurrentTime) import Nostr.Effects.IDGen (runIDGen) @@ -17,6 +16,7 @@ import Nostr.Effects.RelayPool (runRelayPool) import Nostr.Effects.WebSocket (runWebSocket) import Presentation.KeyMgmt qualified as KeyMgmt import UI qualified as UI +import Types main :: IO () main = do @@ -39,10 +39,10 @@ main = do . runCurrentTime . runConcurrent . evalState KeyMgmt.initialState - . evalState AppState.initialRelayPoolState + . evalState Types.initialRelayPoolState . KeyMgmt.runKeyMgmt . KeyMgmt.runKeyMgmtUI - . evalState AppState.initialState + . evalState Types.initialState . runWebSocket 3 -- max 3 retries . runRelayPool . Futr.runFutr diff --git a/src/Nostr/Bech32.hs b/src/Nostr/Bech32.hs new file mode 100644 index 0000000..d08158c --- /dev/null +++ b/src/Nostr/Bech32.hs @@ -0,0 +1,201 @@ +module Nostr.Bech32 + ( secKeyToBech32 + , bech32ToSecKey + , pubKeyXOToBech32 + , bech32ToPubKeyXO + , eventToNaddr + , naddrToEvent + , eventToNevent + , neventToEvent + , pubKeyXOToNprofile + , nprofileToPubKeyXO + , nrelayToRelay + , relayToNrelay + , debugNprofileParsing + ) where + +import Codec.Binary.Bech32 qualified as Bech32 +import Data.Binary.Put (runPut, putWord8, putByteString) +import Data.Binary.Get (runGet, getWord8, getByteString, isEmpty) +import Data.ByteString qualified as BS +import Data.ByteString.Base16 qualified as B16 +import qualified Data.ByteString.Lazy as LBS +import Data.ByteString.Short qualified as BSS +import Data.Maybe (mapMaybe) +import Data.Text qualified as T +import Data.Text.Encoding (encodeUtf8, decodeUtf8) +import Data.Word (Word8) +import Text.Read (readMaybe) +import Text.URI (mkURI) + +import Nostr.Keys (SecKey, PubKeyXO, importPubKeyXO, exportPubKeyXO, importSecKey, exportSecKey) +import Nostr.Types (Event(..), EventId(..), Kind, RelayURI(..), relayURIToText) + + +-- | Bech32 encoding for SecKey +secKeyToBech32 :: SecKey -> T.Text +secKeyToBech32 secKey = toBech32 "nsec" (exportSecKey secKey) + + +-- | Bech32 decoding to SecKey +bech32ToSecKey :: T.Text -> Maybe SecKey +bech32ToSecKey txt = fromBech32 "nsec" txt >>= importSecKey + + +-- | Bech32 encoding for PubKeyXO +pubKeyXOToBech32 :: PubKeyXO -> T.Text +pubKeyXOToBech32 pubKeyXO = toBech32 "npub" (exportPubKeyXO pubKeyXO) + + +-- | Bech32 decoding to PubKeyXO +bech32ToPubKeyXO :: T.Text -> Maybe PubKeyXO +bech32ToPubKeyXO txt = fromBech32 "npub" txt >>= importPubKeyXO + + +-- | Convert an Event to naddr bech32 encoding +eventToNaddr :: Event -> T.Text +eventToNaddr event = toBech32 "naddr" $ encodeTLV + [ (0, BSS.toShort $ getEventId $ eventId event) + , (1, BSS.toShort $ exportPubKeyXO $ pubKey event) + , (2, BSS.toShort $ encodeUtf8 $ T.pack $ show $ kind event) + ] + + +-- | Decode naddr bech32 encoding to Event components +naddrToEvent :: T.Text -> Maybe (EventId, PubKeyXO, Kind) +naddrToEvent txt = do + bs <- fromBech32 "naddr" txt + let tlvs = decodeTLV bs + eventId' <- lookup 0 tlvs >>= (Just . EventId . BSS.fromShort) + pubKey' <- lookup 1 tlvs >>= (importPubKeyXO . BSS.fromShort) + kind' <- lookup 2 tlvs >>= (readMaybe . T.unpack . decodeUtf8 . BSS.fromShort) + return (eventId', pubKey', kind') + + +-- | Convert an Event to nevent bech32 encoding +eventToNevent :: Event -> T.Text +eventToNevent event = toBech32 "nevent" $ encodeTLV + [ (0, BSS.toShort $ getEventId $ eventId event) + , (2, BSS.toShort $ exportPubKeyXO $ pubKey event) + , (3, BSS.toShort $ encodeUtf8 $ T.pack $ show $ kind event) + ] + + +-- | Decode nevent bech32 encoding to Event components +neventToEvent :: T.Text -> Maybe (EventId, PubKeyXO, Kind) +neventToEvent txt = do + bs <- fromBech32 "nevent" txt + let tlvs = decodeTLV bs + eventId' <- lookup 0 tlvs >>= (Just . EventId . BSS.fromShort) + pubKey' <- lookup 2 tlvs >>= (importPubKeyXO . BSS.fromShort) + kind' <- lookup 3 tlvs >>= (readMaybe . T.unpack . decodeUtf8 . BSS.fromShort) + return (eventId', pubKey', kind') + + +-- | Convert a PubKeyXO and list of relays to nprofile bech32 encoding +pubKeyXOToNprofile :: PubKeyXO -> [RelayURI] -> T.Text +pubKeyXOToNprofile pubKey' relays = toBech32 "nprofile" $ encodeTLV $ + (0, BSS.toShort $ exportPubKeyXO pubKey') : map (\r -> (1, BSS.toShort $ encodeUtf8 $ relayURIToText r)) relays + + +-- | Decode nprofile bech32 encoding to PubKeyXO and relays +nprofileToPubKeyXO :: T.Text -> Maybe (PubKeyXO, [RelayURI]) +nprofileToPubKeyXO txt = do + bs <- fromBech32 "nprofile" txt + let tlvs = decodeTLV bs + pubKey' <- case lookup 0 tlvs of + Just pubKeyBS -> importPubKeyXO (BSS.fromShort pubKeyBS) + Nothing -> Nothing + let relays = mapMaybe (\(t, v) -> + if t == 1 + then RelayURI <$> mkURI (decodeUtf8 $ BSS.fromShort v) + else Nothing) tlvs + return (pubKey', relays) + +-- Helper function to print debug information +debugNprofileParsing :: T.Text -> IO () +debugNprofileParsing txt = do + putStrLn $ "Input: " ++ T.unpack txt + case Bech32.decodeLenient txt of + Left err -> putStrLn $ "Failed to decode bech32: " ++ show err + Right (prefix, dataPart) -> do + putStrLn $ "Decoded bech32 prefix: " ++ T.unpack (Bech32.humanReadablePartToText prefix) + case Bech32.dataPartToBytes dataPart of + Nothing -> putStrLn "Failed to convert data part to bytes" + Just bs -> do + putStrLn $ "Decoded bytes: " ++ show bs + let tlvs = decodeTLV bs + putStrLn $ "Decoded TLVs: " ++ show tlvs + case lookup 0 tlvs of + Nothing -> putStrLn "No pubkey found in TLVs" + Just pubKeyBS -> do + putStrLn $ "PubKey bytes: " ++ show pubKeyBS + case importPubKeyXO (BSS.fromShort pubKeyBS) of + Nothing -> putStrLn "Failed to import PubKeyXO" + Just _ -> putStrLn "Successfully imported PubKeyXO" + let relays = mapMaybe (\(t, v) -> + if t == 1 + then Just $ decodeUtf8 $ BSS.fromShort v + else Nothing) tlvs + putStrLn $ "Relays: " ++ show relays + + +-- | Convert a relay URI to nrelay bech32 encoding +relayToNrelay :: RelayURI -> T.Text +relayToNrelay relay = toBech32 "nrelay" $ encodeTLV [(0, BSS.toShort $ encodeUtf8 $ relayURIToText relay)] + + +-- | Decode nrelay bech32 encoding to RelayURI +nrelayToRelay :: T.Text -> Maybe RelayURI +nrelayToRelay txt = do + bs <- fromBech32 "nrelay" txt + let tlvs = decodeTLV bs + relayText <- lookup 0 tlvs >>= (Just . decodeUtf8 . BSS.fromShort) + RelayURI <$> mkURI relayText + + +-- | Convert from bech32 to ByteString +fromBech32 :: T.Text -> T.Text -> Maybe BS.ByteString +fromBech32 hrpText txt = do + case Bech32.decodeLenient txt of + Left _ -> Nothing + Right (prefix, dataPart) -> + if Bech32.humanReadablePartToText prefix == hrpText + then Bech32.dataPartToBytes dataPart + else Nothing + +-- | Convert from ByteString to bech32 +toBech32 :: T.Text -> BS.ByteString -> T.Text +toBech32 hrpText bs = + case Bech32.humanReadablePartFromText hrpText of + Left err -> error $ "Invalid HRP: " ++ show err + Right hrp -> + case Bech32.encode hrp (Bech32.dataPartFromBytes bs) of + Left err -> error $ "Bech32 encoding failed: " ++ show err + Right txt -> txt + + +-- | Encode a list of TLV (Type-Length-Value) items +encodeTLV :: [(Word8, BSS.ShortByteString)] -> BS.ByteString +encodeTLV items = LBS.toStrict $ runPut $ mapM_ encodeTLVItem items + where + encodeTLVItem (t, v) = do + putWord8 t + putWord8 (fromIntegral $ BSS.length v) + putByteString (BSS.fromShort v) + + +-- | Decode a ByteString into a list of TLV (Type-Length-Value) items +decodeTLV :: BS.ByteString -> [(Word8, BSS.ShortByteString)] +decodeTLV bs = runGet go (LBS.fromStrict bs) + where + go = do + empty <- isEmpty + if empty + then return [] + else do + t <- getWord8 + l <- getWord8 + v <- getByteString (fromIntegral l) + rest <- go + return $ (t, BSS.toShort v) : rest diff --git a/src/Nostr/Effects/RelayPool.hs b/src/Nostr/Effects/RelayPool.hs index 63aee91..91e404b 100644 --- a/src/Nostr/Effects/RelayPool.hs +++ b/src/Nostr/Effects/RelayPool.hs @@ -12,11 +12,11 @@ import Effectful.Dispatch.Dynamic (EffectHandler, interpret) import Effectful.State.Static.Shared (State, evalState, get, modify) import Effectful.TH -import AppState (RelayPoolState(..), RelayData(..), initialRelayPoolState) import Nostr.Effects.IDGen import Nostr.Effects.Logging import Nostr.Effects.WebSocket import Nostr.Types +import Types (RelayPoolState(..), RelayData(..), initialRelayPoolState) -- | Effect for handling RelayPool operations. data RelayPool :: Effect where diff --git a/src/Nostr/Effects/Subscription.hs b/src/Nostr/Effects/Subscription.hs new file mode 100644 index 0000000..e22eeb3 --- /dev/null +++ b/src/Nostr/Effects/Subscription.hs @@ -0,0 +1,76 @@ +module Nostr.Effects.Subscription where + +import Data.Aeson (eitherDecode) +import Data.ByteString.Lazy qualified as BSL +import Data.Map.Strict (Map) +import Data.Map.Strict qualified as Map +import Data.Text (Text, pack) +import Data.Text.Encoding qualified as TE +import Effectful +import Effectful.Concurrent (Concurrent) +import Effectful.Concurrent.MVar (MVar, putMVar) +import Effectful.Concurrent.STM (TQueue, atomically, writeTQueue) +import Effectful.Dispatch.Dynamic (EffectHandler, interpret) +import Effectful.State.Static.Shared (State, get, modify) +import Effectful.TH + +import Nostr.Keys (PubKeyXO) +import Nostr.Effects.Logging +import Nostr.Effects.RelayPool +import Nostr.Types +import Types (AppState(..)) + +type SubscriptionEff es = (RelayPool :> es, State (RelayPoolState es) :> es, State AppState :> es, Logging :> es, Concurrent :> es) + +loadingSubscriptionHandler :: SubscriptionEff es => PubKeyXO -> MVar () -> SubscriptionHandler (Eff es) +loadingSubscriptionHandler xo signal = SubscriptionHandler + { onEvent = \event' -> case kind event' of + Metadata -> case eitherDecode (BSL.fromStrict $ TE.encodeUtf8 $ content event') of + Right profile -> modify $ \st -> + let updateProfile = case Map.lookup (pubKey event') (profiles st) of + Just (_, oldTime) | (createdAt event') > oldTime -> True + Nothing -> True + _ -> False + in if updateProfile + then st { profiles = Map.insert (pubKey event') (profile, (createdAt event')) (profiles st) } + else st + Left err -> logWarning $ "Failed to decode metadata: " <> pack err + + ShortTextNote -> return () + + FollowList -> do + let followList = [(pubKey, relayUri, displayName) | PTag pubKey relayUri displayName <- tags event'] + modify $ \st -> st { follows = Map.insert (pubKey event') followList (follows st) } + + _ -> return () + + , onEOSE = \subId' relayURI -> do + stopSubscription subId' + putMVar signal () + , onClose = \subId' relayURI msg -> return () + } + +defaultSubscriptionHandler :: SubscriptionEff es => PubKeyXO -> SubscriptionHandler (Eff es) +defaultSubscriptionHandler xo = SubscriptionHandler + { onEvent = \event' -> case kind event' of + Metadata -> case eitherDecode (BSL.fromStrict $ TE.encodeUtf8 $ content event') of + Right profile -> modify $ \st -> + let updateProfile = case Map.lookup (pubKey event') (profiles st) of + Just (_, oldTime) | (createdAt event') > oldTime -> True + Nothing -> True + _ -> False + in if updateProfile + then st { profiles = Map.insert (pubKey event') (profile, (createdAt event')) (profiles st) } + else st + Left err -> logWarning $ "Failed to decode metadata: " <> pack err + + ShortTextNote -> return () + + FollowList -> do + let followList = [(pubKey, relayUri, displayName) | PTag pubKey relayUri displayName <- tags event'] + modify $ \st -> st { follows = Map.insert (pubKey event') followList (follows st) } + + _ -> return () + , onEOSE = \subId' relayURI -> return () + , onClose = \subId' relayURI msg -> return () + } diff --git a/src/Nostr/Keys.hs b/src/Nostr/Keys.hs index 32b5cc9..e53acca 100755 --- a/src/Nostr/Keys.hs +++ b/src/Nostr/Keys.hs @@ -42,10 +42,6 @@ module Nostr.Keys ( , importSignature , exportPubKeyXO , exportSignature - , secKeyToBech32 - , pubKeyXOToBech32 - , bech32ToPubKeyXO - , bech32ToSecKey , byteStringToHex -- * Conversions @@ -63,7 +59,6 @@ module Nostr.Keys ( import Crypto.Secp256k1 qualified as S import Data.Aeson import Data.Aeson.Encoding (text) -import Codec.Binary.Bech32 qualified as Bech32 import Data.ByteString (ByteString) import Data.ByteString qualified as BS import Data.ByteString.Base16 qualified as B16 @@ -126,22 +121,6 @@ createKeyPair = do sk <- maybe (error "Unknown error upon key pair generation") return $ S.importSecKey bs return $ KeyPair $ S.keyPairCreate sk --- | Bech32 encoding for SecKey -secKeyToBech32 :: SecKey -> T.Text -secKeyToBech32 secKey = toBech32 "nsec" (S.exportSecKey $ getSecKey secKey) - --- | Bech32 encoding for PubKeyXO -pubKeyXOToBech32 :: PubKeyXO -> T.Text -pubKeyXOToBech32 pubKeyXO = toBech32 "npub" (S.exportPubKeyXO $ getPubKeyXO pubKeyXO) - --- | Bech32 decoding to SecKey -bech32ToSecKey :: T.Text -> Maybe SecKey -bech32ToSecKey txt = fmap SecKey $ fromBech32 "nsec" txt >>= S.importSecKey - --- | Bech32 decoding to PubKeyXO -bech32ToPubKeyXO :: T.Text -> Maybe PubKeyXO -bech32ToPubKeyXO txt = fmap PubKeyXO $ fromBech32 "npub" txt >>= S.importPubKeyXO - -- | Import byte string and create SecKey importSecKey :: ByteString -> Maybe SecKey importSecKey = fmap SecKey . S.importSecKey @@ -225,29 +204,6 @@ derivePublicKeyXO sk = PubKeyXO p secKeyGen :: IO BS.ByteString secKeyGen = BS.pack . take 32 . randoms <$> newStdGen --- | Convert from ByteString to bech32 -toBech32 :: T.Text -> BS.ByteString -> T.Text -toBech32 hrpText bs = - case Bech32.humanReadablePartFromText hrpText of - Left err -> error $ "Invalid HRP: " ++ show err - Right hrp -> - case Bech32.encode hrp (Bech32.dataPartFromBytes bs) of - Left err -> error $ "Bech32 encoding failed: " ++ show err - Right txt -> txt - --- | Convert from bech32 to ByteString -fromBech32 :: T.Text -> T.Text -> Maybe BS.ByteString -fromBech32 hrpText txt = - case Bech32.humanReadablePartFromText hrpText of - Left _ -> Nothing - Right hrp -> - case Bech32.decode txt of - Left _ -> Nothing - Right (hrp', dp) -> - if hrp == hrp' - then Bech32.dataPartToBytes dp - else Nothing - -- | Derivation path for Nostr (NIP-06) nostrAddr :: DerivPath nostrAddr = Deriv :| 44 :| 1237 :| 0 :/ 0 :/ 0 diff --git a/src/Nostr/Types.hs b/src/Nostr/Types.hs index 238a638..e556c87 100644 --- a/src/Nostr/Types.hs +++ b/src/Nostr/Types.hs @@ -14,14 +14,13 @@ import Control.Lens ((^.), (^?), (<&>), _Right) import Control.Monad (mzero) import Data.Aeson hiding (Error) import Data.Aeson.Encoding (list, text) -import Data.Aeson.Types (Parser) +import Data.Aeson.Types (Parser, parseEither) import Data.ByteString (ByteString) import Data.ByteString qualified as BS import Data.ByteString.Base16 qualified as B16 import Data.Default import Data.Foldable (toList) import Data.Function (on) -import Data.Maybe (fromMaybe) import Data.String.Conversions (ConvertibleStrings, cs) import Data.Text (Text, isInfixOf, toLower) import Data.Text.Encoding (decodeUtf8) @@ -31,12 +30,12 @@ import Text.URI (URI, mkURI, render, unRText) import Text.URI.Lens (uriAuthority, uriPath, uriScheme, authHost, authPort) import qualified Text.URI.QQ as QQ - import Nostr.Keys (PubKeyXO(..), Signature, byteStringToHex, exportPubKeyXO, exportSignature) -- | Represents a wrapped URI used within a relay. newtype RelayURI = RelayURI URI deriving (Eq, Generic, Ord, Show) + -- | Represents the information associated with a relay. data RelayInfo = RelayInfo { readable :: Bool @@ -44,6 +43,7 @@ data RelayInfo = RelayInfo } deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON) + -- | Represents a relay entity containing URI, relay information, and connection status. data Relay = Relay { uri :: RelayURI @@ -51,9 +51,11 @@ data Relay = Relay } deriving (Eq, Generic, Show) + -- | Represents a subscription id as text. type SubscriptionId = Text + -- | Represents a subscription. data Subscription = Subscription { filters :: [Filter] @@ -61,6 +63,8 @@ data Subscription = Subscription } deriving (Eq, Generic, Show) + +-- | Represents a filter for events. data Filter = MetadataFilter [PubKeyXO] | FollowListFilter [PubKeyXO] @@ -70,6 +74,8 @@ data Filter | AllMetadata Int deriving (Eq, Generic,Show) + +-- | Represents a request to the relay. data Request = SendEvent Event | Subscribe Subscription @@ -77,6 +83,8 @@ data Request | Disconnect deriving (Eq, Generic, Show) + +-- | Represents a response from the relay. data Response = EventReceived SubscriptionId Event | Ok EventId Bool Text @@ -85,9 +93,12 @@ data Response | Notice Text deriving (Eq, Show) + +-- | Represents a standard prefix for error messages. data StandardPrefix = Duplicate | Pow | Blocked | RateLimited | Invalid | Error deriving (Eq, Show) + -- | The 'Kind' data type represents different kinds of events in the Nostr protocol. data Kind = Metadata -- NIP-01 @@ -98,19 +109,27 @@ data Kind | Reaction -- NIP-25 | Seal -- NIP-59 | DirectMessage -- NIP-17 - deriving (Eq, Generic, Show) + deriving (Eq, Generic, Read, Show) + +-- | Represents an event id as a byte string. newtype EventId = EventId { getEventId :: ByteString } deriving (Eq, Ord) + +-- | Represents a relationship type. data Relationship = Reply | Root deriving (Eq, Generic, Show) + +-- | Represents a tag in an event. data Tag = ETag EventId (Maybe RelayURI) (Maybe Relationship) | PTag PubKeyXO (Maybe RelayURI) (Maybe DisplayName) | UnknownTag Value -- Store the original JSON data as an Aeson Value deriving (Eq, Generic, Show) + +-- | Represents an event. data Event = Event { eventId :: EventId , pubKey :: PubKeyXO @@ -122,6 +141,8 @@ data Event = Event } deriving (Eq, Generic, Show) + +-- | Represents an unsigned event. data UnsignedEvent = UnsignedEvent { pubKey' :: PubKeyXO , createdAt' :: Int @@ -131,8 +152,12 @@ data UnsignedEvent = UnsignedEvent } deriving (Eq, Generic, Show) + +-- | Represents a received event with its associated relays. type ReceivedEvent = (Event, [Relay]) + +-- | Represents a contact with a public key and an optional display name. type Contact = (PubKeyXO, Maybe DisplayName) type Name = Text @@ -142,6 +167,8 @@ type Picture = Text type Banner = Text type Nip05 = Text + +-- | Represents a user profile. data Profile = Profile { name :: Maybe Text , displayName :: Maybe Text @@ -151,11 +178,15 @@ data Profile = Profile , banner :: Maybe Text } deriving (Eq, Generic,Show) + +-- | Empty profile. emptyProfile :: Profile emptyProfile = Profile Nothing Nothing Nothing Nothing Nothing Nothing -- Helper functions + +-- | Converts an error message to a standard prefix. noticeReason :: Text -> StandardPrefix noticeReason errMsg | "duplicate:" `isInfixOf` errMsg = Duplicate @@ -165,6 +196,8 @@ noticeReason errMsg | "invalid:" `isInfixOf` errMsg = Invalid | otherwise = Error + +-- | Decodes a hex string to a byte string. decodeHex :: ConvertibleStrings a ByteString => a -> Maybe ByteString decodeHex str = case B16.decode $ cs str of @@ -224,28 +257,64 @@ instance ToJSON UnsignedEvent where , text content' ] - instance FromJSON Tag where - parseJSON v@(Array arr) - | V.length arr > 0 = - case arr V.! 0 of - String "e" -> do - eventId <- parseJSON (arr V.! 1) - relayURL <- parseJSON (fromMaybe Null $ arr V.!? 2) - marker <- parseJSON (fromMaybe Null $ arr V.!? 3) - return $ ETag eventId relayURL marker - String "p" -> do - pubKeyResult <- (Just <$> parseJSON (arr V.! 1)) <|> pure Nothing - case pubKeyResult of - Just pubKey -> do - relayURL <- parseJSON (fromMaybe Null $ arr V.!? 2) - name <- parseJSON (fromMaybe Null $ arr V.!? 3) - return $ PTag pubKey relayURL name - Nothing -> return $ UnknownTag v - _ -> return $ UnknownTag v - | otherwise = return $ UnknownTag v + parseJSON v@(Array arr) = + case V.toList arr of + ("e":rest) -> either (const $ return $ UnknownTag v) return $ parseEither (parseETag rest) v + ("p":rest) -> either (const $ return $ UnknownTag v) return $ parseEither (parsePTag rest) v + _ -> return $ UnknownTag v parseJSON v = return $ UnknownTag v +parseETag :: [Value] -> Value -> Parser Tag +parseETag rest _ = do + case rest of + [eventIdVal, relayVal, markerVal] -> do + eventId <- parseJSONSafe eventIdVal + relay <- parseMaybeRelayURI relayVal + marker <- parseMaybeRelationship markerVal + return $ ETag eventId relay marker + [eventIdVal, relayVal] -> do + eventId <- parseJSONSafe eventIdVal + relay <- parseMaybeRelayURI relayVal + return $ ETag eventId relay Nothing + [eventIdVal] -> do + eventId <- parseJSONSafe eventIdVal + return $ ETag eventId Nothing Nothing + _ -> fail "Invalid ETag format" + +parsePTag :: [Value] -> Value -> Parser Tag +parsePTag rest _ = do + case rest of + (pubKeyVal:relayVal:nameVal:_) -> do + pubKey <- parseJSONSafe pubKeyVal + relay <- parseMaybeRelayURI relayVal + name <- parseMaybeDisplayName nameVal + return $ PTag pubKey relay name + (pubKeyVal:relayVal:_) -> do + pubKey <- parseJSONSafe pubKeyVal + relay <- parseMaybeRelayURI relayVal + return $ PTag pubKey relay Nothing + (pubKeyVal:_) -> do + pubKey <- parseJSONSafe pubKeyVal + return $ PTag pubKey Nothing Nothing + _ -> fail "Invalid PTag format" + +parseJSONSafe :: FromJSON a => Value -> Parser a +parseJSONSafe v = case parseEither parseJSON v of + Left _ -> fail "Parsing failed" + Right x -> return x + +parseMaybeRelayURI :: Value -> Parser (Maybe RelayURI) +parseMaybeRelayURI Null = return Nothing +parseMaybeRelayURI v = (Just <$> parseJSONSafe v) <|> return Nothing + +parseMaybeRelationship :: Value -> Parser (Maybe Relationship) +parseMaybeRelationship Null = return Nothing +parseMaybeRelationship v = (Just <$> parseJSONSafe v) <|> return Nothing + +parseMaybeDisplayName :: Value -> Parser (Maybe DisplayName) +parseMaybeDisplayName Null = return Nothing +parseMaybeDisplayName v = (Just <$> parseJSONSafe v) <|> return Nothing instance ToJSON Tag where toEncoding (ETag eventId Nothing Nothing) = @@ -329,7 +398,7 @@ instance FromJSON RelayURI where parseJSON = withText "URI" $ \u -> case mkURI u of Just u' -> return $ RelayURI u' - Nothing -> fail "Invalid relay URI" + Nothing -> fail $ "Invalid relay URI: " ++ show u -- | Parses a JSON value into a `RelayURI`. instance ToJSON RelayURI where @@ -482,4 +551,4 @@ extractPath r = -- | Checks if two relays are the same based on URI. sameRelay :: Relay -> Relay -> Bool -sameRelay = (==) `on` uri \ No newline at end of file +sameRelay = (==) `on` uri diff --git a/src/Presentation/KeyMgmt.hs b/src/Presentation/KeyMgmt.hs index fbe18ed..0e5aa6e 100644 --- a/src/Presentation/KeyMgmt.hs +++ b/src/Presentation/KeyMgmt.hs @@ -32,6 +32,7 @@ import Effectful.State.Static.Shared (State, get, modify) import Effectful.TH import EffectfulQML import Graphics.QML hiding (fireSignal, runEngineLoop) +import Nostr.Bech32 import Nostr.Keys import Nostr.Types hiding (displayName, picture) import System.FilePath (takeFileName, ()) diff --git a/src/AppState.hs b/src/Types.hs similarity index 93% rename from src/AppState.hs rename to src/Types.hs index 4f10c5d..c0f0a8a 100644 --- a/src/AppState.hs +++ b/src/Types.hs @@ -1,4 +1,4 @@ -module AppState where +module Types where import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map @@ -64,6 +64,8 @@ data AppState = AppState , follows :: FollowModel , confirmations :: Map EventId [EventConfirmation] , currentChatRecipient :: Maybe PubKeyXO + , currentProfile :: Maybe PubKeyXO + , profileObjRef :: Maybe (ObjRef ()) , activeConnections :: Int } @@ -91,5 +93,7 @@ initialState = AppState , follows = FollowModel Map.empty Nothing , confirmations = Map.empty , currentChatRecipient = Nothing + , currentProfile = Nothing + , profileObjRef = Nothing , activeConnections = 0 } diff --git a/src/UI.hs b/src/UI.hs index ce7721a..f627215 100644 --- a/src/UI.hs +++ b/src/UI.hs @@ -10,9 +10,9 @@ import Data.ByteString.Lazy qualified as BSL import Data.List (find) import Data.Map.Strict qualified as Map import Data.Maybe (fromMaybe) +import Data.Proxy (Proxy(..)) import Data.Text (pack, unpack) import Data.Text.Encoding qualified as TE -import Data.Proxy (Proxy(..)) import Effectful import Effectful.Dispatch.Dynamic (interpret) import Effectful.State.Static.Shared (get, modify) @@ -21,16 +21,16 @@ import EffectfulQML import Graphics.QML hiding (fireSignal, runEngineLoop) import Text.Read (readMaybe) - -import AppState +import Nostr.Bech32 import Nostr.Event import Nostr.Effects.CurrentTime import Nostr.Effects.Logging import Nostr.Effects.RelayPool -import Nostr.Keys (PubKeyXO, bech32ToPubKeyXO, keyPairToPubKeyXO, pubKeyXOToBech32) +import Nostr.Keys (PubKeyXO, keyPairToPubKeyXO) import Nostr.Types (Profile(..), emptyProfile, relayURIToText) import Presentation.KeyMgmt qualified as PKeyMgmt -import Futr (Futr, FutrEff, LoginStatusChanged, login, logout) +import Futr (Futr, FutrEff, LoginStatusChanged, login, logout, search, setCurrentProfile) +import Types -- | Key Management Effect for creating QML UI. data UI :: Effect where @@ -50,6 +50,44 @@ runUI = interpret $ \_ -> \case CreateUI changeKey' -> withEffToIO (ConcUnlift Persistent Unlimited) $ \runE -> do keyMgmtObj <- runE $ PKeyMgmt.createUI changeKey' + profileClass <- newClass [ + defPropertySigRO' "name" changeKey' $ \_ -> do + st <- runE $ get @AppState + let pk = fromMaybe (error "No pubkey for current profile") $ currentProfile st + let (profile, _) = Map.findWithDefault (emptyProfile, 0) pk (profiles st) + return $ name profile, + + defPropertySigRO' "displayName" changeKey' $ \_ -> do + st <- runE $ get @AppState + let pk = fromMaybe (error "No pubkey for current profile") $ currentProfile st + let (profile, _) = Map.findWithDefault (emptyProfile, 0) pk (profiles st) + return $ displayName profile, + + defPropertySigRO' "about" changeKey' $ \_ -> do + st <- runE $ get @AppState + let pk = fromMaybe (error "No pubkey for current profile") $ currentProfile st + let (profile, _) = Map.findWithDefault (emptyProfile, 0) pk (profiles st) + return $ about profile, + + defPropertySigRO' "picture" changeKey' $ \_ -> do + st <- runE $ get @AppState + let pk = fromMaybe (error "No pubkey for current profile") $ currentProfile st + let (profile, _) = Map.findWithDefault (emptyProfile, 0) pk (profiles st) + return $ picture profile, + + defPropertySigRO' "nip05" changeKey' $ \_ -> do + st <- runE $ get @AppState + let pk = fromMaybe (error "No pubkey for current profile") $ currentProfile st + let (profile, _) = Map.findWithDefault (emptyProfile, 0) pk (profiles st) + return $ nip05 profile, + + defPropertySigRO' "banner" changeKey' $ \_ -> do + st <- runE $ get @AppState + let pk = fromMaybe (error "No pubkey for current profile") $ currentProfile st + let (profile, _) = Map.findWithDefault (emptyProfile, 0) pk (profiles st) + return $ banner profile + ] + let followProp name' accessor = defPropertySigRO' name' changeKey' $ \obj -> do let pubKeyXO = fromObjRef obj :: PubKeyXO st <- runE $ get @AppState @@ -71,6 +109,12 @@ runUI = interpret $ \_ -> \case let (profile', _) = Map.findWithDefault (emptyProfile, 0) (pubkey follow) (profiles st) in fromMaybe "" (displayName profile') Nothing -> "", + followProp "name" $ \st followMaybe -> + case followMaybe of + Just follow -> + let (profile', _) = Map.findWithDefault (emptyProfile, 0) (pubkey follow) (profiles st) + in fromMaybe "" (name profile') + Nothing -> "", followProp "picture" $ \st followMaybe -> case followMaybe of Just follow -> @@ -84,9 +128,15 @@ runUI = interpret $ \_ -> \case rootClass <- newClass [ defPropertyConst' "ctxKeyMgmt" (\_ -> return keyMgmtObj), + defPropertyConst' "currentProfile" (\_ -> do + profileObj <- newObject profileClass () + runE $ modify @AppState $ \st -> st { profileObjRef = Just profileObj } + return profileObj + ), + defPropertySigRW' "currentScreen" changeKey' (\_ -> do - st <- runE $ get :: IO AppState + st <- runE $ get @AppState return $ pack $ show $ currentScreen st) (\obj newScreen -> do case readMaybe (unpack newScreen) :: Maybe AppScreen of @@ -117,11 +167,11 @@ runUI = interpret $ \_ -> \case defMethod' "logout" $ \obj -> runE $ logout obj, - defMethod' "getProfile" $ \_ npub -> do - st <- runE $ get @AppState - let xo = maybe (error "Invalid bech32 public key") id $ bech32ToPubKeyXO npub - let (profile', _) = Map.findWithDefault (emptyProfile, 0) xo (profiles st) - return $ TE.decodeUtf8 $ BSL.toStrict $ encode profile', + defMethod' "search" $ \obj input -> runE $ do + res <- search obj input + return $ TE.decodeUtf8 $ BSL.toStrict $ encode res, + + defMethod' "setCurrentProfile" $ \_ npub -> runE $ setCurrentProfile npub, defMethod' "saveProfile" $ \_ input -> do let profile = maybe (error "Invalid profile JSON") id $ decode (BSL.fromStrict $ TE.encodeUtf8 input) :: Profile @@ -179,7 +229,8 @@ runUI = interpret $ \_ -> \case let pubKeyXO = maybe (error "Invalid bech32 public key") id $ bech32ToPubKeyXO npub modify $ \st -> st { currentChatRecipient = Just pubKeyXO } fireSignal obj - ] + + ] rootObj <- newObject rootClass ()