Chat system refactor

This commit is contained in:
Kieran 2023-06-20 14:15:33 +01:00
parent 4365fac9b5
commit 234c1c092d
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
22 changed files with 397 additions and 263 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ yarn.lock
dist/ dist/
*.tgz *.tgz
*.log *.log
.DS_Store

View File

@ -2,9 +2,9 @@ import { NostrEvent } from "@snort/system";
import { FeedCache } from "@snort/shared"; import { FeedCache } from "@snort/shared";
import { db } from "Db"; import { db } from "Db";
class DMCache extends FeedCache<NostrEvent> { export class ChatCache extends FeedCache<NostrEvent> {
constructor() { constructor() {
super("DMCache", db.dms); super("ChatCache", db.chats);
} }
key(of: NostrEvent): string { key(of: NostrEvent): string {
@ -23,13 +23,7 @@ class DMCache extends FeedCache<NostrEvent> {
return ret; return ret;
} }
allDms(): Array<NostrEvent> { takeSnapshot(): Array<NostrEvent> {
return [...this.cache.values()]; return [...this.cache.values()];
} }
takeSnapshot(): Array<NostrEvent> {
return this.allDms();
} }
}
export const DmCache = new DMCache();

View File

@ -1,16 +1,16 @@
import { UserProfileCache, UserRelaysCache, RelayMetricCache } from "@snort/system"; import { UserProfileCache, UserRelaysCache, RelayMetricCache } from "@snort/system";
import { DmCache } from "./DMCache";
import { InteractionCache } from "./EventInteractionCache"; import { InteractionCache } from "./EventInteractionCache";
import { ChatCache } from "./ChatCache";
export const UserCache = new UserProfileCache(); export const UserCache = new UserProfileCache();
export const UserRelays = new UserRelaysCache(); export const UserRelays = new UserRelaysCache();
export const RelayMetrics = new RelayMetricCache(); export const RelayMetrics = new RelayMetricCache();
export { DmCache }; export const Chats = new ChatCache();
export async function preload(follows?: Array<string>) { export async function preload(follows?: Array<string>) {
const preloads = [ const preloads = [
UserCache.preload(follows), UserCache.preload(follows),
DmCache.preload(), Chats.preload(),
InteractionCache.preload(), InteractionCache.preload(),
UserRelays.preload(follows), UserRelays.preload(follows),
RelayMetrics.preload(), RelayMetrics.preload(),

View File

@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie";
import { HexKey, NostrEvent, u256 } from "@snort/system"; import { HexKey, NostrEvent, u256 } from "@snort/system";
export const NAME = "snortDB"; export const NAME = "snortDB";
export const VERSION = 10; export const VERSION = 11;
export interface SubCache { export interface SubCache {
id: string; id: string;
@ -28,14 +28,14 @@ export interface Payment {
} }
const STORES = { const STORES = {
dms: "++id, pubkey", chats: "++id",
eventInteraction: "++id", eventInteraction: "++id",
payments: "++url", payments: "++url",
}; };
export class SnortDB extends Dexie { export class SnortDB extends Dexie {
ready = false; ready = false;
dms!: Table<NostrEvent>; chats!: Table<NostrEvent>;
eventInteraction!: Table<EventInteraction>; eventInteraction!: Table<EventInteraction>;
payments!: Table<Payment>; payments!: Table<Payment>;

View File

@ -2,55 +2,65 @@ import "./DM.css";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import { TaggedRawEvent } from "@snort/system"; import { EventKind, TaggedRawEvent } from "@snort/system";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import NoteTime from "Element/NoteTime"; import NoteTime from "Element/NoteTime";
import Text from "Element/Text"; import Text from "Element/Text";
import { setLastReadDm } from "Pages/MessagesPage";
import { unwrap } from "SnortUtils";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { Chat, ChatType, chatTo, setLastReadIn } from "chat";
import messages from "./messages"; import messages from "./messages";
import ProfileImage from "./ProfileImage";
export type DMProps = { export interface DMProps {
chat: Chat;
data: TaggedRawEvent; data: TaggedRawEvent;
}; }
export default function DM(props: DMProps) { export default function DM(props: DMProps) {
const pubKey = useLogin().publicKey; const pubKey = useLogin().publicKey;
const publisher = useEventPublisher(); 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 [decrypted, setDecrypted] = useState(false);
const { ref, inView } = useInView(); const { ref, inView } = useInView();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const isMe = props.data.pubkey === pubKey; const isMe = ev.pubkey === pubKey;
const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]); const otherPubkey = isMe ? pubKey : chatTo(ev);
async function decrypt() { async function decrypt() {
if (publisher) { if (publisher) {
const decrypted = await publisher.decryptDm(props.data); const decrypted = await publisher.decryptDm(ev);
setContent(decrypted || "<ERROR>"); setContent(decrypted || "<ERROR>");
if (!isMe) { if (!isMe) {
setLastReadDm(props.data.pubkey); setLastReadIn(ev.pubkey);
} }
} }
} }
function sender() {
if (props.chat.type !== ChatType.DirectMessage && !isMe) {
return <ProfileImage pubkey={ev.pubkey} />;
}
}
useEffect(() => { useEffect(() => {
if (!decrypted && inView) { if (!decrypted && inView && needsDecryption) {
setDecrypted(true); setDecrypted(true);
decrypt().catch(console.error); decrypt().catch(console.error);
} }
}, [inView, props.data]); }, [inView, ev]);
return ( return (
<div className={isMe ? "dm me" : "dm other"} ref={ref}> <div className={isMe ? "dm me" : "dm other"} ref={ref}>
<div> <div>
{sender()}
<Text content={content} tags={[]} creator={otherPubkey} /> <Text content={content} tags={[]} creator={otherPubkey} />
</div> </div>
<div> <div>
<NoteTime from={props.data.created_at * 1000} fallback={formatMessage(messages.JustNow)} /> <NoteTime from={ev.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
</div> </div>
</div> </div>
); );

View File

@ -8,6 +8,8 @@
overflow-y: auto; overflow-y: auto;
padding: 0 10px 10px 10px; padding: 0 10px 10px 10px;
flex-grow: 1; flex-grow: 1;
display: flex;
flex-direction: column-reverse;
} }
.dm-window > div:nth-child(3) { .dm-window > div:nth-child(3) {

View File

@ -1,85 +1,60 @@
import "./DmWindow.css"; import "./DmWindow.css";
import { useEffect, useMemo, useRef } from "react"; import { useMemo } from "react";
import { TaggedRawEvent } from "@snort/system"; import { TaggedRawEvent } from "@snort/system";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import DM from "Element/DM"; import DM from "Element/DM";
import { dmsForLogin, dmsInChat, isToSelf } from "Pages/MessagesPage";
import NoteToSelf from "Element/NoteToSelf"; import NoteToSelf from "Element/NoteToSelf";
import { useDmCache } from "Hooks/useDmsCache";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import WriteDm from "Element/WriteDm"; import WriteMessage from "Element/WriteMessage";
import { unwrap } from "SnortUtils"; import { Chat, ChatType, useChatSystem } from "chat";
export default function DmWindow({ id }: { id: string }) { export default function DmWindow({ id }: { id: string }) {
const pubKey = useLogin().publicKey; const pubKey = useLogin().publicKey;
const dmListRef = useRef<HTMLDivElement>(null); const dms = useChatSystem();
const chat = dms.find(a => a.id === id);
function resize(chatList: HTMLDivElement) { function sender() {
if (!chatList.parentElement) return; if (id === pubKey) {
return <NoteToSelf className="f-grow mb-10" pubkey={id} />;
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);
} }
if (chat?.type === ChatType.DirectMessage) {
return <ProfileImage pubkey={id} className="f-grow mb10" />;
} }
if (chat?.profile) {
useEffect(() => { return <ProfileImage pubkey={id} className="f-grow mb10" profile={chat.profile} />;
if (dmListRef.current) { }
const scrollWrap = dmListRef.current; return <ProfileImage pubkey={""} className="f-grow mb10" overrideUsername={chat?.id} />;
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 ( return (
<div className="dm-window"> <div className="dm-window">
<div>{sender()}</div>
<div> <div>
{(id === pubKey && <NoteToSelf className="f-grow mb-10" pubkey={id} />) || ( <div className="flex f-col">{chat && <DmChatSelected chat={chat} />}</div>
<ProfileImage pubkey={id} className="f-grow mb10" />
)}
</div> </div>
<div> <div>
<div className="flex f-col" ref={dmListRef}> <WriteMessage chatId={id} />
<DmChatSelected chatPubKey={id} />
</div>
</div>
<div>
<WriteDm chatPubKey={id} />
</div> </div>
</div> </div>
); );
} }
function DmChatSelected({ chatPubKey }: { chatPubKey: string }) { function DmChatSelected({ chat }: { chat: Chat }) {
const dms = useDmCache();
const { publicKey: myPubKey } = useLogin(); const { publicKey: myPubKey } = useLogin();
const sortedDms = useMemo(() => { const sortedDms = useMemo(() => {
if (myPubKey) { const myDms = chat?.messages;
const myDms = dmsForLogin(dms, myPubKey); if (myPubKey && myDms) {
// filter dms in this chat, or dms to self // filter dms in this chat, or dms to self
const thisDms = myPubKey === chatPubKey ? myDms.filter(d => isToSelf(d, myPubKey)) : myDms; return [...myDms].sort((a, b) => a.created_at - b.created_at);
return [...dmsInChat(thisDms, chatPubKey)].sort((a, b) => a.created_at - b.created_at);
} }
return []; return [];
}, [dms, myPubKey, chatPubKey]); }, [chat, myPubKey]);
return ( return (
<> <>
{sortedDms.map(a => ( {sortedDms.map(a => (
<DM data={a as TaggedRawEvent} key={a.id} /> <DM data={a as TaggedRawEvent} key={a.id} chat={chat} />
))} ))}
</> </>
); );

View File

@ -2,7 +2,7 @@ import "./ProfileImage.css";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Link } from "react-router-dom"; 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 { useUserProfile } from "@snort/system-react";
import { hexToBech32, profileLink } from "SnortUtils"; import { hexToBech32, profileLink } from "SnortUtils";
@ -19,6 +19,7 @@ export interface ProfileImageProps {
defaultNip?: string; defaultNip?: string;
verifyNip?: boolean; verifyNip?: boolean;
overrideUsername?: string; overrideUsername?: string;
profile?: UserMetadata;
} }
export default function ProfileImage({ export default function ProfileImage({
@ -30,8 +31,9 @@ export default function ProfileImage({
defaultNip, defaultNip,
verifyNip, verifyNip,
overrideUsername, overrideUsername,
profile,
}: ProfileImageProps) { }: ProfileImageProps) {
const user = useUserProfile(System, pubkey); const user = profile ?? useUserProfile(System, pubkey);
const nip05 = defaultNip ? defaultNip : user?.nip05; const nip05 = defaultNip ? defaultNip : user?.nip05;
const name = useMemo(() => { 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); let name = hexToBech32(NostrPrefix.PublicKey, pubkey).substring(0, 12);
if (typeof user?.display_name === "string" && user.display_name.length > 0) { if (typeof user?.display_name === "string" && user.display_name.length > 0) {
name = user.display_name; name = user.display_name;

View File

@ -7,8 +7,9 @@ import useFileUpload from "Upload";
import { openFile } from "SnortUtils"; import { openFile } from "SnortUtils";
import Textarea from "./Textarea"; import Textarea from "./Textarea";
import { System } from "index"; 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 [msg, setMsg] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -16,6 +17,7 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const uploader = useFileUpload(); const uploader = useFileUpload();
const chat = useChatSystem().find(a => a.id === chatId);
async function attachFile() { async function attachFile() {
try { try {
@ -54,11 +56,11 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) {
} }
} }
async function sendDm() { async function sendMessage() {
if (msg && publisher) { if (msg && publisher && chat) {
setSending(true); setSending(true);
const ev = await publisher.sendDm(msg, chatPubKey); const ev = await chat.createMessage(msg, publisher);
System.BroadcastEvent(ev); await chat.sendMessage(ev, System);
setMsg(""); setMsg("");
setSending(false); setSending(false);
} }
@ -73,7 +75,8 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) {
async function onEnter(e: React.KeyboardEvent<HTMLTextAreaElement>) { async function onEnter(e: React.KeyboardEvent<HTMLTextAreaElement>) {
const isEnter = e.code === "Enter"; const isEnter = e.code === "Enter";
if (isEnter && !e.shiftKey) { if (isEnter && !e.shiftKey) {
await sendDm(); e.preventDefault();
await sendMessage();
} }
} }
@ -96,7 +99,7 @@ export default function WriteDm({ chatPubKey }: { chatPubKey: string }) {
/> />
{error && <b className="error">{error}</b>} {error && <b className="error">{error}</b>}
</div> </div>
<button className="btn-rnd" onClick={() => sendDm()}> <button className="btn-rnd" onClick={() => sendMessage()}>
{sending ? <Spinner width={20} /> : <Icon name="arrow-right" size={20} />} {sending ? <Spinner width={20} /> : <Icon name="arrow-right" size={20} />}
</button> </button>
</> </>

View File

@ -1,6 +1,5 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { TaggedRawEvent, Lists, EventKind, FlatNoteStore, RequestBuilder } from "@snort/system"; import { TaggedRawEvent, Lists, EventKind, FlatNoteStore, RequestBuilder } from "@snort/system";
import debug from "debug";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils"; import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils";
@ -8,7 +7,6 @@ import { makeNotification, sendNotification } from "Notifications";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { getMutedKeys } from "Feed/MuteList"; import { getMutedKeys } from "Feed/MuteList";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import { DmCache } from "Cache";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login"; import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login";
import { SnortPubKey } from "Const"; import { SnortPubKey } from "Const";
@ -16,6 +14,7 @@ import { SubscriptionEvent } from "Subscription";
import useRelaysFeedFollows from "./RelaysFeedFollows"; import useRelaysFeedFollows from "./RelaysFeedFollows";
import { UserRelays } from "Cache"; import { UserRelays } from "Cache";
import { System } from "index"; import { System } from "index";
import { Nip29Chats, Nip4Chats } from "chat";
/** /**
* Managed loading data for the current logged in user * Managed loading data for the current logged in user
@ -41,10 +40,9 @@ export default function useLoginFeed() {
.tag("p", [pubKey]) .tag("p", [pubKey])
.limit(1); .limit(1);
const dmSince = DmCache.newest(); b.add(Nip4Chats.subscription(pubKey));
debug("LoginFeed")("Loading dms since %s", new Date(dmSince * 1000).toISOString()); b.add(Nip29Chats.subscription("n29.nostr.com/"));
b.withFilter().authors([pubKey]).kinds([EventKind.DirectMessage]).since(dmSince);
b.withFilter().kinds([EventKind.DirectMessage]).tag("p", [pubKey]).since(dmSince);
return b; return b;
}, [pubKey]); }, [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")); 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( const subs = loginFeed.data.filter(
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey) a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey)

View File

@ -1,9 +0,0 @@
import { DmCache } from "Cache";
import { useSyncExternalStore } from "react";
export function useDmCache() {
return useSyncExternalStore(
c => DmCache.hook(c, "*"),
() => DmCache.snapshot()
);
}

View File

@ -12,10 +12,8 @@ import { RootState } from "State/Store";
import { setShow, reset } from "State/NoteCreator"; import { setShow, reset } from "State/NoteCreator";
import { System } from "index"; import { System } from "index";
import useLoginFeed from "Feed/LoginFeed"; import useLoginFeed from "Feed/LoginFeed";
import { totalUnread } from "Pages/MessagesPage";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import { NoteCreator } from "Element/NoteCreator"; import { NoteCreator } from "Element/NoteCreator";
import { useDmCache } from "Hooks/useDmsCache";
import { mapPlanName } from "./subscribe"; import { mapPlanName } from "./subscribe";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import Avatar from "Element/Avatar"; import Avatar from "Element/Avatar";
@ -145,25 +143,14 @@ export default function Layout() {
const AccountHeader = () => { const AccountHeader = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { isMuted } = useModeration();
const { publicKey, latestNotification, readNotifications } = useLogin(); const { publicKey, latestNotification, readNotifications } = useLogin();
const dms = useDmCache();
const profile = useUserProfile(System, publicKey); const profile = useUserProfile(System, publicKey);
const hasNotifications = useMemo( const hasNotifications = useMemo(
() => latestNotification > readNotifications, () => latestNotification > readNotifications,
[latestNotification, readNotifications] [latestNotification, readNotifications]
); );
const unreadDms = useMemo( const unreadDms = useMemo(() => (publicKey ? 0 : 0), [publicKey]);
() =>
publicKey
? totalUnread(
dms.filter(a => !isMuted(a.pubkey)),
publicKey
)
: 0,
[dms, publicKey]
);
async function goToNotifications(e: React.MouseEvent) { async function goToNotifications(e: React.MouseEvent) {
e.stopPropagation(); e.stopPropagation();

View File

@ -1,15 +1,14 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom"; 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 { useUserProfile } from "@snort/system-react";
import UnreadCount from "Element/UnreadCount"; import UnreadCount from "Element/UnreadCount";
import ProfileImage, { getDisplayName } from "Element/ProfileImage"; import ProfileImage, { getDisplayName } from "Element/ProfileImage";
import { dedupe, hexToBech32, unwrap } from "SnortUtils"; import { hexToBech32 } from "SnortUtils";
import NoteToSelf from "Element/NoteToSelf"; import NoteToSelf from "Element/NoteToSelf";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import { useDmCache } from "Hooks/useDmsCache";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import usePageWidth from "Hooks/usePageWidth"; import usePageWidth from "Hooks/usePageWidth";
import NoteTime from "Element/NoteTime"; import NoteTime from "Element/NoteTime";
@ -17,40 +16,25 @@ import DmWindow from "Element/DmWindow";
import Avatar from "Element/Avatar"; import Avatar from "Element/Avatar";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import Text from "Element/Text"; import Text from "Element/Text";
import { System } from "index";
import { Chat, ChatType, useChatSystem } from "chat";
import "./MessagesPage.css"; import "./MessagesPage.css";
import messages from "./messages"; import messages from "./messages";
import { System } from "index";
const TwoCol = 768; const TwoCol = 768;
const ThreeCol = 1500; const ThreeCol = 1500;
type DmChat = {
pubkey: HexKey;
unreadMessages: number;
newestMessage: number;
};
export default function MessagesPage() { export default function MessagesPage() {
const login = useLogin(); const login = useLogin();
const { isMuted } = useModeration();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const navigate = useNavigate(); const navigate = useNavigate();
const dms = useDmCache();
const [chat, setChat] = useState<string>(); const [chat, setChat] = useState<string>();
const pageWidth = usePageWidth(); const pageWidth = usePageWidth();
const chats = useMemo(() => { const chats = useChatSystem();
if (login.publicKey) {
return extractChats(
dms.filter(a => !isMuted(a.pubkey)),
login.publicKey
);
}
return [];
}, [dms, login.publicKey, isMuted]);
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<HTMLDivElement>, pubkey: string) { function openChat(e: React.MouseEvent<HTMLDivElement>, pubkey: string) {
e.stopPropagation(); e.stopPropagation();
@ -62,39 +46,34 @@ export default function MessagesPage() {
} }
} }
function noteToSelf(chat: DmChat) { function noteToSelf(chat: Chat) {
return ( return (
<div className="flex mb10" key={chat.pubkey} onClick={e => openChat(e, chat.pubkey)}> <div className="flex mb10" key={chat.id} onClick={e => openChat(e, chat.id)}>
<NoteToSelf clickable={true} className="f-grow" link="" pubkey={chat.pubkey} /> <NoteToSelf clickable={true} className="f-grow" link="" pubkey={chat.id} />
</div> </div>
); );
} }
function person(chat: DmChat) { function person(chat: Chat) {
if (!login.publicKey) return null; if (!login.publicKey) return null;
if (chat.pubkey === login.publicKey) return noteToSelf(chat); if (chat.id === login.publicKey) return noteToSelf(chat);
return ( return (
<div className="flex mb10" key={chat.pubkey} onClick={e => openChat(e, chat.pubkey)}> <div className="flex mb10" key={chat.id} onClick={e => openChat(e, chat.id)}>
<ProfileImage pubkey={chat.pubkey} className="f-grow" link="" /> {chat.type === ChatType.DirectMessage ? (
<ProfileImage pubkey={chat.id} className="f-grow" link="" />
) : (
<ProfileImage pubkey={chat.id} overrideUsername={chat.id} className="f-grow" link="" />
)}
<div className="nowrap"> <div className="nowrap">
<small> <small>
<NoteTime <NoteTime from={chat.lastMessage * 1000} fallback={formatMessage({ defaultMessage: "Just now" })} />
from={newestMessage(dms, login.publicKey, chat.pubkey) * 1000}
fallback={formatMessage({ defaultMessage: "Just now" })}
/>
</small> </small>
{chat.unreadMessages > 0 && <UnreadCount unread={chat.unreadMessages} />} {chat.unread > 0 && <UnreadCount unread={chat.unread} />}
</div> </div>
</div> </div>
); );
} }
function markAllRead() {
for (const c of chats) {
setLastReadDm(c.pubkey);
}
}
return ( return (
<div className="dm-page"> <div className="dm-page">
<div> <div>
@ -102,17 +81,13 @@ export default function MessagesPage() {
<h3 className="f-grow"> <h3 className="f-grow">
<FormattedMessage {...messages.Messages} /> <FormattedMessage {...messages.Messages} />
</h3> </h3>
<button disabled={unreadCount <= 0} type="button" onClick={() => markAllRead()}> <button disabled={unreadCount <= 0} type="button">
<FormattedMessage {...messages.MarkAllRead} /> <FormattedMessage {...messages.MarkAllRead} />
</button> </button>
</div> </div>
{chats {chats
.sort((a, b) => { .sort((a, b) => {
return a.pubkey === login.publicKey return a.id === login.publicKey ? -1 : b.id === login.publicKey ? 1 : b.lastMessage - a.lastMessage;
? -1
: b.pubkey === login.publicKey
? 1
: b.newestMessage - a.newestMessage;
}) })
.map(person)} .map(person)}
</div> </div>
@ -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<NostrEvent>, 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;
});
}

View File

@ -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<NostrEvent>;
profile?: UserMetadata;
createMessage(msg: string, pub: EventPublisher): Promise<NostrEvent>;
sendMessage(ev: NostrEvent, system: SystemInterface): void | Promise<void>;
}
export interface ChatSystem {
/**
* Create a request for this system to get updates
*/
subscription(id: string): RequestBuilder | undefined;
onEvent(evs: Array<NostrEvent>): Promise<void> | void;
listChats(): Array<Chat>;
}
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];
}

View File

@ -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<Array<Chat>> implements ChatSystem {
readonly #cache: FeedCache<NostrEvent>;
constructor(cache: FeedCache<NostrEvent>) {
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);
}
}

View File

@ -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<Array<Chat>> implements ChatSystem {
#cache: FeedCache<NostrEvent>;
#log = debug("NIP-04");
constructor(cache: FeedCache<NostrEvent>) {
super();
this.#cache = cache;
}
async onEvent(evs: Array<NostrEvent>) {
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);
}
}

View File

@ -34,8 +34,10 @@ export abstract class ExternalStore<TSnapshot> {
protected notifyChange(sn?: TSnapshot) { protected notifyChange(sn?: TSnapshot) {
this.#changed = true; this.#changed = true;
if (this.#hooks.length > 0) {
this.#hooks.forEach(h => h.fn(sn)); this.#hooks.forEach(h => h.fn(sn));
} }
}
abstract takeSnapshot(): TSnapshot; abstract takeSnapshot(): TSnapshot;
} }

View File

@ -9,6 +9,7 @@ enum EventKind {
Repost = 6, // NIP-18 Repost = 6, // NIP-18
Reaction = 7, // NIP-25 Reaction = 7, // NIP-25
BadgeAward = 8, // NIP-58 BadgeAward = 8, // NIP-58
SimpleChatMessage = 9, // NIP-29
SnortSubscriptions = 1000, // NIP-XX SnortSubscriptions = 1000, // NIP-XX
Polls = 6969, // NIP-69 Polls = 6969, // NIP-69
GiftWrap = 1059, // NIP-59 GiftWrap = 1059, // NIP-59
@ -23,6 +24,7 @@ enum EventKind {
ProfileBadges = 30008, // NIP-58 ProfileBadges = 30008, // NIP-58
LiveEvent = 30311, // NIP-102 LiveEvent = 30311, // NIP-102
ZapstrTrack = 31337, ZapstrTrack = 31337,
SimpleChatMetadata = 39_000, // NIP-29
ZapRequest = 9734, // NIP 57 ZapRequest = 9734, // NIP 57
ZapReceipt = 9735, // NIP 57 ZapReceipt = 9735, // NIP 57
HttpAuthentication = 27235, // NIP XX - HTTP Authentication HttpAuthentication = 27235, // NIP XX - HTTP Authentication

View File

@ -38,19 +38,21 @@ export type ReqCommand = [cmd: "REQ", id: string, ...filters: Array<ReqFilter>];
* Raw REQ filter object * Raw REQ filter object
*/ */
export interface ReqFilter { export interface ReqFilter {
ids?: u256[]; ids?: u256[]
authors?: u256[]; authors?: u256[]
kinds?: number[]; kinds?: number[]
"#e"?: u256[]; "#e"?: u256[]
"#p"?: u256[]; "#p"?: u256[]
"#t"?: string[]; "#t"?: string[]
"#d"?: string[]; "#d"?: string[]
"#r"?: string[]; "#r"?: string[]
"#a"?: string[]; "#a"?: string[]
search?: string; "#g"?: string[]
since?: number; search?: string
until?: number; since?: number
limit?: number; until?: number
limit?: number
[key: string]: Array<string> | Array<number> | string | number | undefined
} }
/** /**

View File

@ -142,7 +142,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
try { try {
const addr = unwrap(sanitizeRelayUrl(address)); const addr = unwrap(sanitizeRelayUrl(address));
if (!this.#sockets.has(addr)) { 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); this.#sockets.set(addr, c);
c.OnEvent = (s, e) => this.OnEvent(s, e); c.OnEvent = (s, e) => this.OnEvent(s, e);
c.OnEose = s => this.OnEndOfStoredEvents(c, s); c.OnEose = s => this.OnEndOfStoredEvents(c, s);
@ -252,8 +252,16 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
* Write an event to a relay then disconnect * Write an event to a relay then disconnect
*/ */
async WriteOnceToRelay(address: string, ev: NostrEvent) { async WriteOnceToRelay(address: string, ev: NostrEvent) {
return new Promise<void>((resolve, reject) => { const addrClean = sanitizeRelayUrl(address);
const c = new Connection(address, { write: true, read: false }, this.#handleAuth?.bind(this), true); if (!addrClean) {
throw new Error("Invalid relay address");
}
if (this.#sockets.has(addrClean)) {
await this.#sockets.get(addrClean)?.SendAsync(ev);
} else {
return await new Promise<void>((resolve, reject) => {
const c = new Connection(address, { write: true, read: true }, this.#handleAuth?.bind(this), true);
const t = setTimeout(reject, 5_000); const t = setTimeout(reject, 5_000);
c.OnConnected = async () => { c.OnConnected = async () => {
@ -265,6 +273,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
c.Connect(); c.Connect();
}); });
} }
}
takeSnapshot(): SystemSnapshot { takeSnapshot(): SystemSnapshot {
return { return {

View File

@ -22,7 +22,7 @@ export class ProfileLoaderService {
/** /**
* List of pubkeys to fetch metadata for * List of pubkeys to fetch metadata for
*/ */
WantsMetadata: Set<HexKey> = new Set(); #wantsMetadata: Set<HexKey> = new Set();
readonly #log = debug("ProfileCache"); readonly #log = debug("ProfileCache");
@ -42,7 +42,7 @@ export class ProfileLoaderService {
TrackMetadata(pk: HexKey | Array<HexKey>) { TrackMetadata(pk: HexKey | Array<HexKey>) {
const bufferNow = []; const bufferNow = [];
for (const p of Array.isArray(pk) ? pk : [pk]) { 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); bufferNow.push(p);
} }
} }
@ -55,7 +55,7 @@ export class ProfileLoaderService {
UntrackMetadata(pk: HexKey | Array<HexKey>) { UntrackMetadata(pk: HexKey | Array<HexKey>) {
for (const p of Array.isArray(pk) ? pk : [pk]) { for (const p of Array.isArray(pk) ? pk : [pk]) {
if (p.length > 0) { if (p.length > 0) {
this.WantsMetadata.delete(p); this.#wantsMetadata.delete(p);
} }
} }
} }
@ -68,10 +68,10 @@ export class ProfileLoaderService {
} }
async #FetchMetadata() { async #FetchMetadata() {
const missingFromCache = await this.#cache.buffer([...this.WantsMetadata]); const missingFromCache = await this.#cache.buffer([...this.#wantsMetadata]);
const expire = unixNowMs() - ProfileCacheExpire; const expire = unixNowMs() - ProfileCacheExpire;
const expired = [...this.WantsMetadata] const expired = [...this.#wantsMetadata]
.filter(a => !missingFromCache.includes(a)) .filter(a => !missingFromCache.includes(a))
.filter(a => (this.#cache.getFromCache(a)?.loaded ?? 0) < expire); .filter(a => (this.#cache.getFromCache(a)?.loaded ?? 0) < expire);
const missing = new Set([...missingFromCache, ...expired]); const missing = new Set([...missingFromCache, ...expired]);

View File

@ -70,6 +70,13 @@ export class RequestBuilder {
return this.#options; return this.#options;
} }
/**
* Add another request builders filters to this one
*/
add(other: RequestBuilder) {
this.#builders.push(...other.#builders);
}
withFilter() { withFilter() {
const ret = new RequestFilterBuilder(); const ret = new RequestFilterBuilder();
this.#builders.push(ret); this.#builders.push(ret);
@ -203,7 +210,7 @@ export class RequestFilterBuilder {
return this; return this;
} }
tag(key: "e" | "p" | "d" | "t" | "r" | "a", value?: Array<string>) { tag(key: "e" | "p" | "d" | "t" | "r" | "a" | "g", value?: Array<string>) {
if (!value) return this; if (!value) return this;
this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`], value); this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`], value);
return this; return this;