feat: NIP-24
This commit is contained in:
@ -2,27 +2,46 @@ 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 { Chats, GiftsCache } from "Cache";
|
||||
import { findTag, unixNow } from "SnortUtils";
|
||||
import { Nip29ChatSystem } from "./nip29";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { Nip24ChatSystem } from "./nip24";
|
||||
|
||||
export enum ChatType {
|
||||
DirectMessage = 1,
|
||||
PublicGroupChat = 2,
|
||||
PrivateGroupChat = 3,
|
||||
PrivateDirectMessage = 4,
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
from: string;
|
||||
created_at: number;
|
||||
tags: Array<Array<string>>;
|
||||
needsDecryption: boolean;
|
||||
content: string;
|
||||
decrypt: (pub: EventPublisher) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface ChatParticipant {
|
||||
type: "pubkey" | "generic";
|
||||
id: string;
|
||||
profile?: UserMetadata;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
type: ChatType;
|
||||
id: string;
|
||||
title?: 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>;
|
||||
participants: Array<ChatParticipant>;
|
||||
messages: Array<ChatMessage>;
|
||||
createMessage(msg: string, pub: EventPublisher): Promise<Array<NostrEvent>>;
|
||||
sendMessage(ev: Array<NostrEvent>, system: SystemInterface): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ChatSystem {
|
||||
@ -37,6 +56,7 @@ export interface ChatSystem {
|
||||
|
||||
export const Nip4Chats = new Nip4ChatSystem(Chats);
|
||||
export const Nip29Chats = new Nip29ChatSystem(Chats);
|
||||
export const Nip24Chats = new Nip24ChatSystem(GiftsCache);
|
||||
|
||||
/**
|
||||
* Extract the P tag of the event
|
||||
@ -89,10 +109,18 @@ export function useNip29Chat() {
|
||||
);
|
||||
}
|
||||
|
||||
export function useNip24Chat() {
|
||||
const { publicKey } = useLogin();
|
||||
return useSyncExternalStore(
|
||||
c => Nip24Chats.hook(c),
|
||||
() => Nip24Chats.snapshot(publicKey)
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatSystem() {
|
||||
const nip4 = useNip4Chat();
|
||||
const nip29 = useNip29Chat();
|
||||
const nip24 = useNip24Chat();
|
||||
const { muted, blocked } = useModeration();
|
||||
|
||||
return [...nip4, ...nip29].filter(a => !(muted.includes(a.id) || blocked.includes(a.id)));
|
||||
return [...nip4, ...nip24].filter(a => !(muted.includes(a.id) || blocked.includes(a.id)));
|
||||
}
|
||||
|
129
packages/app/src/chat/nip24.ts
Normal file
129
packages/app/src/chat/nip24.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { ExternalStore, dedupe } from "@snort/shared";
|
||||
import {
|
||||
EventKind,
|
||||
SystemInterface,
|
||||
NostrPrefix,
|
||||
encodeTLVEntries,
|
||||
TLVEntryType,
|
||||
TLVEntry,
|
||||
decodeTLV,
|
||||
} from "@snort/system";
|
||||
import { GiftWrapCache } from "Cache/GiftWrapCache";
|
||||
import { UnwrappedGift } from "Db";
|
||||
import { Chat, ChatSystem, ChatType, lastReadInChat } from "chat";
|
||||
|
||||
export class Nip24ChatSystem extends ExternalStore<Array<Chat>> implements ChatSystem {
|
||||
#cache: GiftWrapCache;
|
||||
|
||||
constructor(cache: GiftWrapCache) {
|
||||
super();
|
||||
this.#cache = cache;
|
||||
this.#cache.hook(() => this.notifyChange(), "*");
|
||||
}
|
||||
|
||||
subscription() {
|
||||
// ignored
|
||||
return undefined;
|
||||
}
|
||||
|
||||
onEvent() {
|
||||
// ignored
|
||||
}
|
||||
|
||||
listChats(pk: string): Chat[] {
|
||||
const evs = this.#nip24Events();
|
||||
const messages = evs.filter(a => a.to === pk);
|
||||
const chatId = (u: UnwrappedGift) => {
|
||||
const pTags = dedupe([...(u.tags ?? []).filter(a => a[0] === "p").map(a => a[1]), u.inner.pubkey])
|
||||
.sort()
|
||||
.filter(a => a !== pk);
|
||||
|
||||
return encodeTLVEntries(
|
||||
"chat24" as NostrPrefix,
|
||||
...pTags.map(
|
||||
v =>
|
||||
({
|
||||
value: v,
|
||||
type: TLVEntryType.Author,
|
||||
length: v.length,
|
||||
} as TLVEntry)
|
||||
)
|
||||
);
|
||||
};
|
||||
return dedupe(messages.map(a => chatId(a))).map(a => {
|
||||
const chatMessages = messages.filter(b => chatId(b) === a);
|
||||
return Nip24ChatSystem.createChatObj(a, chatMessages);
|
||||
});
|
||||
}
|
||||
|
||||
static createChatObj(id: string, messages: Array<UnwrappedGift>) {
|
||||
const last = lastReadInChat(id);
|
||||
const participants = decodeTLV(id)
|
||||
.filter(v => v.type === TLVEntryType.Author)
|
||||
.map(v => ({
|
||||
type: "pubkey",
|
||||
id: v.value as string,
|
||||
}));
|
||||
const title = messages.reduce(
|
||||
(acc, v) => {
|
||||
const sbj = v.tags?.find(a => a[0] === "subject")?.[1];
|
||||
if (v.created_at > acc.t && sbj) {
|
||||
acc.title = sbj;
|
||||
acc.t = v.created_at;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
t: 0,
|
||||
title: "",
|
||||
}
|
||||
);
|
||||
return {
|
||||
type: ChatType.PrivateDirectMessage,
|
||||
id,
|
||||
title: title.title,
|
||||
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.map(m => ({
|
||||
id: m.id,
|
||||
created_at: m.created_at,
|
||||
from: m.inner.pubkey,
|
||||
tags: m.tags,
|
||||
content: "",
|
||||
needsDecryption: true,
|
||||
decrypt: async pub => {
|
||||
return await pub.decryptDm(m.inner);
|
||||
},
|
||||
})),
|
||||
createMessage: async (msg, pub) => {
|
||||
const gossip = pub.createUnsigned(EventKind.ChatRumor, msg, eb => {
|
||||
for (const pt of participants) {
|
||||
eb.tag(["p", pt.id]);
|
||||
}
|
||||
return eb;
|
||||
});
|
||||
const messages = [];
|
||||
for (const pt of participants) {
|
||||
const recvSealedN = await pub.giftWrap(await pub.sealRumor(gossip, pt.id), pt.id);
|
||||
messages.push(recvSealedN);
|
||||
}
|
||||
const sendSealed = await pub.giftWrap(await pub.sealRumor(gossip, pub.pubKey), pub.pubKey);
|
||||
return [...messages, sendSealed];
|
||||
},
|
||||
sendMessage: (ev, system: SystemInterface) => {
|
||||
console.debug(ev);
|
||||
ev.forEach(a => system.BroadcastEvent(a));
|
||||
},
|
||||
} as Chat;
|
||||
}
|
||||
|
||||
takeSnapshot(p: string): Chat[] {
|
||||
return this.listChats(p);
|
||||
}
|
||||
|
||||
#nip24Events() {
|
||||
const sn = this.#cache.takeSnapshot();
|
||||
return sn.filter(a => a.inner.kind === EventKind.SealedRumor);
|
||||
}
|
||||
}
|
0
packages/app/src/chat/nip28.ts
Normal file
0
packages/app/src/chat/nip28.ts
Normal file
@ -57,19 +57,41 @@ export class Nip29ChatSystem extends ExternalStore<Array<Chat>> implements ChatS
|
||||
return {
|
||||
type: ChatType.PublicGroupChat,
|
||||
id: g,
|
||||
title: `${relay}/${channel}`,
|
||||
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);
|
||||
});
|
||||
messages: messages.map(m => ({
|
||||
id: m.id,
|
||||
created_at: m.created_at,
|
||||
from: m.pubkey,
|
||||
tags: m.tags,
|
||||
needsDecryption: false,
|
||||
content: m.content,
|
||||
decrypt: async () => {
|
||||
return m.content;
|
||||
},
|
||||
})),
|
||||
participants: [
|
||||
{
|
||||
type: "generic",
|
||||
id: "",
|
||||
profile: {
|
||||
name: `${relay}/${channel}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
createMessage: async (msg, pub) => {
|
||||
return [
|
||||
await 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);
|
||||
sendMessage: async (ev, system: SystemInterface) => {
|
||||
ev.forEach(async a => await system.WriteOnceToRelay(`wss://${relay}`, a));
|
||||
},
|
||||
} as Chat;
|
||||
});
|
||||
|
@ -1,5 +1,14 @@
|
||||
import { ExternalStore, FeedCache, dedupe } from "@snort/shared";
|
||||
import { EventKind, NostrEvent, RequestBuilder, SystemInterface } from "@snort/system";
|
||||
import {
|
||||
EventKind,
|
||||
NostrEvent,
|
||||
NostrPrefix,
|
||||
RequestBuilder,
|
||||
SystemInterface,
|
||||
TLVEntryType,
|
||||
decodeTLV,
|
||||
encodeTLVEntries,
|
||||
} from "@snort/system";
|
||||
import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat, selfChat } from "chat";
|
||||
import { debug } from "debug";
|
||||
|
||||
@ -40,27 +49,50 @@ export class Nip4ChatSystem extends ExternalStore<Array<Chat>> implements ChatSy
|
||||
|
||||
listChats(pk: string): Chat[] {
|
||||
const myDms = this.#nip4Events();
|
||||
return dedupe(myDms.map(a => inChatWith(a, pk))).map(a => {
|
||||
const messages = myDms.filter(
|
||||
b => (a === pk && selfChat(b, pk)) || (!selfChat(b, pk) && inChatWith(b, pk) === a)
|
||||
);
|
||||
const chatId = (a: NostrEvent) => {
|
||||
return encodeTLVEntries("chat4" as NostrPrefix, {
|
||||
type: TLVEntryType.Author,
|
||||
value: inChatWith(a, pk),
|
||||
length: 0,
|
||||
});
|
||||
};
|
||||
|
||||
return dedupe(myDms.map(chatId)).map(a => {
|
||||
const messages = myDms.filter(b => chatId(b) === a);
|
||||
return Nip4ChatSystem.createChatObj(a, messages);
|
||||
});
|
||||
}
|
||||
|
||||
static createChatObj(id: string, messages: Array<NostrEvent>) {
|
||||
const last = lastReadInChat(id);
|
||||
const pk = decodeTLV(id).find(a => a.type === TLVEntryType.Author)?.value as string;
|
||||
return {
|
||||
type: ChatType.DirectMessage,
|
||||
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),
|
||||
messages,
|
||||
createMessage: (msg, pub) => {
|
||||
return pub.sendDm(msg, id);
|
||||
participants: [
|
||||
{
|
||||
type: "pubkey",
|
||||
id: pk,
|
||||
},
|
||||
],
|
||||
messages: messages.map(m => ({
|
||||
id: m.id,
|
||||
created_at: m.created_at,
|
||||
from: m.pubkey,
|
||||
tags: m.tags,
|
||||
content: "",
|
||||
needsDecryption: true,
|
||||
decrypt: async pub => {
|
||||
return await pub.decryptDm(m);
|
||||
},
|
||||
})),
|
||||
createMessage: async (msg, pub) => {
|
||||
return [await pub.sendDm(msg, pk)];
|
||||
},
|
||||
sendMessage: (ev: NostrEvent, system: SystemInterface) => {
|
||||
system.BroadcastEvent(ev);
|
||||
sendMessage: (ev, system: SystemInterface) => {
|
||||
ev.forEach(a => system.BroadcastEvent(a));
|
||||
},
|
||||
} as Chat;
|
||||
}
|
||||
|
Reference in New Issue
Block a user