DMs
This commit is contained in:
parent
eb94a239e4
commit
593c8a4fa9
@ -7,6 +7,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@noble/secp256k1": "^1.7.0",
|
"@noble/secp256k1": "^1.7.0",
|
||||||
|
"@protobufjs/base64": "^1.1.2",
|
||||||
"@reduxjs/toolkit": "^1.9.1",
|
"@reduxjs/toolkit": "^1.9.1",
|
||||||
"@types/jest": "^29.2.5",
|
"@types/jest": "^29.2.5",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
|
17
src/Text.js
17
src/Text.js
@ -6,6 +6,7 @@ import { UrlRegex, FileExtensionRegex, MentionRegex, InvoiceRegex, YoutubeUrlReg
|
|||||||
import { eventLink, hexToBech32, profileLink } from "./Util";
|
import { eventLink, hexToBech32, profileLink } from "./Util";
|
||||||
import LazyImage from "./element/LazyImage";
|
import LazyImage from "./element/LazyImage";
|
||||||
import Hashtag from "./element/Hashtag";
|
import Hashtag from "./element/Hashtag";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
function transformHttpLink(a) {
|
function transformHttpLink(a) {
|
||||||
try {
|
try {
|
||||||
@ -135,3 +136,19 @@ export function extractHashtags(fragments) {
|
|||||||
return f;
|
return f;
|
||||||
}).flat();
|
}).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
26
src/element/DM.css
Normal 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
41
src/element/DM.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import Event from "../nostr/Event";
|
import Event from "../nostr/Event";
|
||||||
import ProfileImage from "./ProfileImage";
|
import ProfileImage from "./ProfileImage";
|
||||||
import { extractLinks, extractMentions, extractInvoices, extractHashtags } from "../Text";
|
import Text, { extractMentions } from "../Text";
|
||||||
import { eventLink, hexToBech32 } from "../Util";
|
import { eventLink, hexToBech32 } from "../Util";
|
||||||
import NoteFooter from "./NoteFooter";
|
import NoteFooter from "./NoteFooter";
|
||||||
import NoteTime from "./NoteTime";
|
import NoteTime from "./NoteTime";
|
||||||
@ -28,19 +28,13 @@ export default function Note(props) {
|
|||||||
|
|
||||||
const transformBody = useCallback(() => {
|
const transformBody = useCallback(() => {
|
||||||
let body = ev?.Content ?? "";
|
let body = ev?.Content ?? "";
|
||||||
|
|
||||||
let fragments = extractLinks([body]);
|
|
||||||
fragments = extractMentions(fragments, ev.Tags, users);
|
|
||||||
fragments = extractInvoices(fragments);
|
|
||||||
fragments = extractHashtags(fragments);
|
|
||||||
if (deletion?.length > 0) {
|
if (deletion?.length > 0) {
|
||||||
return (
|
return (<b className="error">Deleted</b>);
|
||||||
<>
|
|
||||||
<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]);
|
}, [data, dataEvent, reactions, deletion]);
|
||||||
|
|
||||||
function goToEvent(e, id) {
|
function goToEvent(e, id) {
|
||||||
|
@ -4,8 +4,7 @@ const MinuteInMs = 1_000 * 60;
|
|||||||
const HourInMs = MinuteInMs * 60;
|
const HourInMs = MinuteInMs * 60;
|
||||||
const DayInMs = HourInMs * 24;
|
const DayInMs = HourInMs * 24;
|
||||||
|
|
||||||
export default function NoteTime(props) {
|
export default function NoteTime({ from }) {
|
||||||
const from = props.from;
|
|
||||||
const [time, setTime] = useState("");
|
const [time, setTime] = useState("");
|
||||||
|
|
||||||
function calcTime() {
|
function calcTime() {
|
||||||
|
@ -7,7 +7,7 @@ import useProfile from "../feed/ProfileFeed";
|
|||||||
import { hexToBech32, profileLink } from "../Util";
|
import { hexToBech32, profileLink } from "../Util";
|
||||||
import LazyImage from "./LazyImage";
|
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 navigate = useNavigate();
|
||||||
const user = useProfile(pubkey);
|
const user = useProfile(pubkey);
|
||||||
|
|
||||||
@ -23,12 +23,12 @@ export default function ProfileImage({ pubkey, subHeader, showUsername = true, c
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`pfp ${className ?? ""}`}>
|
<div className={`pfp ${className}`}>
|
||||||
<LazyImage src={hasImage ? user.picture : Nostrich} onClick={() => navigate(profileLink(pubkey))} />
|
<LazyImage src={hasImage ? user.picture : Nostrich} onClick={() => navigate(link ?? profileLink(pubkey))} />
|
||||||
{showUsername && (<div className="f-grow">
|
{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}
|
{subHeader ? <>{subHeader}</> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -20,7 +20,7 @@ export default function useEventPublisher() {
|
|||||||
async function signEvent(ev) {
|
async function signEvent(ev) {
|
||||||
if (hasNip07 && !privKey) {
|
if (hasNip07 && !privKey) {
|
||||||
ev.Id = await ev.CreateId();
|
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);
|
return Event.FromObject(tmpEv);
|
||||||
} else {
|
} else {
|
||||||
await ev.Sign(privKey);
|
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(["e", replyTo.Id, "", "reply"], ev.Tags.length));
|
||||||
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
|
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
|
||||||
for (let pk of thread.PubKeys) {
|
for (let pk of thread.PubKeys) {
|
||||||
if(pk === pubKey) {
|
if (pk === pubKey) {
|
||||||
continue; // dont tag self in replies
|
continue; // dont tag self in replies
|
||||||
}
|
}
|
||||||
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
|
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(["e", note.Id]));
|
||||||
ev.Tags.push(new Tag(["p", note.PubKey]));
|
ev.Tags.push(new Tag(["p", note.PubKey]));
|
||||||
return await signEvent(ev);
|
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;
|
||||||
|
}
|
||||||
|
};
|
@ -2,7 +2,7 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
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 { setUserData } from "../state/Users";
|
||||||
import useSubscription from "./Subscription";
|
import useSubscription from "./Subscription";
|
||||||
import { mapEventToProfile } from "./UsersFeed";
|
import { mapEventToProfile } from "./UsersFeed";
|
||||||
@ -24,9 +24,11 @@ export default function useLoginFeed() {
|
|||||||
sub.Authors.add(pubKey);
|
sub.Authors.add(pubKey);
|
||||||
sub.Kinds.add(EventKind.ContactList);
|
sub.Kinds.add(EventKind.ContactList);
|
||||||
sub.Kinds.add(EventKind.SetMetadata);
|
sub.Kinds.add(EventKind.SetMetadata);
|
||||||
|
sub.Kinds.add(EventKind.DirectMessage);
|
||||||
|
|
||||||
let notifications = new Subscriptions();
|
let notifications = new Subscriptions();
|
||||||
notifications.Kinds.add(EventKind.TextNote);
|
notifications.Kinds.add(EventKind.TextNote);
|
||||||
|
notifications.Kinds.add(EventKind.DirectMessage);
|
||||||
notifications.PTags.add(pubKey);
|
notifications.PTags.add(pubKey);
|
||||||
notifications.Limit = 100;
|
notifications.Limit = 100;
|
||||||
sub.AddSubscription(notifications);
|
sub.AddSubscription(notifications);
|
||||||
@ -40,6 +42,7 @@ export default function useLoginFeed() {
|
|||||||
let contactList = notes.filter(a => a.kind === EventKind.ContactList);
|
let contactList = notes.filter(a => a.kind === EventKind.ContactList);
|
||||||
let notifications = notes.filter(a => a.kind === EventKind.TextNote);
|
let notifications = notes.filter(a => a.kind === EventKind.TextNote);
|
||||||
let metadata = notes.filter(a => a.kind === EventKind.SetMetadata).map(a => mapEventToProfile(a));
|
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) {
|
for (let cl of contactList) {
|
||||||
if (cl.content !== "") {
|
if (cl.content !== "") {
|
||||||
@ -58,5 +61,6 @@ export default function useLoginFeed() {
|
|||||||
}
|
}
|
||||||
dispatch(addNotifications(notifications));
|
dispatch(addNotifications(notifications));
|
||||||
dispatch(setUserData(metadata));
|
dispatch(setUserData(metadata));
|
||||||
|
dispatch(addDirectMessage(dms));
|
||||||
}, [notes]);
|
}, [notes]);
|
||||||
}
|
}
|
10
src/index.js
10
src/index.js
@ -20,6 +20,8 @@ import NewUserPage from './pages/NewUserPage';
|
|||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import ErrorPage from './pages/ErrorPage';
|
import ErrorPage from './pages/ErrorPage';
|
||||||
import VerificationPage from './pages/Verification';
|
import VerificationPage from './pages/Verification';
|
||||||
|
import MessagesPage from './pages/MessagesPage';
|
||||||
|
import ChatPage from './pages/ChatPage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nostr websocket managment system
|
* Nostr websocket managment system
|
||||||
@ -62,6 +64,14 @@ const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: "/verification",
|
path: "/verification",
|
||||||
element: <VerificationPage />
|
element: <VerificationPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/messages",
|
||||||
|
element: <MessagesPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/messages/:id",
|
||||||
|
element: <ChatPage />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import * as secp from '@noble/secp256k1';
|
import * as secp from '@noble/secp256k1';
|
||||||
|
import base64 from "@protobufjs/base64"
|
||||||
import EventKind from "./EventKind";
|
import EventKind from "./EventKind";
|
||||||
import Tag from './Tag';
|
import Tag from './Tag';
|
||||||
import Thread from './Thread';
|
import Thread from './Thread';
|
||||||
@ -165,4 +166,48 @@ export default class Event {
|
|||||||
ev.PubKey = pubKey;
|
ev.PubKey = pubKey;
|
||||||
return ev;
|
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
9
src/nostr/index.ts
Normal 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
32
src/pages/ChatPage.css
Normal 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
64
src/pages/ChatPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -12,4 +12,4 @@
|
|||||||
background-color: var(--error);
|
background-color: var(--error);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
@ -2,7 +2,7 @@ import "./Layout.css";
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { Outlet, useNavigate } from "react-router-dom";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { System } from ".."
|
import { System } from ".."
|
||||||
@ -57,12 +57,15 @@ export default function Layout(props) {
|
|||||||
const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length;
|
const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length;
|
||||||
return (
|
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" />
|
<FontAwesomeIcon icon={faBell} size="xl" />
|
||||||
</div>
|
</div>
|
||||||
<span className="unread-count">
|
{unreadNotifications > 0 && (<span className="unread-count">
|
||||||
{unreadNotifications > 1000 ? "..." : unreadNotifications}
|
{unreadNotifications > 100 ? ">99" : unreadNotifications}
|
||||||
</span>
|
</span>)}
|
||||||
<ProfileImage pubkey={key} showUsername={false} />
|
<ProfileImage pubkey={key} showUsername={false} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
36
src/pages/MessagesPage.tsx
Normal file
36
src/pages/MessagesPage.tsx
Normal 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)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -43,6 +43,11 @@ const LoginSlice = createSlice({
|
|||||||
* Timestamp of last read notification
|
* Timestamp of last read notification
|
||||||
*/
|
*/
|
||||||
readNotifications: 0,
|
readNotifications: 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted DM's
|
||||||
|
*/
|
||||||
|
dms: []
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
init: (state) => {
|
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) => {
|
logout: (state) => {
|
||||||
window.localStorage.removeItem(PrivateKeyItem);
|
window.localStorage.removeItem(PrivateKeyItem);
|
||||||
window.localStorage.removeItem(PublicKeyItem);
|
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;
|
export const reducer = LoginSlice.reducer;
|
@ -1594,6 +1594,11 @@
|
|||||||
schema-utils "^3.0.0"
|
schema-utils "^3.0.0"
|
||||||
source-map "^0.7.3"
|
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":
|
"@reduxjs/toolkit@^1.9.1":
|
||||||
version "1.9.1"
|
version "1.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.1.tgz#4c34dc4ddcec161535288c60da5c19c3ef15180e"
|
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.1.tgz#4c34dc4ddcec161535288c60da5c19c3ef15180e"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user