feat: nip-28
This commit is contained in:
@ -20,6 +20,8 @@ import { Nip29ChatSystem } from "./nip29";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { Nip24ChatSystem } from "./nip24";
|
||||
import { LoginSession } from "Login";
|
||||
import { Nip28ChatSystem } from "./nip28";
|
||||
|
||||
export enum ChatType {
|
||||
DirectMessage = 1,
|
||||
@ -60,7 +62,7 @@ export interface ChatSystem {
|
||||
/**
|
||||
* Create a request for this system to get updates
|
||||
*/
|
||||
subscription(id: string): RequestBuilder | undefined;
|
||||
subscription(session: LoginSession): RequestBuilder | undefined;
|
||||
onEvent(evs: readonly TaggedNostrEvent[]): Promise<void> | void;
|
||||
|
||||
listChats(pk: string): Array<Chat>;
|
||||
@ -69,6 +71,7 @@ export interface ChatSystem {
|
||||
export const Nip4Chats = new Nip4ChatSystem(Chats);
|
||||
export const Nip29Chats = new Nip29ChatSystem(Chats);
|
||||
export const Nip24Chats = new Nip24ChatSystem(GiftsCache);
|
||||
export const Nip28Chats = new Nip28ChatSystem(Chats);
|
||||
|
||||
/**
|
||||
* Extract the P tag of the event
|
||||
@ -143,17 +146,23 @@ export function createChatLink(type: ChatType, ...params: Array<string>) {
|
||||
),
|
||||
)}`;
|
||||
}
|
||||
case ChatType.PublicGroupChat: {
|
||||
return `/messages/${Nip28ChatSystem.chatId(params[0])}`;
|
||||
}
|
||||
}
|
||||
throw new Error("Unknown chat type");
|
||||
}
|
||||
|
||||
export function createEmptyChatObject(id: string) {
|
||||
if (id.startsWith("chat4")) {
|
||||
if (id.startsWith("chat41")) {
|
||||
return Nip4ChatSystem.createChatObj(id, []);
|
||||
}
|
||||
if (id.startsWith("chat24")) {
|
||||
if (id.startsWith("chat241")) {
|
||||
return Nip24ChatSystem.createChatObj(id, []);
|
||||
}
|
||||
if (id.startsWith("chat281")) {
|
||||
return Nip28ChatSystem.createChatObj(id, []);
|
||||
}
|
||||
throw new Error("Cant create new empty chat, unknown id");
|
||||
}
|
||||
|
||||
@ -180,10 +189,18 @@ export function useNip24Chat() {
|
||||
);
|
||||
}
|
||||
|
||||
export function useNip28Chat() {
|
||||
return useSyncExternalStore(
|
||||
c => Nip28Chats.hook(c),
|
||||
() => Nip28Chats.snapshot(),
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatSystem() {
|
||||
const nip4 = useNip4Chat();
|
||||
//const nip24 = useNip24Chat();
|
||||
const nip28 = useNip28Chat();
|
||||
const { muted, blocked } = useModeration();
|
||||
|
||||
return [...nip4].filter(a => !(muted.includes(a.id) || blocked.includes(a.id)));
|
||||
return [...nip4, ...nip28].filter(a => !(muted.includes(a.id) || blocked.includes(a.id)));
|
||||
}
|
||||
|
@ -0,0 +1,164 @@
|
||||
import debug from "debug";
|
||||
import { ExternalStore, FeedCache, unixNow, unwrap } from "@snort/shared";
|
||||
import {
|
||||
EventKind,
|
||||
NostrEvent,
|
||||
NostrPrefix,
|
||||
RequestBuilder,
|
||||
SystemInterface,
|
||||
TLVEntryType,
|
||||
TaggedNostrEvent,
|
||||
UserMetadata,
|
||||
decodeTLV,
|
||||
encodeTLVEntries,
|
||||
} from "@snort/system";
|
||||
|
||||
import { LoginSession } from "Login";
|
||||
import { findTag } from "SnortUtils";
|
||||
import { Chat, ChatParticipant, ChatSystem, ChatType, lastReadInChat } from "chat";
|
||||
import { Day } from "Const";
|
||||
|
||||
export class Nip28ChatSystem extends ExternalStore<Array<Chat>> implements ChatSystem {
|
||||
#cache: FeedCache<NostrEvent>;
|
||||
#log = debug("NIP-04");
|
||||
readonly ChannelKinds = [
|
||||
EventKind.PublicChatChannel,
|
||||
EventKind.PublicChatMessage,
|
||||
EventKind.PublicChatMetadata,
|
||||
EventKind.PublicChatMuteMessage,
|
||||
EventKind.PublicChatMuteUser,
|
||||
];
|
||||
|
||||
constructor(cache: FeedCache<NostrEvent>) {
|
||||
super();
|
||||
this.#cache = cache;
|
||||
}
|
||||
|
||||
subscription(session: LoginSession): RequestBuilder | undefined {
|
||||
const chats = (session.extraChats ?? []).filter(a => a.startsWith("chat281"));
|
||||
if (chats.length === 0) return;
|
||||
|
||||
const chatId = (v: string) => unwrap(decodeTLV(v).find(a => a.type === TLVEntryType.Special)).value as string;
|
||||
|
||||
const messages = this.#chatChannels();
|
||||
const rb = new RequestBuilder(`nip28:${session.id}`);
|
||||
rb.withFilter()
|
||||
.ids(chats.map(v => chatId(v)))
|
||||
.kinds([EventKind.PublicChatChannel, EventKind.PublicChatMetadata]);
|
||||
for (const c of chats) {
|
||||
const id = chatId(c);
|
||||
const lastMessage = messages[id]?.reduce((acc, v) => (v.created_at > acc ? v.created_at : acc), 0) ?? 0;
|
||||
rb.withFilter()
|
||||
.tag("e", [id])
|
||||
.since(lastMessage === 0 ? unixNow() - 2 * Day : lastMessage)
|
||||
.kinds(this.ChannelKinds);
|
||||
}
|
||||
|
||||
return rb;
|
||||
}
|
||||
|
||||
async onEvent(evs: readonly TaggedNostrEvent[]) {
|
||||
const dms = evs.filter(a => this.ChannelKinds.includes(a.kind));
|
||||
if (dms.length > 0) {
|
||||
await this.#cache.bulkSet(dms);
|
||||
this.notifyChange();
|
||||
}
|
||||
}
|
||||
|
||||
listChats(): Chat[] {
|
||||
const chats = this.#chatChannels();
|
||||
return Object.entries(chats).map(([k, v]) => {
|
||||
return Nip28ChatSystem.createChatObj(Nip28ChatSystem.chatId(k), v);
|
||||
});
|
||||
}
|
||||
|
||||
static chatId(id: string) {
|
||||
return encodeTLVEntries("chat28" as NostrPrefix, {
|
||||
type: TLVEntryType.Special,
|
||||
value: id,
|
||||
length: id.length,
|
||||
});
|
||||
}
|
||||
|
||||
static createChatObj(id: string, messages: Array<NostrEvent>) {
|
||||
const last = lastReadInChat(id);
|
||||
const participants = decodeTLV(id)
|
||||
.filter(v => v.type === TLVEntryType.Special)
|
||||
.map(
|
||||
v =>
|
||||
({
|
||||
type: "generic",
|
||||
id: v.value as string,
|
||||
profile: this.#chatProfileFromMessages(messages),
|
||||
}) as ChatParticipant,
|
||||
);
|
||||
return {
|
||||
type: ChatType.PublicGroupChat,
|
||||
id,
|
||||
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),
|
||||
participants,
|
||||
messages: messages
|
||||
.filter(a => a.kind === EventKind.PublicChatMessage)
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
created_at: m.created_at,
|
||||
from: m.pubkey,
|
||||
tags: m.tags,
|
||||
content: m.content,
|
||||
needsDecryption: false,
|
||||
})),
|
||||
createMessage: async (msg, pub) => {
|
||||
return [
|
||||
await pub.generic(eb => {
|
||||
return eb.kind(EventKind.PublicChatMessage).content(msg).tag(["e", participants[0].id, "", "root"]);
|
||||
}),
|
||||
];
|
||||
},
|
||||
sendMessage: (ev, system: SystemInterface) => {
|
||||
ev.forEach(a => system.BroadcastEvent(a));
|
||||
},
|
||||
} as Chat;
|
||||
}
|
||||
|
||||
takeSnapshot(): Chat[] {
|
||||
return this.listChats();
|
||||
}
|
||||
|
||||
static #chatProfileFromMessages(messages: Array<NostrEvent>) {
|
||||
const chatDefs = messages.filter(
|
||||
a => a.kind === EventKind.PublicChatChannel || a.kind === EventKind.PublicChatMetadata,
|
||||
);
|
||||
const chatDef =
|
||||
chatDefs.length > 0
|
||||
? chatDefs.reduce((acc, v) => (acc.created_at > v.created_at ? acc : v), chatDefs[0])
|
||||
: undefined;
|
||||
return chatDef ? (JSON.parse(chatDef.content) as UserMetadata) : undefined;
|
||||
}
|
||||
|
||||
#chatChannels() {
|
||||
const messages = this.#cache.snapshot();
|
||||
const chats = messages.reduce(
|
||||
(acc, v) => {
|
||||
const k = this.#chatId(v);
|
||||
if (k) {
|
||||
acc[k] ??= [];
|
||||
acc[k].push(v);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Array<NostrEvent>>,
|
||||
);
|
||||
return chats;
|
||||
}
|
||||
|
||||
#chatId(ev: NostrEvent) {
|
||||
if (ev.kind === EventKind.PublicChatChannel) {
|
||||
return ev.id;
|
||||
} else if (ev.kind === EventKind.PublicChatMetadata) {
|
||||
return findTag(ev, "e");
|
||||
} else if (this.ChannelKinds.includes(ev.kind)) {
|
||||
return ev.tags.find(a => a[0] === "e" && a[3] === "root")?.[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ExternalStore, FeedCache, dedupe } from "@snort/shared";
|
||||
import { RequestBuilder, NostrEvent, EventKind, SystemInterface, TaggedNostrEvent } from "@snort/system";
|
||||
import { LoginSession } from "Login";
|
||||
import { unwrap } from "SnortUtils";
|
||||
import { Chat, ChatSystem, ChatType, lastReadInChat } from "chat";
|
||||
|
||||
@ -15,7 +16,9 @@ export class Nip29ChatSystem extends ExternalStore<Array<Chat>> implements ChatS
|
||||
return this.listChats();
|
||||
}
|
||||
|
||||
subscription(id: string) {
|
||||
subscription(session: LoginSession) {
|
||||
const id = session.publicKey;
|
||||
if (!id) return;
|
||||
const gs = id.split("/", 2);
|
||||
const rb = new RequestBuilder(`nip29:${id}`);
|
||||
const last = this.listChats().find(a => a.id === id)?.lastMessage;
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
TaggedNostrEvent,
|
||||
decodeTLV,
|
||||
} from "@snort/system";
|
||||
import { LoginSession } from "Login";
|
||||
import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat } from "chat";
|
||||
import { debug } from "debug";
|
||||
|
||||
@ -30,7 +31,10 @@ export class Nip4ChatSystem extends ExternalStore<Array<Chat>> implements ChatSy
|
||||
}
|
||||
}
|
||||
|
||||
subscription(pk: string) {
|
||||
subscription(session: LoginSession) {
|
||||
const pk = session.publicKey;
|
||||
if (!pk) return;
|
||||
|
||||
const rb = new RequestBuilder(`nip4:${pk.slice(0, 12)}`);
|
||||
const dms = this.#cache.snapshot();
|
||||
const dmSince = dms.reduce(
|
||||
|
Reference in New Issue
Block a user