From 593c8a4fa93eb04e021675e61e836226b83b3ab9 Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 12 Jan 2023 09:48:39 +0000 Subject: [PATCH] DMs --- package.json | 1 + src/Text.js | 17 ++++++++++ src/element/DM.css | 26 +++++++++++++++ src/element/DM.tsx | 41 ++++++++++++++++++++++++ src/element/Note.js | 18 ++++------- src/element/NoteTime.js | 3 +- src/element/ProfileImage.js | 10 +++--- src/feed/EventPublisher.js | 62 +++++++++++++++++++++++++++++++++-- src/feed/LoginFeed.js | 6 +++- src/index.js | 10 ++++++ src/nostr/Event.js | 45 ++++++++++++++++++++++++++ src/nostr/index.ts | 9 ++++++ src/pages/ChatPage.css | 32 +++++++++++++++++++ src/pages/ChatPage.tsx | 64 +++++++++++++++++++++++++++++++++++++ src/pages/Layout.css | 2 +- src/pages/Layout.js | 13 +++++--- src/pages/MessagesPage.tsx | 36 +++++++++++++++++++++ src/state/Login.js | 26 ++++++++++++++- yarn.lock | 5 +++ 19 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 src/element/DM.css create mode 100644 src/element/DM.tsx create mode 100644 src/nostr/index.ts create mode 100644 src/pages/ChatPage.css create mode 100644 src/pages/ChatPage.tsx create mode 100644 src/pages/MessagesPage.tsx diff --git a/package.json b/package.json index bd74db73..22079ccc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-fontawesome": "^0.2.0", "@noble/secp256k1": "^1.7.0", + "@protobufjs/base64": "^1.1.2", "@reduxjs/toolkit": "^1.9.1", "@types/jest": "^29.2.5", "@types/node": "^18.11.18", diff --git a/src/Text.js b/src/Text.js index 883d5cbd..3cd71e3a 100644 --- a/src/Text.js +++ b/src/Text.js @@ -6,6 +6,7 @@ import { UrlRegex, FileExtensionRegex, MentionRegex, InvoiceRegex, YoutubeUrlReg import { eventLink, hexToBech32, profileLink } from "./Util"; import LazyImage from "./element/LazyImage"; import Hashtag from "./element/Hashtag"; +import { useMemo } from "react"; function transformHttpLink(a) { try { @@ -135,3 +136,19 @@ export function extractHashtags(fragments) { return f; }).flat(); } + +export default function Text({ content, transforms }) { + const transformed = useMemo(() => { + let fragments = [content]; + transforms?.forEach(a => { + fragments = a(fragments); + }); + fragments = extractLinks(fragments); + fragments = extractInvoices(fragments); + fragments = extractHashtags(fragments); + + return fragments; + }, [content]); + + return transformed; +} \ No newline at end of file diff --git a/src/element/DM.css b/src/element/DM.css new file mode 100644 index 00000000..cb3e343f --- /dev/null +++ b/src/element/DM.css @@ -0,0 +1,26 @@ +.dm { + padding: 8px; + background-color: var(--gray); + margin-bottom: 5px; + border-radius: 5px; + width: fit-content; + min-width: 100px; + max-width: 90%; + overflow: hidden; +} + +.dm > div:first-child { + color: var(--gray-light); + font-size: small; + margin-bottom: 3px; +} + +.dm.me { + align-self: flex-end; + background-color: var(--gray-secondary); +} + +.dm img, .dm video, .dm iframe { + max-width: 100%; + max-height: 500px; +} \ No newline at end of file diff --git a/src/element/DM.tsx b/src/element/DM.tsx new file mode 100644 index 00000000..328d13ed --- /dev/null +++ b/src/element/DM.tsx @@ -0,0 +1,41 @@ +import "./DM.css"; +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; + +// @ts-ignore +import useEventPublisher from "../feed/EventPublisher"; +// @ts-ignore +import Event from "../nostr/Event"; +// @ts-ignore +import NoteTime from "./NoteTime"; +// @ts-ignore +import Text from "../Text"; + +export type DMProps = { + data: any +} + +export default function DM(props: DMProps) { + const pubKey = useSelector(s => s.login.publicKey); + const publisher = useEventPublisher(); + const [content, setContent] = useState("Loading..."); + + async function decrypt() { + let e = Event.FromObject(props.data); + let decrypted = await publisher.decryptDm(e); + setContent(decrypted); + } + + useEffect(() => { + decrypt().catch(console.error); + }, [props.data]); + + return ( +
+
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/element/Note.js b/src/element/Note.js index c24d79ed..f51fd502 100644 --- a/src/element/Note.js +++ b/src/element/Note.js @@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom"; import Event from "../nostr/Event"; import ProfileImage from "./ProfileImage"; -import { extractLinks, extractMentions, extractInvoices, extractHashtags } from "../Text"; +import Text, { extractMentions } from "../Text"; import { eventLink, hexToBech32 } from "../Util"; import NoteFooter from "./NoteFooter"; import NoteTime from "./NoteTime"; @@ -28,19 +28,13 @@ export default function Note(props) { const transformBody = useCallback(() => { let body = ev?.Content ?? ""; - - let fragments = extractLinks([body]); - fragments = extractMentions(fragments, ev.Tags, users); - fragments = extractInvoices(fragments); - fragments = extractHashtags(fragments); if (deletion?.length > 0) { - return ( - <> - Deleted - - ); + return (Deleted); } - return fragments; + const mentions = (fragments) => { + return extractMentions(fragments, ev.Tags, users); + } + return ; }, [data, dataEvent, reactions, deletion]); function goToEvent(e, id) { diff --git a/src/element/NoteTime.js b/src/element/NoteTime.js index 9b2ebc77..dcdc94d1 100644 --- a/src/element/NoteTime.js +++ b/src/element/NoteTime.js @@ -4,8 +4,7 @@ const MinuteInMs = 1_000 * 60; const HourInMs = MinuteInMs * 60; const DayInMs = HourInMs * 24; -export default function NoteTime(props) { - const from = props.from; +export default function NoteTime({ from }) { const [time, setTime] = useState(""); function calcTime() { diff --git a/src/element/ProfileImage.js b/src/element/ProfileImage.js index cdc35582..828c583c 100644 --- a/src/element/ProfileImage.js +++ b/src/element/ProfileImage.js @@ -7,7 +7,7 @@ import useProfile from "../feed/ProfileFeed"; import { hexToBech32, profileLink } from "../Util"; import LazyImage from "./LazyImage"; -export default function ProfileImage({ pubkey, subHeader, showUsername = true, className }) { +export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }) { const navigate = useNavigate(); const user = useProfile(pubkey); @@ -23,12 +23,12 @@ export default function ProfileImage({ pubkey, subHeader, showUsername = true, c }, [user]); return ( -
- navigate(profileLink(pubkey))} /> +
+ navigate(link ?? profileLink(pubkey))} /> {showUsername && (
- {name} + {name} {subHeader ? <>{subHeader} : null} -
+
)}
) diff --git a/src/feed/EventPublisher.js b/src/feed/EventPublisher.js index 27edb674..1e51025b 100644 --- a/src/feed/EventPublisher.js +++ b/src/feed/EventPublisher.js @@ -20,7 +20,7 @@ export default function useEventPublisher() { async function signEvent(ev) { if (hasNip07 && !privKey) { ev.Id = await ev.CreateId(); - let tmpEv = await window.nostr.signEvent(ev.ToObject()); + let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject())); return Event.FromObject(tmpEv); } else { await ev.Sign(privKey); @@ -72,7 +72,7 @@ export default function useEventPublisher() { ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length)); ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length)); for (let pk of thread.PubKeys) { - if(pk === pubKey) { + if (pk === pubKey) { continue; // dont tag self in replies } ev.Tags.push(new Tag(["p", pk], ev.Tags.length)); @@ -152,6 +152,62 @@ export default function useEventPublisher() { ev.Tags.push(new Tag(["e", note.Id])); ev.Tags.push(new Tag(["p", note.PubKey])); return await signEvent(ev); + }, + decryptDm: async (note) => { + if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) { + return ""; + } + try { + let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey : note.PubKey; + if (hasNip07 && !privKey) { + return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content)); + } else if(privKey) { + await note.DecryptDm(privKey, otherPubKey); + return note.Content; + } + } catch (e) { + console.error("Decyrption failed", e); + return ""; + } + return "test"; + }, + sendDm: async (content, to) => { + let ev = Event.ForPubKey(pubKey); + ev.Kind = EventKind.DirectMessage; + ev.Content = content; + ev.Tags.push(new Tag(["p", to])); + + try { + if (hasNip07 && !privKey) { + let ev = await barierNip07(() => window.nostr.nip04.encrypt(to, content)); + return await signEvent(ev); + } else if(privKey) { + await ev.EncryptDmForPubkey(to, privKey); + return await signEvent(ev); + } + } catch (e) { + console.error("Encryption failed", e); + } } } -} \ No newline at end of file +} + +let isNip07Busy = false; + +const delay = (t) => { + return new Promise((resolve, reject) => { + setTimeout(resolve, t); + }); +} + +const barierNip07 = async (then) => { + while (isNip07Busy) { + await delay(10); + } + isNip07Busy = true; + try { + return await then(); + } finally { + isNip07Busy = false; + } +}; \ No newline at end of file diff --git a/src/feed/LoginFeed.js b/src/feed/LoginFeed.js index 629b17f2..ab121cbc 100644 --- a/src/feed/LoginFeed.js +++ b/src/feed/LoginFeed.js @@ -2,7 +2,7 @@ import { useEffect, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; import EventKind from "../nostr/EventKind"; import { Subscriptions } from "../nostr/Subscriptions"; -import { addNotifications, setFollows, setRelays } from "../state/Login"; +import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login"; import { setUserData } from "../state/Users"; import useSubscription from "./Subscription"; import { mapEventToProfile } from "./UsersFeed"; @@ -24,9 +24,11 @@ export default function useLoginFeed() { sub.Authors.add(pubKey); sub.Kinds.add(EventKind.ContactList); sub.Kinds.add(EventKind.SetMetadata); + sub.Kinds.add(EventKind.DirectMessage); let notifications = new Subscriptions(); notifications.Kinds.add(EventKind.TextNote); + notifications.Kinds.add(EventKind.DirectMessage); notifications.PTags.add(pubKey); notifications.Limit = 100; sub.AddSubscription(notifications); @@ -40,6 +42,7 @@ export default function useLoginFeed() { let contactList = notes.filter(a => a.kind === EventKind.ContactList); let notifications = notes.filter(a => a.kind === EventKind.TextNote); let metadata = notes.filter(a => a.kind === EventKind.SetMetadata).map(a => mapEventToProfile(a)); + let dms = notes.filter(a => a.kind === EventKind.DirectMessage); for (let cl of contactList) { if (cl.content !== "") { @@ -58,5 +61,6 @@ export default function useLoginFeed() { } dispatch(addNotifications(notifications)); dispatch(setUserData(metadata)); + dispatch(addDirectMessage(dms)); }, [notes]); } \ No newline at end of file diff --git a/src/index.js b/src/index.js index 10ec5f17..e834baa9 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,8 @@ import NewUserPage from './pages/NewUserPage'; import SettingsPage from './pages/SettingsPage'; import ErrorPage from './pages/ErrorPage'; import VerificationPage from './pages/Verification'; +import MessagesPage from './pages/MessagesPage'; +import ChatPage from './pages/ChatPage'; /** * Nostr websocket managment system @@ -62,6 +64,14 @@ const router = createBrowserRouter([ { path: "/verification", element: + }, + { + path: "/messages", + element: + }, + { + path: "/messages/:id", + element: } ] } diff --git a/src/nostr/Event.js b/src/nostr/Event.js index 058b462e..88f07d47 100644 --- a/src/nostr/Event.js +++ b/src/nostr/Event.js @@ -1,4 +1,5 @@ import * as secp from '@noble/secp256k1'; +import base64 from "@protobufjs/base64" import EventKind from "./EventKind"; import Tag from './Tag'; import Thread from './Thread'; @@ -165,4 +166,48 @@ export default class Event { ev.PubKey = pubKey; return ev; } + + /** + * Encrypt the message content in place + * @param {string} pubkey + * @param {string} privkey + */ + async EncryptDmForPubkey(pubkey, privkey) { + let key = await this._GetDmSharedKey(pubkey, privkey); + let iv = window.crypto.getRandomValues(new Uint8Array(16)); + let data = new TextEncoder().encode(this.Content); + let result = await window.crypto.subtle.encrypt({ + name: "AES-CBC", + iv: iv + }, key, data); + let uData = new Uint8Array(result); + this.Content = `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`; + } + + /** + * Decrypt the content of this message in place + * @param {string} privkey + * @param {string} pubkey + */ + async DecryptDm(privkey, pubkey) { + let key = await this._GetDmSharedKey(pubkey, privkey); + let cSplit = this.Content.split("?iv="); + let data = new Uint8Array(base64.length(cSplit[0])); + base64.decode(cSplit[0], data, 0); + + let iv = new Uint8Array(base64.length(cSplit[1])); + base64.decode(cSplit[1], iv, 0); + + let result = await window.crypto.subtle.decrypt({ + name: "AES-CBC", + iv: iv + }, key, data); + this.Content = new TextDecoder().decode(result); + } + + async _GetDmSharedKey(pubkey, privkey) { + let sharedPoint = secp.getSharedSecret(privkey, '02' + pubkey); + let sharedX = sharedPoint.slice(1, 33); + return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]) + } } \ No newline at end of file diff --git a/src/nostr/index.ts b/src/nostr/index.ts new file mode 100644 index 00000000..ce45564e --- /dev/null +++ b/src/nostr/index.ts @@ -0,0 +1,9 @@ +export interface RawEvent { + id: string, + pubkey: string, + created_at: number, + kind: number, + tags: string[][], + content: string, + sig: string +} \ No newline at end of file diff --git a/src/pages/ChatPage.css b/src/pages/ChatPage.css new file mode 100644 index 00000000..2e49d634 --- /dev/null +++ b/src/pages/ChatPage.css @@ -0,0 +1,32 @@ +.dm-list { + overflow-y: auto; + overflow-x: hidden; + height: calc(100vh - 66px - 50px - 70px); +} + +.dm-list > div { + display: flex; + flex-direction: column; + margin-bottom: 10px; +} + +.write-dm { + position: fixed; + bottom: 0; + background-color: var(--gray-light); + width: inherit; + border-radius: 5px 5px 0 0; +} + +.write-dm .inner { + display: flex; + align-items: center; + padding: 10px 5px; +} +.write-dm textarea { + resize: none; +} + +.write-dm-spacer { + margin-bottom: 80px; +} \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx new file mode 100644 index 00000000..4db43e63 --- /dev/null +++ b/src/pages/ChatPage.tsx @@ -0,0 +1,64 @@ +import "./ChatPage.css"; +import { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { useParams } from "react-router-dom"; + +// @ts-ignore +import ProfileImage from "../element/ProfileImage"; +// @ts-ignore +import { bech32ToHex } from "../Util"; +// @ts-ignore +import useEventPublisher from "../feed/EventPublisher"; + +import DM from "../element/DM"; +import { RawEvent } from "../nostr"; + +type RouterParams = { + id: string +} + +export default function ChatPage() { + const params = useParams(); + const publisher = useEventPublisher(); + const id = bech32ToHex(params.id); + const dms = useSelector(s => filterDms(s.login.dms, s.login.publicKey)); + const [content, setContent] = useState(); + + function filterDms(dms: RawEvent[], myPubkey: string) { + return dms.filter(a => { + if (a.pubkey === myPubkey && a.tags.some(b => b[0] === "p" && b[1] === id)) { + return true; + } else if (a.pubkey === id && a.tags.some(b => b[0] === "p" && b[1] === myPubkey)) { + return true; + } + return false; + }); + } + + const sortedDms = useMemo(() => { + return [...dms].sort((a, b) => a.created_at - b.created_at) + }, [dms]); + + async function sendDm() { + let ev = await publisher.sendDm(content, id); + console.debug(ev); + publisher.broadcast(ev); + } + + return ( + <> + +
+
+ {sortedDms.slice(-10).map(a => )} +
+
+
+
+ +
sendDm()}>Send
+
+
+ + ) +} \ No newline at end of file diff --git a/src/pages/Layout.css b/src/pages/Layout.css index 9add2f2a..7725e9b8 100644 --- a/src/pages/Layout.css +++ b/src/pages/Layout.css @@ -12,4 +12,4 @@ background-color: var(--error); font-weight: bold; text-align: center; -} +} \ No newline at end of file diff --git a/src/pages/Layout.js b/src/pages/Layout.js index d853941e..1a9a7137 100644 --- a/src/pages/Layout.js +++ b/src/pages/Layout.js @@ -2,7 +2,7 @@ import "./Layout.css"; import { useEffect } from "react" import { useDispatch, useSelector } from "react-redux"; import { Outlet, useNavigate } from "react-router-dom"; -import { faBell } from "@fortawesome/free-solid-svg-icons"; +import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { System } from ".." @@ -57,12 +57,15 @@ export default function Layout(props) { const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length; return ( <> -
goToNotifications(e)}> +
navigate("/messages")}> + +
+
goToNotifications(e)}>
- - {unreadNotifications > 1000 ? "..." : unreadNotifications} - + {unreadNotifications > 0 && ( + {unreadNotifications > 100 ? ">99" : unreadNotifications} + )} ) diff --git a/src/pages/MessagesPage.tsx b/src/pages/MessagesPage.tsx new file mode 100644 index 00000000..d8785cdc --- /dev/null +++ b/src/pages/MessagesPage.tsx @@ -0,0 +1,36 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux" + +import { RawEvent } from "../nostr"; + +// @ts-ignore +import ProfileImage from "../element/ProfileImage"; +// @ts-ignore +import { hexToBech32 } from "../Util"; + +export default function MessagesPage() { + const pubKey = useSelector(s => s.login.publicKey); + const dms = useSelector(s => s.login.dms); + + const pubKeys = useMemo(() => { + return Array.from(new Set(dms.map(a => a.pubkey))); + }, [dms]); + + function person(pubkey: string) { + return ( +
+ + + {dms?.filter(a => a.pubkey === pubkey && a.pubkey !== pubKey).length} + +
+ ) + } + + return ( + <> +

Messages

+ {pubKeys.map(person)} + + ) +} \ No newline at end of file diff --git a/src/state/Login.js b/src/state/Login.js index 1c812686..c616cb11 100644 --- a/src/state/Login.js +++ b/src/state/Login.js @@ -43,6 +43,11 @@ const LoginSlice = createSlice({ * Timestamp of last read notification */ readNotifications: 0, + + /** + * Encrypted DM's + */ + dms: [] }, reducers: { init: (state) => { @@ -126,6 +131,25 @@ const LoginSlice = createSlice({ ]; } }, + addDirectMessage: (state, action) => { + let n = action.payload; + if (!Array.isArray(n)) { + n = [n]; + } + + let didChange = false; + for (let x of n) { + if (!state.dms.some(a => a.id === x.id)) { + state.dms.push(x); + didChange = true; + } + } + if (didChange) { + state.dms = [ + ...state.dms + ]; + } + }, logout: (state) => { window.localStorage.removeItem(PrivateKeyItem); window.localStorage.removeItem(PublicKeyItem); @@ -144,5 +168,5 @@ const LoginSlice = createSlice({ } }); -export const { init, setPrivateKey, setPublicKey, setRelays, removeRelay, setFollows, addNotifications, logout, markNotificationsRead } = LoginSlice.actions; +export const { init, setPrivateKey, setPublicKey, setRelays, removeRelay, setFollows, addNotifications, addDirectMessage, logout, markNotificationsRead } = LoginSlice.actions; export const reducer = LoginSlice.reducer; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 114dfd8f..ea9ad38f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1594,6 +1594,11 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + "@reduxjs/toolkit@^1.9.1": version "1.9.1" resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.1.tgz#4c34dc4ddcec161535288c60da5c19c3ef15180e"