feat: nip-28
This commit is contained in:
parent
2833813ef3
commit
8e414a10c5
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"arcanis.vscode-zipfs",
|
"arcanis.vscode-zipfs",
|
||||||
"dbaeumer.vscode-eslint"
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
2
.yarn/sdks/eslint/package.json
vendored
2
.yarn/sdks/eslint/package.json
vendored
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "eslint",
|
"name": "eslint",
|
||||||
"version": "8.44.0-sdk",
|
"version": "8.48.0-sdk",
|
||||||
"main": "./lib/api.js",
|
"main": "./lib/api.js",
|
||||||
"type": "commonjs"
|
"type": "commonjs"
|
||||||
}
|
}
|
||||||
|
20
.yarn/sdks/prettier/index.js
vendored
Executable file
20
.yarn/sdks/prettier/index.js
vendored
Executable file
@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const {existsSync} = require(`fs`);
|
||||||
|
const {createRequire} = require(`module`);
|
||||||
|
const {resolve} = require(`path`);
|
||||||
|
|
||||||
|
const relPnpApiPath = "../../../.pnp.cjs";
|
||||||
|
|
||||||
|
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
|
||||||
|
const absRequire = createRequire(absPnpApiPath);
|
||||||
|
|
||||||
|
if (existsSync(absPnpApiPath)) {
|
||||||
|
if (!process.versions.pnp) {
|
||||||
|
// Setup the environment to be able to require prettier
|
||||||
|
require(absPnpApiPath).setup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer to the real prettier your application uses
|
||||||
|
module.exports = absRequire(`prettier`);
|
6
.yarn/sdks/prettier/package.json
vendored
Normal file
6
.yarn/sdks/prettier/package.json
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "prettier",
|
||||||
|
"version": "3.0.3-sdk",
|
||||||
|
"main": "./index.js",
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
2
.yarn/sdks/typescript/package.json
vendored
2
.yarn/sdks/typescript/package.json
vendored
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "typescript",
|
"name": "typescript",
|
||||||
"version": "5.1.6-sdk",
|
"version": "5.2.2-sdk",
|
||||||
"main": "./lib/typescript.js",
|
"main": "./lib/typescript.js",
|
||||||
"type": "commonjs"
|
"type": "commonjs"
|
||||||
}
|
}
|
||||||
|
@ -9,11 +9,6 @@
|
|||||||
"test": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/app test && yarn workspace @snort/system test",
|
"test": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/app test && yarn workspace @snort/system test",
|
||||||
"pre:commit": "yarn workspace @snort/app intl-extract && yarn workspace @snort/app intl-compile && yarn prettier --write ."
|
"pre:commit": "yarn workspace @snort/app intl-extract && yarn workspace @snort/app intl-compile && yarn prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
|
||||||
"@cloudflare/workers-types": "^4.20230307.0",
|
|
||||||
"@tauri-apps/cli": "^1.2.3",
|
|
||||||
"prettier": "^3.0.0"
|
|
||||||
},
|
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
"bracketSameLine": true,
|
"bracketSameLine": true,
|
||||||
@ -21,7 +16,10 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "yarn@3.6.3",
|
"packageManager": "yarn@3.6.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20230307.0",
|
||||||
|
"@tauri-apps/cli": "^1.2.3",
|
||||||
"eslint": "^8.48.0",
|
"eslint": "^8.48.0",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
12
packages/app/src/Element/ChatParticipant.tsx
Normal file
12
packages/app/src/Element/ChatParticipant.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ChatParticipant } from "chat";
|
||||||
|
import NoteToSelf from "./NoteToSelf";
|
||||||
|
import ProfileImage from "./ProfileImage";
|
||||||
|
import useLogin from "Hooks/useLogin";
|
||||||
|
|
||||||
|
export function ChatParticipantProfile({ participant }: { participant: ChatParticipant }) {
|
||||||
|
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
|
||||||
|
if (participant.id === publicKey) {
|
||||||
|
return <NoteToSelf className="f-grow" pubkey={participant.id} />;
|
||||||
|
}
|
||||||
|
return <ProfileImage pubkey={participant.id} className="f-grow" profile={participant.profile} />;
|
||||||
|
}
|
@ -3,33 +3,19 @@ import { useMemo } from "react";
|
|||||||
|
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import DM from "Element/DM";
|
import DM from "Element/DM";
|
||||||
import NoteToSelf from "Element/NoteToSelf";
|
|
||||||
import useLogin from "Hooks/useLogin";
|
import useLogin from "Hooks/useLogin";
|
||||||
import WriteMessage from "Element/WriteMessage";
|
import WriteMessage from "Element/WriteMessage";
|
||||||
import { Chat, ChatParticipant, createEmptyChatObject, useChatSystem } from "chat";
|
import { Chat, createEmptyChatObject, useChatSystem } from "chat";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { ChatParticipantProfile } from "./ChatParticipant";
|
||||||
|
|
||||||
export default function DmWindow({ id }: { id: string }) {
|
export default function DmWindow({ id }: { id: string }) {
|
||||||
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
|
|
||||||
const dms = useChatSystem();
|
const dms = useChatSystem();
|
||||||
const chat = dms.find(a => a.id === id) ?? createEmptyChatObject(id);
|
const chat = dms.find(a => a.id === id) ?? createEmptyChatObject(id);
|
||||||
|
|
||||||
function participant(p: ChatParticipant) {
|
|
||||||
if (p.id === publicKey) {
|
|
||||||
return <NoteToSelf className="f-grow mb-10" pubkey={p.id} />;
|
|
||||||
}
|
|
||||||
if (p.type === "pubkey") {
|
|
||||||
return <ProfileImage pubkey={p.id} className="f-grow mb10" />;
|
|
||||||
}
|
|
||||||
if (p?.profile) {
|
|
||||||
return <ProfileImage pubkey={p.id} className="f-grow mb10" profile={p.profile} />;
|
|
||||||
}
|
|
||||||
return <ProfileImage pubkey={p.id} className="f-grow mb10" overrideUsername={p.id} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sender() {
|
function sender() {
|
||||||
if (chat.participants.length === 1) {
|
if (chat.participants.length === 1) {
|
||||||
return participant(chat.participants[0]);
|
return <ChatParticipantProfile participant={chat.participants[0]} />;
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="flex pfp-overlap mb10">
|
<div className="flex pfp-overlap mb10">
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import "./ProfilePreview.css";
|
import "./ProfilePreview.css";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { HexKey } from "@snort/system";
|
import { HexKey, UserMetadata } from "@snort/system";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
import { useUserProfile } from "@snort/system-react";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
@ -13,6 +13,7 @@ export interface ProfilePreviewProps {
|
|||||||
about?: boolean;
|
about?: boolean;
|
||||||
linkToProfile?: boolean;
|
linkToProfile?: boolean;
|
||||||
};
|
};
|
||||||
|
profile?: UserMetadata;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
@ -41,6 +42,7 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
|
|||||||
<>
|
<>
|
||||||
<ProfileImage
|
<ProfileImage
|
||||||
pubkey={pubkey}
|
pubkey={pubkey}
|
||||||
|
profile={props.profile}
|
||||||
link={options.linkToProfile ?? true ? undefined : ""}
|
link={options.linkToProfile ?? true ? undefined : ""}
|
||||||
subHeader={options.about ? <div className="about">{user?.about}</div> : undefined}
|
subHeader={options.about ? <div className="about">{user?.about}</div> : undefined}
|
||||||
/>
|
/>
|
||||||
|
@ -25,7 +25,7 @@ import { SubscriptionEvent } from "Subscription";
|
|||||||
import useRelaysFeedFollows from "./RelaysFeedFollows";
|
import useRelaysFeedFollows from "./RelaysFeedFollows";
|
||||||
import { FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache";
|
import { FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
import { Nip4Chats } from "chat";
|
import { Nip28Chats, Nip4Chats } from "chat";
|
||||||
import { useRefreshFeedCache } from "Hooks/useRefreshFeedcache";
|
import { useRefreshFeedCache } from "Hooks/useRefreshFeedcache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +42,7 @@ export default function useLoginFeed() {
|
|||||||
useRefreshFeedCache(GiftsCache, true);
|
useRefreshFeedCache(GiftsCache, true);
|
||||||
|
|
||||||
const subLogin = useMemo(() => {
|
const subLogin = useMemo(() => {
|
||||||
if (!pubKey) return null;
|
if (!login || !pubKey) return null;
|
||||||
|
|
||||||
const b = new RequestBuilder(`login:${pubKey.slice(0, 12)}`);
|
const b = new RequestBuilder(`login:${pubKey.slice(0, 12)}`);
|
||||||
b.withOptions({
|
b.withOptions({
|
||||||
@ -57,10 +57,16 @@ export default function useLoginFeed() {
|
|||||||
.tag("p", [pubKey])
|
.tag("p", [pubKey])
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
b.add(Nip4Chats.subscription(pubKey));
|
const n4Sub = Nip4Chats.subscription(login);
|
||||||
|
if (n4Sub) {
|
||||||
|
b.add(n4Sub);
|
||||||
|
}
|
||||||
|
const n28Sub = Nip28Chats.subscription(login);
|
||||||
|
if (n28Sub) {
|
||||||
|
b.add(n28Sub);
|
||||||
|
}
|
||||||
return b;
|
return b;
|
||||||
}, [pubKey]);
|
}, [login]);
|
||||||
|
|
||||||
const subLists = useMemo(() => {
|
const subLists = useMemo(() => {
|
||||||
if (!pubKey) return null;
|
if (!pubKey) return null;
|
||||||
@ -94,6 +100,7 @@ export default function useLoginFeed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Nip4Chats.onEvent(loginFeed.data);
|
Nip4Chats.onEvent(loginFeed.data);
|
||||||
|
Nip28Chats.onEvent(loginFeed.data);
|
||||||
|
|
||||||
const subs = loginFeed.data.filter(
|
const subs = loginFeed.data.filter(
|
||||||
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey),
|
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey),
|
||||||
|
@ -123,4 +123,9 @@ export interface LoginSession {
|
|||||||
* Snort application data
|
* Snort application data
|
||||||
*/
|
*/
|
||||||
appData: Newest<SnortAppData>;
|
appData: Newest<SnortAppData>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of chats which we have joined (NIP-28/NIP-29)
|
||||||
|
*/
|
||||||
|
extraChats: Array<string>;
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,7 @@ const LoggedOut = {
|
|||||||
},
|
},
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
},
|
},
|
||||||
|
extraChats: [],
|
||||||
} as LoginSession;
|
} as LoginSession;
|
||||||
|
|
||||||
export class MultiAccountStore extends ExternalStore<LoginSession> {
|
export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||||
@ -79,10 +80,11 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
|||||||
}
|
}
|
||||||
v.appData ??= {
|
v.appData ??= {
|
||||||
item: {
|
item: {
|
||||||
mutedWords: []
|
mutedWords: [],
|
||||||
},
|
},
|
||||||
timestamp: 0
|
timestamp: 0,
|
||||||
}
|
};
|
||||||
|
v.extraChats ??= [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import "./MessagesPage.css";
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { TLVEntryType, decodeTLV } from "@snort/system";
|
import { NostrLink, NostrPrefix, TLVEntryType, UserMetadata, decodeTLV } from "@snort/system";
|
||||||
import { useUserProfile, useUserSearch } from "@snort/system-react";
|
import { useUserProfile, useUserSearch } from "@snort/system-react";
|
||||||
|
|
||||||
import UnreadCount from "Element/UnreadCount";
|
import UnreadCount from "Element/UnreadCount";
|
||||||
@ -21,6 +21,10 @@ import Text from "Element/Text";
|
|||||||
import { Chat, ChatType, createChatLink, useChatSystem } from "chat";
|
import { Chat, ChatType, createChatLink, useChatSystem } from "chat";
|
||||||
import Modal from "Element/Modal";
|
import Modal from "Element/Modal";
|
||||||
import ProfilePreview from "Element/ProfilePreview";
|
import ProfilePreview from "Element/ProfilePreview";
|
||||||
|
import { useEventFeed } from "Feed/EventFeed";
|
||||||
|
import { LoginSession, LoginStore } from "Login";
|
||||||
|
import { Nip28ChatSystem } from "chat/nip28";
|
||||||
|
import { ChatParticipantProfile } from "Element/ChatParticipant";
|
||||||
|
|
||||||
const TwoCol = 768;
|
const TwoCol = 768;
|
||||||
const ThreeCol = 1500;
|
const ThreeCol = 1500;
|
||||||
@ -57,18 +61,12 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
function conversationIdent(cx: Chat) {
|
function conversationIdent(cx: Chat) {
|
||||||
if (cx.participants.length === 1) {
|
if (cx.participants.length === 1) {
|
||||||
const p = cx.participants[0];
|
return <ChatParticipantProfile participant={cx.participants[0]} />;
|
||||||
|
|
||||||
if (p.type === "pubkey") {
|
|
||||||
return <ProfileImage pubkey={p.id} className="f-grow" link="" />;
|
|
||||||
} else {
|
|
||||||
return <ProfileImage pubkey={""} overrideUsername={p.id} className="f-grow" link="" />;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="flex f-grow pfp-overlap">
|
<div className="flex f-grow pfp-overlap">
|
||||||
{cx.participants.map(v => (
|
{cx.participants.map(v => (
|
||||||
<ProfileImage pubkey={v.id} link="" showUsername={false} />
|
<ProfileImage pubkey={v.id} link="" showUsername={false} profile={v.profile} />
|
||||||
))}
|
))}
|
||||||
{cx.title ?? <FormattedMessage defaultMessage="Group Chat" />}
|
{cx.title ?? <FormattedMessage defaultMessage="Group Chat" />}
|
||||||
</div>
|
</div>
|
||||||
@ -168,17 +166,17 @@ function ProfileDmActions({ id }: { id: string }) {
|
|||||||
|
|
||||||
function NewChatWindow() {
|
function NewChatWindow() {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const [newChat, setNewChat] = useState<string[]>([]);
|
const [newChat, setNewChat] = useState<Array<string>>([]);
|
||||||
const [results, setResults] = useState<string[]>([]);
|
const [results, setResults] = useState<Array<string>>([]);
|
||||||
const [term, setSearchTerm] = useState("");
|
const [term, setSearchTerm] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const search = useUserSearch();
|
const search = useUserSearch();
|
||||||
const { follows } = useLogin();
|
const login = useLogin();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNewChat([]);
|
setNewChat([]);
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setResults(follows.item);
|
setResults(login.follows.item);
|
||||||
}, [show]);
|
}, [show]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -186,7 +184,7 @@ function NewChatWindow() {
|
|||||||
if (term) {
|
if (term) {
|
||||||
search(term).then(setResults);
|
search(term).then(setResults);
|
||||||
} else {
|
} else {
|
||||||
setResults(follows.item);
|
setResults(login.follows.item);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [term]);
|
}, [term]);
|
||||||
@ -259,6 +257,19 @@ function NewChatWindow() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{results.length === 1 && (
|
||||||
|
<Nip28ChatProfile
|
||||||
|
id={results[0]}
|
||||||
|
onClick={id => {
|
||||||
|
setShow(false);
|
||||||
|
LoginStore.updateSession({
|
||||||
|
...login,
|
||||||
|
extraChats: appendDedupe(login.extraChats, [Nip28ChatSystem.chatId(id)]),
|
||||||
|
} as LoginSession);
|
||||||
|
navigate(createChatLink(ChatType.PublicGroupChat, id));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -267,3 +278,19 @@ function NewChatWindow() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Nip28ChatProfile({ id, onClick }: { id: string; onClick: (id: string) => void }) {
|
||||||
|
const channel = useEventFeed(new NostrLink(NostrPrefix.Event, id, 40));
|
||||||
|
if (channel?.data) {
|
||||||
|
const meta = JSON.parse(channel.data.content) as UserMetadata;
|
||||||
|
return (
|
||||||
|
<ProfilePreview
|
||||||
|
pubkey=""
|
||||||
|
profile={meta}
|
||||||
|
options={{ about: false, linkToProfile: false }}
|
||||||
|
actions={<></>}
|
||||||
|
onClick={() => onClick(id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -20,6 +20,8 @@ import { Nip29ChatSystem } from "./nip29";
|
|||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import useLogin from "Hooks/useLogin";
|
import useLogin from "Hooks/useLogin";
|
||||||
import { Nip24ChatSystem } from "./nip24";
|
import { Nip24ChatSystem } from "./nip24";
|
||||||
|
import { LoginSession } from "Login";
|
||||||
|
import { Nip28ChatSystem } from "./nip28";
|
||||||
|
|
||||||
export enum ChatType {
|
export enum ChatType {
|
||||||
DirectMessage = 1,
|
DirectMessage = 1,
|
||||||
@ -60,7 +62,7 @@ export interface ChatSystem {
|
|||||||
/**
|
/**
|
||||||
* Create a request for this system to get updates
|
* 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;
|
onEvent(evs: readonly TaggedNostrEvent[]): Promise<void> | void;
|
||||||
|
|
||||||
listChats(pk: string): Array<Chat>;
|
listChats(pk: string): Array<Chat>;
|
||||||
@ -69,6 +71,7 @@ export interface ChatSystem {
|
|||||||
export const Nip4Chats = new Nip4ChatSystem(Chats);
|
export const Nip4Chats = new Nip4ChatSystem(Chats);
|
||||||
export const Nip29Chats = new Nip29ChatSystem(Chats);
|
export const Nip29Chats = new Nip29ChatSystem(Chats);
|
||||||
export const Nip24Chats = new Nip24ChatSystem(GiftsCache);
|
export const Nip24Chats = new Nip24ChatSystem(GiftsCache);
|
||||||
|
export const Nip28Chats = new Nip28ChatSystem(Chats);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the P tag of the event
|
* 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");
|
throw new Error("Unknown chat type");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyChatObject(id: string) {
|
export function createEmptyChatObject(id: string) {
|
||||||
if (id.startsWith("chat4")) {
|
if (id.startsWith("chat41")) {
|
||||||
return Nip4ChatSystem.createChatObj(id, []);
|
return Nip4ChatSystem.createChatObj(id, []);
|
||||||
}
|
}
|
||||||
if (id.startsWith("chat24")) {
|
if (id.startsWith("chat241")) {
|
||||||
return Nip24ChatSystem.createChatObj(id, []);
|
return Nip24ChatSystem.createChatObj(id, []);
|
||||||
}
|
}
|
||||||
|
if (id.startsWith("chat281")) {
|
||||||
|
return Nip28ChatSystem.createChatObj(id, []);
|
||||||
|
}
|
||||||
throw new Error("Cant create new empty chat, unknown 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() {
|
export function useChatSystem() {
|
||||||
const nip4 = useNip4Chat();
|
const nip4 = useNip4Chat();
|
||||||
//const nip24 = useNip24Chat();
|
//const nip24 = useNip24Chat();
|
||||||
|
const nip28 = useNip28Chat();
|
||||||
const { muted, blocked } = useModeration();
|
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 { ExternalStore, FeedCache, dedupe } from "@snort/shared";
|
||||||
import { RequestBuilder, NostrEvent, EventKind, SystemInterface, TaggedNostrEvent } from "@snort/system";
|
import { RequestBuilder, NostrEvent, EventKind, SystemInterface, TaggedNostrEvent } from "@snort/system";
|
||||||
|
import { LoginSession } from "Login";
|
||||||
import { unwrap } from "SnortUtils";
|
import { unwrap } from "SnortUtils";
|
||||||
import { Chat, ChatSystem, ChatType, lastReadInChat } from "chat";
|
import { Chat, ChatSystem, ChatType, lastReadInChat } from "chat";
|
||||||
|
|
||||||
@ -15,7 +16,9 @@ export class Nip29ChatSystem extends ExternalStore<Array<Chat>> implements ChatS
|
|||||||
return this.listChats();
|
return this.listChats();
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription(id: string) {
|
subscription(session: LoginSession) {
|
||||||
|
const id = session.publicKey;
|
||||||
|
if (!id) return;
|
||||||
const gs = id.split("/", 2);
|
const gs = id.split("/", 2);
|
||||||
const rb = new RequestBuilder(`nip29:${id}`);
|
const rb = new RequestBuilder(`nip29:${id}`);
|
||||||
const last = this.listChats().find(a => a.id === id)?.lastMessage;
|
const last = this.listChats().find(a => a.id === id)?.lastMessage;
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
TaggedNostrEvent,
|
TaggedNostrEvent,
|
||||||
decodeTLV,
|
decodeTLV,
|
||||||
} from "@snort/system";
|
} from "@snort/system";
|
||||||
|
import { LoginSession } from "Login";
|
||||||
import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat } from "chat";
|
import { Chat, ChatSystem, ChatType, inChatWith, lastReadInChat } from "chat";
|
||||||
import { debug } from "debug";
|
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 rb = new RequestBuilder(`nip4:${pk.slice(0, 12)}`);
|
||||||
const dms = this.#cache.snapshot();
|
const dms = this.#cache.snapshot();
|
||||||
const dmSince = dms.reduce(
|
const dmSince = dms.reduce(
|
||||||
|
@ -12,6 +12,11 @@ enum EventKind {
|
|||||||
SimpleChatMessage = 9, // NIP-29
|
SimpleChatMessage = 9, // NIP-29
|
||||||
SealedRumor = 13, // NIP-59
|
SealedRumor = 13, // NIP-59
|
||||||
ChatRumor = 14, // NIP-24
|
ChatRumor = 14, // NIP-24
|
||||||
|
PublicChatChannel = 40, // NIP-28
|
||||||
|
PublicChatMetadata = 41, // NIP-28
|
||||||
|
PublicChatMessage = 42, // NIP-28
|
||||||
|
PublicChatMuteMessage = 43, // NIP-28
|
||||||
|
PublicChatMuteUser = 44, // NIP-28
|
||||||
SnortSubscriptions = 1000, // NIP-XX
|
SnortSubscriptions = 1000, // NIP-XX
|
||||||
Polls = 6969, // NIP-69
|
Polls = 6969, // NIP-69
|
||||||
GiftWrap = 1059, // NIP-59
|
GiftWrap = 1059, // NIP-59
|
||||||
|
10
yarn.lock
10
yarn.lock
@ -10949,12 +10949,12 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"prettier@npm:^3.0.0":
|
"prettier@npm:^3.0.3":
|
||||||
version: 3.0.2
|
version: 3.0.3
|
||||||
resolution: "prettier@npm:3.0.2"
|
resolution: "prettier@npm:3.0.3"
|
||||||
bin:
|
bin:
|
||||||
prettier: bin/prettier.cjs
|
prettier: bin/prettier.cjs
|
||||||
checksum: 118b59ddb6c80abe2315ab6d0f4dd1b253be5cfdb20622fa5b65bb1573dcd362e6dd3dcf2711dd3ebfe64aecf7bdc75de8a69dc2422dcd35bdde7610586b677a
|
checksum: e10b9af02b281f6c617362ebd2571b1d7fc9fb8a3bd17e371754428cda992e5e8d8b7a046e8f7d3e2da1dcd21aa001e2e3c797402ebb6111b5cd19609dd228e0
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -11726,7 +11726,7 @@ __metadata:
|
|||||||
"@cloudflare/workers-types": ^4.20230307.0
|
"@cloudflare/workers-types": ^4.20230307.0
|
||||||
"@tauri-apps/cli": ^1.2.3
|
"@tauri-apps/cli": ^1.2.3
|
||||||
eslint: ^8.48.0
|
eslint: ^8.48.0
|
||||||
prettier: ^3.0.0
|
prettier: ^3.0.3
|
||||||
typescript: ^5.2.2
|
typescript: ^5.2.2
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
Loading…
x
Reference in New Issue
Block a user