This commit is contained in:
Kieran 2023-01-12 09:48:39 +00:00
parent eb94a239e4
commit 593c8a4fa9
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
19 changed files with 396 additions and 30 deletions

View File

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

View File

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

26
src/element/DM.css Normal file
View File

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

41
src/element/DM.tsx Normal file
View File

@ -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<any>(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 (
<div className={`flex dm f-col${props.data.pubkey === pubKey ? " me" : ""}`}>
<div><NoteTime from={props.data.created_at * 1000} /></div>
<div className="w-max">
<Text content={content} />
</div>
</div>
)
}

View File

@ -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 (
<>
<b className="error">Deleted</b>
</>
);
return (<b className="error">Deleted</b>);
}
return fragments;
const mentions = (fragments) => {
return extractMentions(fragments, ev.Tags, users);
}
return <Text content={body} transforms={[mentions]} />;
}, [data, dataEvent, reactions, deletion]);
function goToEvent(e, id) {

View File

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

View File

@ -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 (
<div className={`pfp ${className ?? ""}`}>
<LazyImage src={hasImage ? user.picture : Nostrich} onClick={() => navigate(profileLink(pubkey))} />
<div className={`pfp ${className}`}>
<LazyImage src={hasImage ? user.picture : Nostrich} onClick={() => navigate(link ?? profileLink(pubkey))} />
{showUsername && (<div className="f-grow">
<Link key={pubkey} to={profileLink(pubkey)}>{name}</Link>
<Link key={pubkey} to={link ?? profileLink(pubkey)}>{name}</Link>
{subHeader ? <>{subHeader}</> : null}
</div>
</div>
)}
</div>
)

View File

@ -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 "<CANT DECRYPT>";
}
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 "<DECRYPTION FAILED>";
}
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);
}
}
}
}
}
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;
}
};

View File

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

View File

@ -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: <VerificationPage />
},
{
path: "/messages",
element: <MessagesPage />
},
{
path: "/messages/:id",
element: <ChatPage />
}
]
}

View File

@ -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"])
}
}

9
src/nostr/index.ts Normal file
View File

@ -0,0 +1,9 @@
export interface RawEvent {
id: string,
pubkey: string,
created_at: number,
kind: number,
tags: string[][],
content: string,
sig: string
}

32
src/pages/ChatPage.css Normal file
View File

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

64
src/pages/ChatPage.tsx Normal file
View File

@ -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<RouterParams>();
const publisher = useEventPublisher();
const id = bech32ToHex(params.id);
const dms = useSelector<any, RawEvent[]>(s => filterDms(s.login.dms, s.login.publicKey));
const [content, setContent] = useState<string>();
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<any[]>(() => {
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 (
<>
<ProfileImage pubkey={id} className="f-grow mb10" />
<div className="dm-list">
<div>
{sortedDms.slice(-10).map(a => <DM data={a} key={a.id} />)}
</div>
</div>
<div className="write-dm">
<div className="inner">
<textarea className="f-grow mr10" value={content} onChange={(e) => setContent(e.target.value)}></textarea>
<div className="btn" onClick={() => sendDm()}>Send</div>
</div>
</div>
</>
)
}

View File

@ -12,4 +12,4 @@
background-color: var(--error);
font-weight: bold;
text-align: center;
}
}

View File

@ -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 (
<>
<div className="btn btn-rnd notifications" onClick={(e) => goToNotifications(e)}>
<div className="btn btn-rnd mr10" onClick={(e) => navigate("/messages")}>
<FontAwesomeIcon icon={faMessage} size="xl" />
</div>
<div className={`btn btn-rnd${unreadNotifications === 0 ? " mr10" : ""}`} onClick={(e) => goToNotifications(e)}>
<FontAwesomeIcon icon={faBell} size="xl" />
</div>
<span className="unread-count">
{unreadNotifications > 1000 ? "..." : unreadNotifications}
</span>
{unreadNotifications > 0 && (<span className="unread-count">
{unreadNotifications > 100 ? ">99" : unreadNotifications}
</span>)}
<ProfileImage pubkey={key} showUsername={false} />
</>
)

View File

@ -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<any, string>(s => s.login.publicKey);
const dms = useSelector<any, RawEvent[]>(s => s.login.dms);
const pubKeys = useMemo(() => {
return Array.from(new Set<string>(dms.map(a => a.pubkey)));
}, [dms]);
function person(pubkey: string) {
return (
<div className="flex" key={pubkey}>
<ProfileImage pubkey={pubkey} className="f-grow" link={`/messages/${hexToBech32("npub", pubkey)}`} />
<span className="pill">
{dms?.filter(a => a.pubkey === pubkey && a.pubkey !== pubKey).length}
</span>
</div>
)
}
return (
<>
<h3>Messages</h3>
{pubKeys.map(person)}
</>
)
}

View File

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

View File

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