forked from Kieran/snort
1
0
Fork 0

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

3
.gitignore vendored
View File

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

View File

@ -2,9 +2,9 @@ import { NostrEvent } from "@snort/system";
import { FeedCache } from "@snort/shared";
import { db } from "Db";
class DMCache extends FeedCache<NostrEvent> {
export class ChatCache extends FeedCache<NostrEvent> {
constructor() {
super("DMCache", db.dms);
super("ChatCache", db.chats);
}
key(of: NostrEvent): string {
@ -23,13 +23,7 @@ class DMCache extends FeedCache<NostrEvent> {
return ret;
}
allDms(): Array<NostrEvent> {
takeSnapshot(): Array<NostrEvent> {
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 { 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<string>) {
const preloads = [
UserCache.preload(follows),
DmCache.preload(),
Chats.preload(),
InteractionCache.preload(),
UserRelays.preload(follows),
RelayMetrics.preload(),

View File

@ -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<NostrEvent>;
chats!: Table<NostrEvent>;
eventInteraction!: Table<EventInteraction>;
payments!: Table<Payment>;

View File

@ -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 || "<ERROR>");
if (!isMe) {
setLastReadDm(props.data.pubkey);
setLastReadIn(ev.pubkey);
}
}
}
function sender() {
if (props.chat.type !== ChatType.DirectMessage && !isMe) {
return <ProfileImage pubkey={ev.pubkey} />;
}
}
useEffect(() => {
if (!decrypted && inView) {
if (!decrypted && inView && needsDecryption) {
setDecrypted(true);
decrypt().catch(console.error);
}
}, [inView, props.data]);
}, [inView, ev]);
return (
<div className={isMe ? "dm me" : "dm other"} ref={ref}>
<div>
{sender()}
<Text content={content} tags={[]} creator={otherPubkey} />
</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>
);

View File

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

View File

@ -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<HTMLDivElement>(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 <NoteToSelf className="f-grow mb-10" pubkey={id} />;
}
if (chat?.type === ChatType.DirectMessage) {
return <ProfileImage pubkey={id} className="f-grow mb10" />;
}
if (chat?.profile) {
return <ProfileImage pubkey={id} className="f-grow mb10" profile={chat.profile} />;
}
return <ProfileImage pubkey={""} className="f-grow mb10" overrideUsername={chat?.id} />;
}
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 (
<div className="dm-window">
<div>{sender()}</div>
<div>
{(id === pubKey && <NoteToSelf className="f-grow mb-10" pubkey={id} />) || (
<ProfileImage pubkey={id} className="f-grow mb10" />
)}
<div className="flex f-col">{chat && <DmChatSelected chat={chat} />}</div>
</div>
<div>
<div className="flex f-col" ref={dmListRef}>
<DmChatSelected chatPubKey={id} />
</div>
</div>
<div>
<WriteDm chatPubKey={id} />
<WriteMessage chatId={id} />
</div>
</div>
);
}
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 => (
<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 { 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;

View File

@ -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<HTMLTextAreaElement>) {
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 && <b className="error">{error}</b>}
</div>
<button className="btn-rnd" onClick={() => sendDm()}>
<button className="btn-rnd" onClick={() => sendMessage()}>
{sending ? <Spinner width={20} /> : <Icon name="arrow-right" size={20} />}
</button>
</>

View File

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

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

View File

@ -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<string>();
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<HTMLDivElement>, pubkey: string) {
e.stopPropagation();
@ -62,39 +46,34 @@ export default function MessagesPage() {
}
}
function noteToSelf(chat: DmChat) {
function noteToSelf(chat: Chat) {
return (
<div className="flex mb10" key={chat.pubkey} onClick={e => openChat(e, chat.pubkey)}>
<NoteToSelf clickable={true} className="f-grow" link="" pubkey={chat.pubkey} />
<div className="flex mb10" key={chat.id} onClick={e => openChat(e, chat.id)}>
<NoteToSelf clickable={true} className="f-grow" link="" pubkey={chat.id} />
</div>
);
}
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 (
<div className="flex mb10" key={chat.pubkey} onClick={e => openChat(e, chat.pubkey)}>
<ProfileImage pubkey={chat.pubkey} className="f-grow" link="" />
<div className="flex mb10" key={chat.id} onClick={e => openChat(e, chat.id)}>
{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">
<small>
<NoteTime
from={newestMessage(dms, login.publicKey, chat.pubkey) * 1000}
fallback={formatMessage({ defaultMessage: "Just now" })}
/>
<NoteTime from={chat.lastMessage * 1000} fallback={formatMessage({ defaultMessage: "Just now" })} />
</small>
{chat.unreadMessages > 0 && <UnreadCount unread={chat.unreadMessages} />}
{chat.unread > 0 && <UnreadCount unread={chat.unread} />}
</div>
</div>
);
}
function markAllRead() {
for (const c of chats) {
setLastReadDm(c.pubkey);
}
}
return (
<div className="dm-page">
<div>
@ -102,17 +81,13 @@ export default function MessagesPage() {
<h3 className="f-grow">
<FormattedMessage {...messages.Messages} />
</h3>
<button disabled={unreadCount <= 0} type="button" onClick={() => markAllRead()}>
<button disabled={unreadCount <= 0} type="button">
<FormattedMessage {...messages.MarkAllRead} />
</button>
</div>
{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)}
</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,7 +34,9 @@ export abstract class ExternalStore<TSnapshot> {
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;

View File

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

View File

@ -38,19 +38,21 @@ export type ReqCommand = [cmd: "REQ", id: string, ...filters: Array<ReqFilter>];
* 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<string> | Array<number> | string | number | undefined
}
/**

View File

@ -142,7 +142,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> 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<SystemSnapshot> implements System
* Write an event to a relay then disconnect
*/
async WriteOnceToRelay(address: string, ev: NostrEvent) {
return new Promise<void>((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<void>((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 {

View File

@ -22,7 +22,7 @@ export class ProfileLoaderService {
/**
* List of pubkeys to fetch metadata for
*/
WantsMetadata: Set<HexKey> = new Set();
#wantsMetadata: Set<HexKey> = new Set();
readonly #log = debug("ProfileCache");
@ -42,7 +42,7 @@ export class ProfileLoaderService {
TrackMetadata(pk: HexKey | Array<HexKey>) {
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<HexKey>) {
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]);

View File

@ -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<string>) {
tag(key: "e" | "p" | "d" | "t" | "r" | "a" | "g", value?: Array<string>) {
if (!value) return this;
this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`], value);
return this;