From 234c1c092d0aefc507482385065c94e15e49de9c Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 20 Jun 2023 14:15:33 +0100 Subject: [PATCH] Chat system refactor --- .gitignore | 3 +- .../src/Cache/{DMCache.ts => ChatCache.ts} | 12 +- packages/app/src/Cache/index.ts | 6 +- packages/app/src/Db/index.ts | 6 +- packages/app/src/Element/DM.tsx | 36 +++-- packages/app/src/Element/DmWindow.css | 2 + packages/app/src/Element/DmWindow.tsx | 73 ++++------ packages/app/src/Element/ProfileImage.tsx | 8 +- .../Element/{WriteDm.tsx => WriteMessage.tsx} | 17 ++- packages/app/src/Feed/LoginFeed.ts | 17 ++- packages/app/src/Hooks/useDmsCache.tsx | 9 -- packages/app/src/Pages/Layout.tsx | 15 +- packages/app/src/Pages/MessagesPage.tsx | 134 +++--------------- packages/app/src/chat/index.ts | 90 ++++++++++++ packages/app/src/chat/nip29.ts | 81 +++++++++++ packages/app/src/chat/nip4.ts | 65 +++++++++ packages/shared/src/external-store.ts | 4 +- packages/system/src/EventKind.ts | 2 + packages/system/src/Nostr.ts | 28 ++-- packages/system/src/NostrSystem.ts | 33 +++-- packages/system/src/ProfileCache.ts | 10 +- packages/system/src/RequestBuilder.ts | 9 +- 22 files changed, 397 insertions(+), 263 deletions(-) rename packages/app/src/Cache/{DMCache.ts => ChatCache.ts} (75%) rename packages/app/src/Element/{WriteDm.tsx => WriteMessage.tsx} (85%) delete mode 100644 packages/app/src/Hooks/useDmsCache.tsx create mode 100644 packages/app/src/chat/index.ts create mode 100644 packages/app/src/chat/nip29.ts create mode 100644 packages/app/src/chat/nip4.ts diff --git a/.gitignore b/.gitignore index 3fb207a93..5eb38cb1d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules/ yarn.lock dist/ *.tgz -*.log \ No newline at end of file +*.log +.DS_Store \ No newline at end of file diff --git a/packages/app/src/Cache/DMCache.ts b/packages/app/src/Cache/ChatCache.ts similarity index 75% rename from packages/app/src/Cache/DMCache.ts rename to packages/app/src/Cache/ChatCache.ts index 0f1c693cf..487383868 100644 --- a/packages/app/src/Cache/DMCache.ts +++ b/packages/app/src/Cache/ChatCache.ts @@ -2,9 +2,9 @@ import { NostrEvent } from "@snort/system"; import { FeedCache } from "@snort/shared"; import { db } from "Db"; -class DMCache extends FeedCache { +export class ChatCache extends FeedCache { constructor() { - super("DMCache", db.dms); + super("ChatCache", db.chats); } key(of: NostrEvent): string { @@ -23,13 +23,7 @@ class DMCache extends FeedCache { return ret; } - allDms(): Array { + takeSnapshot(): Array { return [...this.cache.values()]; } - - takeSnapshot(): Array { - return this.allDms(); - } } - -export const DmCache = new DMCache(); diff --git a/packages/app/src/Cache/index.ts b/packages/app/src/Cache/index.ts index 8156b669e..f872ea4d1 100644 --- a/packages/app/src/Cache/index.ts +++ b/packages/app/src/Cache/index.ts @@ -1,16 +1,16 @@ import { UserProfileCache, UserRelaysCache, RelayMetricCache } from "@snort/system"; -import { DmCache } from "./DMCache"; import { InteractionCache } from "./EventInteractionCache"; +import { ChatCache } from "./ChatCache"; export const UserCache = new UserProfileCache(); export const UserRelays = new UserRelaysCache(); export const RelayMetrics = new RelayMetricCache(); -export { DmCache }; +export const Chats = new ChatCache(); export async function preload(follows?: Array) { const preloads = [ UserCache.preload(follows), - DmCache.preload(), + Chats.preload(), InteractionCache.preload(), UserRelays.preload(follows), RelayMetrics.preload(), diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index 4ce448d32..c589fb547 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie"; import { HexKey, NostrEvent, u256 } from "@snort/system"; export const NAME = "snortDB"; -export const VERSION = 10; +export const VERSION = 11; export interface SubCache { id: string; @@ -28,14 +28,14 @@ export interface Payment { } const STORES = { - dms: "++id, pubkey", + chats: "++id", eventInteraction: "++id", payments: "++url", }; export class SnortDB extends Dexie { ready = false; - dms!: Table; + chats!: Table; eventInteraction!: Table; payments!: Table; diff --git a/packages/app/src/Element/DM.tsx b/packages/app/src/Element/DM.tsx index 2a8e9efac..f57458177 100644 --- a/packages/app/src/Element/DM.tsx +++ b/packages/app/src/Element/DM.tsx @@ -2,55 +2,65 @@ import "./DM.css"; import { useEffect, useState } from "react"; import { useIntl } from "react-intl"; import { useInView } from "react-intersection-observer"; -import { TaggedRawEvent } from "@snort/system"; +import { EventKind, TaggedRawEvent } from "@snort/system"; import useEventPublisher from "Feed/EventPublisher"; import NoteTime from "Element/NoteTime"; import Text from "Element/Text"; -import { setLastReadDm } from "Pages/MessagesPage"; -import { unwrap } from "SnortUtils"; import useLogin from "Hooks/useLogin"; +import { Chat, ChatType, chatTo, setLastReadIn } from "chat"; import messages from "./messages"; +import ProfileImage from "./ProfileImage"; -export type DMProps = { +export interface DMProps { + chat: Chat; data: TaggedRawEvent; -}; +} export default function DM(props: DMProps) { const pubKey = useLogin().publicKey; const publisher = useEventPublisher(); - const [content, setContent] = useState("Loading..."); + const ev = props.data; + const needsDecryption = ev.kind === EventKind.DirectMessage; + const [content, setContent] = useState(needsDecryption ? "Loading..." : ev.content); const [decrypted, setDecrypted] = useState(false); const { ref, inView } = useInView(); const { formatMessage } = useIntl(); - const isMe = props.data.pubkey === pubKey; - const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]); + const isMe = ev.pubkey === pubKey; + const otherPubkey = isMe ? pubKey : chatTo(ev); async function decrypt() { if (publisher) { - const decrypted = await publisher.decryptDm(props.data); + const decrypted = await publisher.decryptDm(ev); setContent(decrypted || ""); if (!isMe) { - setLastReadDm(props.data.pubkey); + setLastReadIn(ev.pubkey); } } } + function sender() { + if (props.chat.type !== ChatType.DirectMessage && !isMe) { + return ; + } + } + useEffect(() => { - if (!decrypted && inView) { + if (!decrypted && inView && needsDecryption) { setDecrypted(true); decrypt().catch(console.error); } - }, [inView, props.data]); + }, [inView, ev]); return (
+ {sender()}
- +
); diff --git a/packages/app/src/Element/DmWindow.css b/packages/app/src/Element/DmWindow.css index 2fe1afee0..8a75ff5b7 100644 --- a/packages/app/src/Element/DmWindow.css +++ b/packages/app/src/Element/DmWindow.css @@ -8,6 +8,8 @@ overflow-y: auto; padding: 0 10px 10px 10px; flex-grow: 1; + display: flex; + flex-direction: column-reverse; } .dm-window > div:nth-child(3) { diff --git a/packages/app/src/Element/DmWindow.tsx b/packages/app/src/Element/DmWindow.tsx index b56e95c0e..5821845e2 100644 --- a/packages/app/src/Element/DmWindow.tsx +++ b/packages/app/src/Element/DmWindow.tsx @@ -1,85 +1,60 @@ import "./DmWindow.css"; -import { useEffect, useMemo, useRef } from "react"; +import { useMemo } from "react"; import { TaggedRawEvent } from "@snort/system"; import ProfileImage from "Element/ProfileImage"; import DM from "Element/DM"; -import { dmsForLogin, dmsInChat, isToSelf } from "Pages/MessagesPage"; import NoteToSelf from "Element/NoteToSelf"; -import { useDmCache } from "Hooks/useDmsCache"; import useLogin from "Hooks/useLogin"; -import WriteDm from "Element/WriteDm"; -import { unwrap } from "SnortUtils"; +import WriteMessage from "Element/WriteMessage"; +import { Chat, ChatType, useChatSystem } from "chat"; export default function DmWindow({ id }: { id: string }) { const pubKey = useLogin().publicKey; - const dmListRef = useRef(null); + const dms = useChatSystem(); + const chat = dms.find(a => a.id === id); - function resize(chatList: HTMLDivElement) { - if (!chatList.parentElement) return; - - const scrollWrap = unwrap(chatList.parentElement); - const h = scrollWrap.scrollHeight; - const s = scrollWrap.clientHeight + scrollWrap.scrollTop; - const pos = Math.abs(h - s); - const atBottom = pos === 0; - //console.debug("Resize", h, s, pos, atBottom); - if (atBottom) { - scrollWrap.scrollTo(0, scrollWrap.scrollHeight); + function sender() { + if (id === pubKey) { + return ; } + if (chat?.type === ChatType.DirectMessage) { + return ; + } + if (chat?.profile) { + return ; + } + return ; } - useEffect(() => { - if (dmListRef.current) { - const scrollWrap = dmListRef.current; - const chatList = unwrap(scrollWrap.parentElement); - chatList.onscroll = () => { - resize(dmListRef.current as HTMLDivElement); - }; - new ResizeObserver(() => resize(dmListRef.current as HTMLDivElement)).observe(scrollWrap); - return () => { - chatList.onscroll = null; - new ResizeObserver(() => resize(dmListRef.current as HTMLDivElement)).unobserve(scrollWrap); - }; - } - }, [dmListRef]); - return (
+
{sender()}
- {(id === pubKey && ) || ( - - )} +
{chat && }
-
- -
-
-
- +
); } -function DmChatSelected({ chatPubKey }: { chatPubKey: string }) { - const dms = useDmCache(); +function DmChatSelected({ chat }: { chat: Chat }) { const { publicKey: myPubKey } = useLogin(); const sortedDms = useMemo(() => { - if (myPubKey) { - const myDms = dmsForLogin(dms, myPubKey); + const myDms = chat?.messages; + if (myPubKey && myDms) { // filter dms in this chat, or dms to self - const thisDms = myPubKey === chatPubKey ? myDms.filter(d => isToSelf(d, myPubKey)) : myDms; - return [...dmsInChat(thisDms, chatPubKey)].sort((a, b) => a.created_at - b.created_at); + return [...myDms].sort((a, b) => a.created_at - b.created_at); } return []; - }, [dms, myPubKey, chatPubKey]); + }, [chat, myPubKey]); return ( <> {sortedDms.map(a => ( - + ))} ); diff --git a/packages/app/src/Element/ProfileImage.tsx b/packages/app/src/Element/ProfileImage.tsx index 22b8e133c..203dd9e31 100644 --- a/packages/app/src/Element/ProfileImage.tsx +++ b/packages/app/src/Element/ProfileImage.tsx @@ -2,7 +2,7 @@ import "./ProfileImage.css"; import React, { useMemo } from "react"; import { Link } from "react-router-dom"; -import { HexKey, NostrPrefix, MetadataCache } from "@snort/system"; +import { HexKey, NostrPrefix, UserMetadata } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; import { hexToBech32, profileLink } from "SnortUtils"; @@ -19,6 +19,7 @@ export interface ProfileImageProps { defaultNip?: string; verifyNip?: boolean; overrideUsername?: string; + profile?: UserMetadata; } export default function ProfileImage({ @@ -30,8 +31,9 @@ export default function ProfileImage({ defaultNip, verifyNip, overrideUsername, + profile, }: ProfileImageProps) { - const user = useUserProfile(System, pubkey); + const user = profile ?? useUserProfile(System, pubkey); const nip05 = defaultNip ? defaultNip : user?.nip05; const name = useMemo(() => { @@ -66,7 +68,7 @@ export default function ProfileImage({ ); } -export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) { +export function getDisplayName(user: UserMetadata | undefined, pubkey: HexKey) { let name = hexToBech32(NostrPrefix.PublicKey, pubkey).substring(0, 12); if (typeof user?.display_name === "string" && user.display_name.length > 0) { name = user.display_name; diff --git a/packages/app/src/Element/WriteDm.tsx b/packages/app/src/Element/WriteMessage.tsx similarity index 85% rename from packages/app/src/Element/WriteDm.tsx rename to packages/app/src/Element/WriteMessage.tsx index 9c6ede07b..490009ce3 100644 --- a/packages/app/src/Element/WriteDm.tsx +++ b/packages/app/src/Element/WriteMessage.tsx @@ -7,8 +7,9 @@ import useFileUpload from "Upload"; import { openFile } from "SnortUtils"; import Textarea from "./Textarea"; import { System } from "index"; +import { useChatSystem } from "chat"; -export default function WriteDm({ chatPubKey }: { chatPubKey: string }) { +export default function WriteMessage({ chatId }: { chatId: string }) { const [msg, setMsg] = useState(""); const [sending, setSending] = useState(false); const [uploading, setUploading] = useState(false); @@ -16,6 +17,7 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) { const [error, setError] = useState(""); const publisher = useEventPublisher(); const uploader = useFileUpload(); + const chat = useChatSystem().find(a => a.id === chatId); async function attachFile() { try { @@ -54,11 +56,11 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) { } } - async function sendDm() { - if (msg && publisher) { + async function sendMessage() { + if (msg && publisher && chat) { setSending(true); - const ev = await publisher.sendDm(msg, chatPubKey); - System.BroadcastEvent(ev); + const ev = await chat.createMessage(msg, publisher); + await chat.sendMessage(ev, System); setMsg(""); setSending(false); } @@ -73,7 +75,8 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) { async function onEnter(e: React.KeyboardEvent) { const isEnter = e.code === "Enter"; if (isEnter && !e.shiftKey) { - await sendDm(); + e.preventDefault(); + await sendMessage(); } } @@ -96,7 +99,7 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) { /> {error && {error}} - diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 2f740192f..2b8f3c87e 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -1,6 +1,5 @@ import { useEffect, useMemo } from "react"; import { TaggedRawEvent, Lists, EventKind, FlatNoteStore, RequestBuilder } from "@snort/system"; -import debug from "debug"; import { useRequestBuilder } from "@snort/system-react"; import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils"; @@ -8,7 +7,6 @@ import { makeNotification, sendNotification } from "Notifications"; import useEventPublisher from "Feed/EventPublisher"; import { getMutedKeys } from "Feed/MuteList"; import useModeration from "Hooks/useModeration"; -import { DmCache } from "Cache"; import useLogin from "Hooks/useLogin"; import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login"; import { SnortPubKey } from "Const"; @@ -16,6 +14,7 @@ import { SubscriptionEvent } from "Subscription"; import useRelaysFeedFollows from "./RelaysFeedFollows"; import { UserRelays } from "Cache"; import { System } from "index"; +import { Nip29Chats, Nip4Chats } from "chat"; /** * Managed loading data for the current logged in user @@ -41,10 +40,9 @@ export default function useLoginFeed() { .tag("p", [pubKey]) .limit(1); - const dmSince = DmCache.newest(); - debug("LoginFeed")("Loading dms since %s", new Date(dmSince * 1000).toISOString()); - b.withFilter().authors([pubKey]).kinds([EventKind.DirectMessage]).since(dmSince); - b.withFilter().kinds([EventKind.DirectMessage]).tag("p", [pubKey]).since(dmSince); + b.add(Nip4Chats.subscription(pubKey)); + b.add(Nip29Chats.subscription("n29.nostr.com/")); + return b; }, [pubKey]); @@ -78,7 +76,12 @@ export default function useLoginFeed() { } const dms = loginFeed.data.filter(a => a.kind === EventKind.DirectMessage && a.tags.some(b => b[0] === "p")); - DmCache.bulkSet(dms); + Nip4Chats.onEvent(dms); + + const nip29Messages = loginFeed.data.filter( + a => a.kind === EventKind.SimpleChatMessage && a.tags.some(b => b[0] === "g") + ); + Nip29Chats.onEvent(nip29Messages); const subs = loginFeed.data.filter( a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey) diff --git a/packages/app/src/Hooks/useDmsCache.tsx b/packages/app/src/Hooks/useDmsCache.tsx deleted file mode 100644 index 51f09f046..000000000 --- a/packages/app/src/Hooks/useDmsCache.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { DmCache } from "Cache"; -import { useSyncExternalStore } from "react"; - -export function useDmCache() { - return useSyncExternalStore( - c => DmCache.hook(c, "*"), - () => DmCache.snapshot() - ); -} diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 82e59d46c..34b1a16c7 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -12,10 +12,8 @@ import { RootState } from "State/Store"; import { setShow, reset } from "State/NoteCreator"; import { System } from "index"; import useLoginFeed from "Feed/LoginFeed"; -import { totalUnread } from "Pages/MessagesPage"; import useModeration from "Hooks/useModeration"; import { NoteCreator } from "Element/NoteCreator"; -import { useDmCache } from "Hooks/useDmsCache"; import { mapPlanName } from "./subscribe"; import useLogin from "Hooks/useLogin"; import Avatar from "Element/Avatar"; @@ -145,25 +143,14 @@ export default function Layout() { const AccountHeader = () => { const navigate = useNavigate(); - const { isMuted } = useModeration(); const { publicKey, latestNotification, readNotifications } = useLogin(); - const dms = useDmCache(); const profile = useUserProfile(System, publicKey); const hasNotifications = useMemo( () => latestNotification > readNotifications, [latestNotification, readNotifications] ); - const unreadDms = useMemo( - () => - publicKey - ? totalUnread( - dms.filter(a => !isMuted(a.pubkey)), - publicKey - ) - : 0, - [dms, publicKey] - ); + const unreadDms = useMemo(() => (publicKey ? 0 : 0), [publicKey]); async function goToNotifications(e: React.MouseEvent) { e.stopPropagation(); diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx index 65a67072e..2c52f3b20 100644 --- a/packages/app/src/Pages/MessagesPage.tsx +++ b/packages/app/src/Pages/MessagesPage.tsx @@ -1,15 +1,14 @@ import React, { useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useNavigate } from "react-router-dom"; -import { HexKey, NostrEvent, NostrPrefix } from "@snort/system"; +import { NostrPrefix } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; import UnreadCount from "Element/UnreadCount"; import ProfileImage, { getDisplayName } from "Element/ProfileImage"; -import { dedupe, hexToBech32, unwrap } from "SnortUtils"; +import { hexToBech32 } from "SnortUtils"; import NoteToSelf from "Element/NoteToSelf"; import useModeration from "Hooks/useModeration"; -import { useDmCache } from "Hooks/useDmsCache"; import useLogin from "Hooks/useLogin"; import usePageWidth from "Hooks/usePageWidth"; import NoteTime from "Element/NoteTime"; @@ -17,40 +16,25 @@ import DmWindow from "Element/DmWindow"; import Avatar from "Element/Avatar"; import Icon from "Icons/Icon"; import Text from "Element/Text"; +import { System } from "index"; +import { Chat, ChatType, useChatSystem } from "chat"; import "./MessagesPage.css"; import messages from "./messages"; -import { System } from "index"; const TwoCol = 768; const ThreeCol = 1500; -type DmChat = { - pubkey: HexKey; - unreadMessages: number; - newestMessage: number; -}; - export default function MessagesPage() { const login = useLogin(); - const { isMuted } = useModeration(); const { formatMessage } = useIntl(); const navigate = useNavigate(); - const dms = useDmCache(); const [chat, setChat] = useState(); const pageWidth = usePageWidth(); - const chats = useMemo(() => { - if (login.publicKey) { - return extractChats( - dms.filter(a => !isMuted(a.pubkey)), - login.publicKey - ); - } - return []; - }, [dms, login.publicKey, isMuted]); + const chats = useChatSystem(); - const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unreadMessages, 0), [chats]); + const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unread, 0), [chats]); function openChat(e: React.MouseEvent, pubkey: string) { e.stopPropagation(); @@ -62,39 +46,34 @@ export default function MessagesPage() { } } - function noteToSelf(chat: DmChat) { + function noteToSelf(chat: Chat) { return ( -
openChat(e, chat.pubkey)}> - +
openChat(e, chat.id)}> +
); } - function person(chat: DmChat) { + function person(chat: Chat) { if (!login.publicKey) return null; - if (chat.pubkey === login.publicKey) return noteToSelf(chat); + if (chat.id === login.publicKey) return noteToSelf(chat); return ( -
openChat(e, chat.pubkey)}> - +
openChat(e, chat.id)}> + {chat.type === ChatType.DirectMessage ? ( + + ) : ( + + )}
- + - {chat.unreadMessages > 0 && } + {chat.unread > 0 && }
); } - function markAllRead() { - for (const c of chats) { - setLastReadDm(c.pubkey); - } - } - return (
@@ -102,17 +81,13 @@ export default function MessagesPage() {

-
{chats .sort((a, b) => { - return a.pubkey === login.publicKey - ? -1 - : b.pubkey === login.publicKey - ? 1 - : b.newestMessage - a.newestMessage; + return a.id === login.publicKey ? -1 : b.id === login.publicKey ? 1 : b.lastMessage - a.lastMessage; }) .map(person)}
@@ -146,70 +121,3 @@ function ProfileDmActions({ pubkey }: { pubkey: string }) { ); } - -export function lastReadDm(pk: HexKey) { - const k = `dm:seen:${pk}`; - return parseInt(window.localStorage.getItem(k) ?? "0"); -} - -export function setLastReadDm(pk: HexKey) { - const now = Math.floor(new Date().getTime() / 1000); - const current = lastReadDm(pk); - if (current >= now) { - return; - } - - const k = `dm:seen:${pk}`; - window.localStorage.setItem(k, now.toString()); -} - -export function dmTo(e: NostrEvent) { - const firstP = e.tags.find(b => b[0] === "p"); - return unwrap(firstP?.[1]); -} - -export function isToSelf(e: Readonly, pk: HexKey) { - return e.pubkey === pk && dmTo(e) === pk; -} - -export function dmsInChat(dms: readonly NostrEvent[], pk: HexKey) { - return dms.filter(a => a.pubkey === pk || dmTo(a) === pk); -} - -export function totalUnread(dms: NostrEvent[], myPubKey: HexKey) { - return extractChats(dms, myPubKey).reduce((acc, v) => (acc += v.unreadMessages), 0); -} - -function unreadDms(dms: NostrEvent[], myPubKey: HexKey, pk: HexKey) { - if (pk === myPubKey) return 0; - const lastRead = lastReadDm(pk); - return dmsInChat(dms, pk).filter(a => a.created_at >= lastRead && a.pubkey !== myPubKey).length; -} - -function newestMessage(dms: readonly NostrEvent[], myPubKey: HexKey, pk: HexKey) { - if (pk === myPubKey) { - return dmsInChat( - dms.filter(d => isToSelf(d, myPubKey)), - pk - ).reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0); - } - - return dmsInChat(dms, pk).reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0); -} - -export function dmsForLogin(dms: readonly NostrEvent[], myPubKey: HexKey) { - return dms.filter(a => a.pubkey === myPubKey || (a.pubkey !== myPubKey && dmTo(a) === myPubKey)); -} - -export function extractChats(dms: NostrEvent[], myPubKey: HexKey) { - const myDms = dmsForLogin(dms, myPubKey); - const keys = myDms.map(a => [a.pubkey, dmTo(a)]).flat(); - const filteredKeys = dedupe(keys); - return filteredKeys.map(a => { - return { - pubkey: a, - unreadMessages: unreadDms(myDms, myPubKey, a), - newestMessage: newestMessage(myDms, myPubKey, a), - } as DmChat; - }); -} diff --git a/packages/app/src/chat/index.ts b/packages/app/src/chat/index.ts new file mode 100644 index 000000000..a458fb936 --- /dev/null +++ b/packages/app/src/chat/index.ts @@ -0,0 +1,90 @@ +import { useSyncExternalStore } from "react"; +import { Nip4ChatSystem } from "./nip4"; +import { EventKind, EventPublisher, NostrEvent, RequestBuilder, SystemInterface, UserMetadata } from "@snort/system"; +import { unwrap } from "@snort/shared"; +import { Chats } from "Cache"; +import { findTag, unixNow } from "SnortUtils"; +import { Nip29ChatSystem } from "./nip29"; + +export enum ChatType { + DirectMessage = 1, + PublicGroupChat = 2, + PrivateGroupChat = 3, +} + +export interface Chat { + type: ChatType; + id: string; + unread: number; + lastMessage: number; + messages: Array; + profile?: UserMetadata; + createMessage(msg: string, pub: EventPublisher): Promise; + sendMessage(ev: NostrEvent, system: SystemInterface): void | Promise; +} + +export interface ChatSystem { + /** + * Create a request for this system to get updates + */ + subscription(id: string): RequestBuilder | undefined; + onEvent(evs: Array): Promise | void; + + listChats(): Array; +} + +export const Nip4Chats = new Nip4ChatSystem(Chats); +export const Nip29Chats = new Nip29ChatSystem(Chats); + +/** + * Extract the P tag of the event + */ +export function chatTo(e: NostrEvent) { + if (e.kind === EventKind.DirectMessage) { + return unwrap(findTag(e, "p")); + } else if (e.kind === EventKind.SimpleChatMessage) { + const gt = unwrap(e.tags.find(a => a[0] === "g")); + return `${gt[2]}${gt[1]}`; + } + throw new Error("Not a chat message"); +} + +export function inChatWith(e: NostrEvent, myPk: string) { + if (e.pubkey === myPk) { + return chatTo(e); + } else { + return e.pubkey; + } +} + +export function lastReadInChat(id: string) { + const k = `dm:seen:${id}`; + return parseInt(window.localStorage.getItem(k) ?? "0"); +} + +export function setLastReadIn(id: string) { + const now = unixNow(); + const k = `dm:seen:${id}`; + window.localStorage.setItem(k, now.toString()); +} + +export function useNip4Chat() { + return useSyncExternalStore( + c => Nip4Chats.hook(c), + () => Nip4Chats.snapshot() + ); +} + +export function useNip29Chat() { + return useSyncExternalStore( + c => Nip29Chats.hook(c), + () => Nip29Chats.snapshot() + ); +} + +export function useChatSystem() { + const nip4 = useNip4Chat(); + const nip29 = useNip29Chat(); + + return [...nip4, ...nip29]; +} diff --git a/packages/app/src/chat/nip29.ts b/packages/app/src/chat/nip29.ts new file mode 100644 index 000000000..c9830c3dc --- /dev/null +++ b/packages/app/src/chat/nip29.ts @@ -0,0 +1,81 @@ +import { ExternalStore, FeedCache, dedupe } from "@snort/shared"; +import { RequestBuilder, NostrEvent, EventKind, SystemInterface } from "@snort/system"; +import { unwrap } from "SnortUtils"; +import { Chat, ChatSystem, ChatType, lastReadInChat } from "chat"; + +export class Nip29ChatSystem extends ExternalStore> implements ChatSystem { + readonly #cache: FeedCache; + + constructor(cache: FeedCache) { + super(); + this.#cache = cache; + } + + takeSnapshot(): Chat[] { + return this.listChats(); + } + + subscription(id: string) { + const gs = id.split("/", 2); + const rb = new RequestBuilder(`nip29:${id}`); + const last = this.listChats().find(a => a.id === id)?.lastMessage; + rb.withFilter() + .relay(`wss://${gs[0]}`) + .kinds([EventKind.SimpleChatMessage]) + .tag("g", [`/${gs[1]}`]) + .since(last); + rb.withFilter() + .relay(`wss://${gs[0]}`) + .kinds([EventKind.SimpleChatMetadata]) + .tag("d", [`/${gs[1]}`]); + return rb; + } + + async onEvent(evs: NostrEvent[]) { + const msg = evs.filter(a => a.kind === EventKind.SimpleChatMessage); + if (msg.length > 0) { + await this.#cache.bulkSet(msg); + this.notifyChange(); + } + } + + listChats(): Chat[] { + const allMessages = this.#nip29Chats(); + const groups = dedupe( + allMessages + .map(a => a.tags.find(b => b[0] === "g")) + .filter(a => a !== undefined) + .map(a => unwrap(a)) + .map(a => `${a[2]}${a[1]}`) + ); + return groups.map(g => { + const [relay, channel] = g.split("/", 2); + const messages = allMessages.filter( + a => `${a.tags.find(b => b[0] === "g")?.[2]}${a.tags.find(b => b[0] === "g")?.[1]}` === g + ); + const lastRead = lastReadInChat(g); + return { + type: ChatType.PublicGroupChat, + id: g, + unread: messages.reduce((acc, v) => (v.created_at > lastRead ? acc++ : acc), 0), + lastMessage: messages.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0), + messages, + createMessage: (msg, pub) => { + return pub.generic(eb => { + return eb + .kind(EventKind.SimpleChatMessage) + .tag(["g", `/${channel}`, relay]) + .content(msg); + }); + }, + sendMessage: async (ev: NostrEvent, system: SystemInterface) => { + await system.WriteOnceToRelay(`wss://${relay}`, ev); + }, + } as Chat; + }); + } + + #nip29Chats() { + return this.#cache.snapshot().filter(a => a.kind === EventKind.SimpleChatMessage); + } +} diff --git a/packages/app/src/chat/nip4.ts b/packages/app/src/chat/nip4.ts new file mode 100644 index 000000000..49b033d94 --- /dev/null +++ b/packages/app/src/chat/nip4.ts @@ -0,0 +1,65 @@ +import { ExternalStore, FeedCache, dedupe } from "@snort/shared"; +import { EventKind, NostrEvent, RequestBuilder, SystemInterface } from "@snort/system"; +import { Chat, ChatSystem, ChatType, chatTo, inChatWith, lastReadInChat } from "chat"; +import { debug } from "debug"; + +export class Nip4ChatSystem extends ExternalStore> implements ChatSystem { + #cache: FeedCache; + #log = debug("NIP-04"); + + constructor(cache: FeedCache) { + super(); + this.#cache = cache; + } + + async onEvent(evs: Array) { + const dms = evs.filter(a => a.kind === EventKind.DirectMessage); + if (dms.length > 0) { + await this.#cache.bulkSet(dms); + this.notifyChange(); + } + } + + subscription(pk: string) { + const rb = new RequestBuilder(`nip4:${pk.slice(0, 12)}`); + const dms = this.#cache.snapshot(); + const dmSince = dms.reduce( + (acc, v) => (v.created_at > acc && v.kind === EventKind.DirectMessage ? (acc = v.created_at) : acc), + 0 + ); + + this.#log("Loading DMS since %s", new Date(dmSince * 1000)); + rb.withFilter().authors([pk]).kinds([EventKind.DirectMessage]).since(dmSince); + rb.withFilter().kinds([EventKind.DirectMessage]).tag("p", [pk]).since(dmSince); + return rb; + } + + takeSnapshot() { + return this.listChats(); + } + + listChats(): Chat[] { + const myDms = this.#nip4Events(); + return dedupe(myDms.map(a => a.pubkey)).map(a => { + const messages = myDms.filter(b => chatTo(b) === a || b.pubkey === a); + const last = lastReadInChat(a); + return { + type: ChatType.DirectMessage, + id: a, + unread: messages.reduce((acc, v) => (v.created_at > last ? acc++ : acc), 0), + lastMessage: messages.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0), + messages, + createMessage: (msg, pub) => { + return pub.sendDm(msg, a); + }, + sendMessage: (ev: NostrEvent, system: SystemInterface) => { + system.BroadcastEvent(ev); + }, + } as Chat; + }); + } + + #nip4Events() { + return this.#cache.snapshot().filter(a => a.kind === EventKind.DirectMessage); + } +} diff --git a/packages/shared/src/external-store.ts b/packages/shared/src/external-store.ts index 72e537dd2..fe2e7731e 100644 --- a/packages/shared/src/external-store.ts +++ b/packages/shared/src/external-store.ts @@ -34,7 +34,9 @@ export abstract class ExternalStore { protected notifyChange(sn?: TSnapshot) { this.#changed = true; - this.#hooks.forEach(h => h.fn(sn)); + if (this.#hooks.length > 0) { + this.#hooks.forEach(h => h.fn(sn)); + } } abstract takeSnapshot(): TSnapshot; diff --git a/packages/system/src/EventKind.ts b/packages/system/src/EventKind.ts index 3baef1ef0..af6bdff40 100644 --- a/packages/system/src/EventKind.ts +++ b/packages/system/src/EventKind.ts @@ -9,6 +9,7 @@ enum EventKind { Repost = 6, // NIP-18 Reaction = 7, // NIP-25 BadgeAward = 8, // NIP-58 + SimpleChatMessage = 9, // NIP-29 SnortSubscriptions = 1000, // NIP-XX Polls = 6969, // NIP-69 GiftWrap = 1059, // NIP-59 @@ -23,6 +24,7 @@ enum EventKind { ProfileBadges = 30008, // NIP-58 LiveEvent = 30311, // NIP-102 ZapstrTrack = 31337, + SimpleChatMetadata = 39_000, // NIP-29 ZapRequest = 9734, // NIP 57 ZapReceipt = 9735, // NIP 57 HttpAuthentication = 27235, // NIP XX - HTTP Authentication diff --git a/packages/system/src/Nostr.ts b/packages/system/src/Nostr.ts index 460fb7ba4..41808d907 100644 --- a/packages/system/src/Nostr.ts +++ b/packages/system/src/Nostr.ts @@ -38,19 +38,21 @@ export type ReqCommand = [cmd: "REQ", id: string, ...filters: Array]; * Raw REQ filter object */ export interface ReqFilter { - ids?: u256[]; - authors?: u256[]; - kinds?: number[]; - "#e"?: u256[]; - "#p"?: u256[]; - "#t"?: string[]; - "#d"?: string[]; - "#r"?: string[]; - "#a"?: string[]; - search?: string; - since?: number; - until?: number; - limit?: number; + ids?: u256[] + authors?: u256[] + kinds?: number[] + "#e"?: u256[] + "#p"?: u256[] + "#t"?: string[] + "#d"?: string[] + "#r"?: string[] + "#a"?: string[] + "#g"?: string[] + search?: string + since?: number + until?: number + limit?: number + [key: string]: Array | Array | string | number | undefined } /** diff --git a/packages/system/src/NostrSystem.ts b/packages/system/src/NostrSystem.ts index dbcfc34f1..c2238f40f 100644 --- a/packages/system/src/NostrSystem.ts +++ b/packages/system/src/NostrSystem.ts @@ -142,7 +142,7 @@ export class NostrSystem extends ExternalStore implements System try { const addr = unwrap(sanitizeRelayUrl(address)); if (!this.#sockets.has(addr)) { - const c = new Connection(addr, { read: true, write: false }, this.#handleAuth?.bind(this), true); + const c = new Connection(addr, { read: true, write: true }, this.#handleAuth?.bind(this), true); this.#sockets.set(addr, c); c.OnEvent = (s, e) => this.OnEvent(s, e); c.OnEose = s => this.OnEndOfStoredEvents(c, s); @@ -252,18 +252,27 @@ export class NostrSystem extends ExternalStore implements System * Write an event to a relay then disconnect */ async WriteOnceToRelay(address: string, ev: NostrEvent) { - return new Promise((resolve, reject) => { - const c = new Connection(address, { write: true, read: false }, this.#handleAuth?.bind(this), true); + const addrClean = sanitizeRelayUrl(address); + if (!addrClean) { + throw new Error("Invalid relay address"); + } - const t = setTimeout(reject, 5_000); - c.OnConnected = async () => { - clearTimeout(t); - await c.SendAsync(ev); - c.Close(); - resolve(); - }; - c.Connect(); - }); + if (this.#sockets.has(addrClean)) { + await this.#sockets.get(addrClean)?.SendAsync(ev); + } else { + return await new Promise((resolve, reject) => { + const c = new Connection(address, { write: true, read: true }, this.#handleAuth?.bind(this), true); + + const t = setTimeout(reject, 5_000); + c.OnConnected = async () => { + clearTimeout(t); + await c.SendAsync(ev); + c.Close(); + resolve(); + }; + c.Connect(); + }); + } } takeSnapshot(): SystemSnapshot { diff --git a/packages/system/src/ProfileCache.ts b/packages/system/src/ProfileCache.ts index 7fd8b5d4b..107b9da04 100644 --- a/packages/system/src/ProfileCache.ts +++ b/packages/system/src/ProfileCache.ts @@ -22,7 +22,7 @@ export class ProfileLoaderService { /** * List of pubkeys to fetch metadata for */ - WantsMetadata: Set = new Set(); + #wantsMetadata: Set = new Set(); readonly #log = debug("ProfileCache"); @@ -42,7 +42,7 @@ export class ProfileLoaderService { TrackMetadata(pk: HexKey | Array) { const bufferNow = []; for (const p of Array.isArray(pk) ? pk : [pk]) { - if (p.length > 0 && this.WantsMetadata.add(p)) { + if (p.length === 64 && this.#wantsMetadata.add(p)) { bufferNow.push(p); } } @@ -55,7 +55,7 @@ export class ProfileLoaderService { UntrackMetadata(pk: HexKey | Array) { for (const p of Array.isArray(pk) ? pk : [pk]) { if (p.length > 0) { - this.WantsMetadata.delete(p); + this.#wantsMetadata.delete(p); } } } @@ -68,10 +68,10 @@ export class ProfileLoaderService { } async #FetchMetadata() { - const missingFromCache = await this.#cache.buffer([...this.WantsMetadata]); + const missingFromCache = await this.#cache.buffer([...this.#wantsMetadata]); const expire = unixNowMs() - ProfileCacheExpire; - const expired = [...this.WantsMetadata] + const expired = [...this.#wantsMetadata] .filter(a => !missingFromCache.includes(a)) .filter(a => (this.#cache.getFromCache(a)?.loaded ?? 0) < expire); const missing = new Set([...missingFromCache, ...expired]); diff --git a/packages/system/src/RequestBuilder.ts b/packages/system/src/RequestBuilder.ts index a9b0a4e8c..45d43e40a 100644 --- a/packages/system/src/RequestBuilder.ts +++ b/packages/system/src/RequestBuilder.ts @@ -70,6 +70,13 @@ export class RequestBuilder { return this.#options; } + /** + * Add another request builders filters to this one + */ + add(other: RequestBuilder) { + this.#builders.push(...other.#builders); + } + withFilter() { const ret = new RequestFilterBuilder(); this.#builders.push(ret); @@ -203,7 +210,7 @@ export class RequestFilterBuilder { return this; } - tag(key: "e" | "p" | "d" | "t" | "r" | "a", value?: Array) { + tag(key: "e" | "p" | "d" | "t" | "r" | "a" | "g", value?: Array) { if (!value) return this; this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`], value); return this;