Chat system refactor
This commit is contained in:
parent
4365fac9b5
commit
234c1c092d
3
.gitignore
vendored
3
.gitignore
vendored
@ -4,4 +4,5 @@ node_modules/
|
||||
yarn.lock
|
||||
dist/
|
||||
*.tgz
|
||||
*.log
|
||||
*.log
|
||||
.DS_Store
|
@ -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();
|
@ -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(),
|
||||
|
@ -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>;
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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) {
|
||||
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
</>
|
@ -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)
|
||||
|
@ -1,9 +0,0 @@
|
||||
import { DmCache } from "Cache";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export function useDmCache() {
|
||||
return useSyncExternalStore(
|
||||
c => DmCache.hook(c, "*"),
|
||||
() => DmCache.snapshot()
|
||||
);
|
||||
}
|
@ -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();
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
90
packages/app/src/chat/index.ts
Normal file
90
packages/app/src/chat/index.ts
Normal 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];
|
||||
}
|
81
packages/app/src/chat/nip29.ts
Normal file
81
packages/app/src/chat/nip29.ts
Normal 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);
|
||||
}
|
||||
}
|
65
packages/app/src/chat/nip4.ts
Normal file
65
packages/app/src/chat/nip4.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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 {
|
||||
|
@ -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]);
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user