feat: UserState
continuous-integration/drone/push Build is failing Details

This commit is contained in:
kieran 2024-04-22 14:38:14 +01:00
parent 5a7657a95d
commit 80a4b5d8e6
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
103 changed files with 4179 additions and 1165 deletions

View File

@ -1,41 +0,0 @@
module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs", "react-refresh", "simple-import-sort"],
rules: {
"formatjs/enforce-id": [
"error",
{
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-unused-vars": "error",
"max-lines": ["warn", { max: 300, skipBlankLines: true, skipComments: true }],
},
overrides: [
{
files: ["*.tsx"],
rules: {
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
},
},
],
root: true,
ignorePatterns: ["build/", "*.test.ts", "*.js"],
env: {
browser: true,
worker: true,
commonjs: true,
node: false,
},
};

View File

@ -0,0 +1,44 @@
/* eslint-disable import/no-anonymous-default-export */
export default [
{
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs", "react-refresh", "simple-import-sort"],
rules: {
"formatjs/enforce-id": [
"error",
{
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "error",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-unused-vars": "error",
"max-lines": ["warn", { max: 300, skipBlankLines: true, skipComments: true }],
},
overrides: [
{
files: ["*.tsx"],
rules: {
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
},
},
],
root: true,
ignores: ["build/", "*.test.ts", "*.js"],
env: {
browser: true,
worker: true,
commonjs: true,
node: false,
},
},
];

View File

@ -106,6 +106,7 @@
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"config": "^3.3.9", "config": "^3.3.9",
"eslint": "^8.48.0", "eslint": "^8.48.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-formatjs": "^4.11.3", "eslint-plugin-formatjs": "^4.11.3",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
@ -120,6 +121,7 @@
"tinybench": "^2.5.1", "tinybench": "^2.5.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.2.8", "vite": "^5.2.8",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-pwa": "^0.19.2", "vite-plugin-pwa": "^0.19.2",
"vite-plugin-version-mark": "^0.0.10", "vite-plugin-version-mark": "^0.0.10",
"vitest": "^0.34.6" "vitest": "^0.34.6"

View File

@ -1,10 +1,10 @@
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
import { MixCloudRegex } from "@/Utils/Const"; import { MixCloudRegex } from "@/Utils/Const";
const MixCloudEmbed = ({ link }: { link: string }) => { const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2); const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const theme = useLogin(s => s.appData.json.preferences.theme); const theme = usePreferences(s => s.theme);
const lightParams = theme === "light" ? "light=1" : "light=0"; const lightParams = theme === "light" ? "light=1" : "light=0";
return ( return (
<> <>

View File

@ -8,30 +8,31 @@ import AsyncButton from "@/Components/Button/AsyncButton";
import { Toastore } from "@/Components/Toaster/Toaster"; import { Toastore } from "@/Components/Toaster/Toaster";
import FollowListBase from "@/Components/User/FollowListBase"; import FollowListBase from "@/Components/User/FollowListBase";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils"; import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils";
import { useWallet } from "@/Wallet"; import { useWallet } from "@/Wallet";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) { export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
const wallet = useWallet(); const wallet = useWallet();
const login = useLogin(); const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
const { publisher } = useEventPublisher(); const { publisher, system } = useEventPublisher();
const ids = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1])); const ids = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1]));
async function zapAll() { async function zapAll() {
for (const pk of ids) { for (const pk of ids) {
try { try {
const profile = await UserCache.get(pk); const profile = await UserCache.get(pk);
const amtSend = login.appData.json.preferences.defaultZapAmount; const amtSend = defaultZapAmount;
const lnurl = profile?.lud16 || profile?.lud06; const lnurl = profile?.lud16 || profile?.lud06;
if (lnurl) { if (lnurl) {
const svc = new LNURL(lnurl); const svc = new LNURL(lnurl);
await svc.load(); await svc.load();
const relays = await system.requestRouter?.forReplyTo(pk);
const zap = await publisher?.zap( const zap = await publisher?.zap(
amtSend * 1000, amtSend * 1000,
pk, pk,
Object.keys(login.relays.item), relays ?? [],
undefined, undefined,
`Zap from ${hexToBech32("note", ev.id)}`, `Zap from ${hexToBech32("note", ev.id)}`,
); );
@ -74,7 +75,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
defaultMessage="Zap all {n} sats" defaultMessage="Zap all {n} sats"
id="IVbtTS" id="IVbtTS"
values={{ values={{
n: <FormattedNumber value={login.appData.json.preferences.defaultZapAmount * ids.length} />, n: <FormattedNumber value={defaultZapAmount * ids.length} />,
}} }}
/> />
</AsyncButton> </AsyncButton>

View File

@ -20,6 +20,8 @@ import { Toastore } from "@/Components/Toaster/Toaster";
import ProfileImage from "@/Components/User/ProfileImage"; import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import useRelays from "@/Hooks/useRelays";
import { useNoteCreator } from "@/State/NoteCreator"; import { useNoteCreator } from "@/State/NoteCreator";
import { openFile, trackEvent } from "@/Utils"; import { openFile, trackEvent } from "@/Utils";
import useFileUpload from "@/Utils/Upload"; import useFileUpload from "@/Utils/Upload";
@ -56,11 +58,12 @@ const quoteNoteOptions = {
export function NoteCreator() { export function NoteCreator() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const uploader = useFileUpload(); const uploader = useFileUpload();
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.json.preferences.pow })); const publicKey = useLogin(s => s.publicKey);
const pow = usePreferences(s => s.pow);
const relays = useRelays();
const { system, publisher: pub } = useEventPublisher(); const { system, publisher: pub } = useEventPublisher();
const publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub; const publisher = pow ? pub?.pow(pow, GetPowWorker()) : pub;
const note = useNoteCreator(); const note = useNoteCreator();
const relays = login.relays;
useEffect(() => { useEffect(() => {
const draft = localStorage.getItem("msgDraft"); const draft = localStorage.getItem("msgDraft");
@ -367,8 +370,9 @@ export function NoteCreator() {
function renderRelayCustomisation() { function renderRelayCustomisation() {
return ( return (
<div className="flex flex-col g8"> <div className="flex flex-col g8">
{Object.keys(relays.item || {}) {Object.entries(relays)
.filter(el => relays.item[el].write) .filter(el => el[1].write)
.map(a => a[0])
.map((r, i, a) => ( .map((r, i, a) => (
<div className="p flex justify-between note-creator-relay" key={r}> <div className="p flex justify-between note-creator-relay" key={r}>
<div>{r}</div> <div>{r}</div>
@ -523,7 +527,7 @@ export function NoteCreator() {
<div className="flex justify-between"> <div className="flex justify-between">
<div className="flex items-center g8"> <div className="flex items-center g8">
<ProfileImage <ProfileImage
pubkey={login.publicKey ?? ""} pubkey={publicKey ?? ""}
className="note-creator-icon" className="note-creator-icon"
link="" link=""
showUsername={false} showUsername={false}

View File

@ -8,21 +8,16 @@ import IconButton from "@/Components/Button/IconButton";
import Icon from "@/Components/Icons/Icon"; import Icon from "@/Components/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { saveRelays } from "@/Pages/settings/saveRelays";
import { getRelayName } from "@/Utils"; import { getRelayName } from "@/Utils";
import { removeRelay } from "@/Utils/Login";
export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => void }) { export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => void }) {
const [r, setResult] = useState(rsp); const [r, setResult] = useState(rsp);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { publisher, system } = useEventPublisher(); const { system } = useEventPublisher();
const login = useLogin(); const login = useLogin();
async function removeRelayFromResult(r: OkResponse) { async function removeRelayFromResult(r: OkResponse) {
if (publisher) { await login.state.removeRelay(unwrap(sanitizeRelayUrl(r.relay)), true);
removeRelay(login, unwrap(sanitizeRelayUrl(r.relay)));
await saveRelays(system, publisher, login.relays.item);
}
close(); close();
} }

View File

@ -1,10 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
const HiddenNote = ({ children }: { children: React.ReactNode }) => { const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const hideMutedNotes = useLogin(s => s.appData.json.preferences.hideMutedNotes); const hideMutedNotes = usePreferences(s => s.hideMutedNotes);
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
if (hideMutedNotes) return; if (hideMutedNotes) return;

View File

@ -1,4 +1,4 @@
import { HexKey, NostrLink, NostrPrefix } from "@snort/system"; import { EventKind, HexKey, NostrLink, NostrPrefix } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu"; import { Menu, MenuItem } from "@szhsin/react-menu";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@ -10,7 +10,7 @@ import SnortApi from "@/External/SnortApi";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration"; import useModeration from "@/Hooks/useModeration";
import { setBookmarked, setPinned } from "@/Utils/Login"; import usePreferences from "@/Hooks/usePreferences";
import { getCurrentSubscription, SubscriptionType } from "@/Utils/Subscription"; import { getCurrentSubscription, SubscriptionType } from "@/Utils/Subscription";
import { ReBroadcaster } from "../../ReBroadcaster"; import { ReBroadcaster } from "../../ReBroadcaster";
@ -18,7 +18,8 @@ import { ReBroadcaster } from "../../ReBroadcaster";
export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) { export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const login = useLogin(); const login = useLogin();
const { mute, block } = useModeration(); const autoTranslate = usePreferences(s => s.autoTranslate);
const { mute } = useModeration();
const { publisher, system } = useEventPublisher(); const { publisher, system } = useEventPublisher();
const [showBroadcast, setShowBroadcast] = useState(false); const [showBroadcast, setShowBroadcast] = useState(false);
const lang = window.navigator.language; const lang = window.navigator.language;
@ -26,6 +27,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
type: "language", type: "language",
}); });
const isMine = ev.pubkey === login.publicKey; const isMine = ev.pubkey === login.publicKey;
const link = NostrLink.fromEvent(ev);
async function deleteEvent() { async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) { if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
@ -78,7 +80,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
useEffect(() => { useEffect(() => {
const sub = getCurrentSubscription(login.subscriptions); const sub = getCurrentSubscription(login.subscriptions);
if (sub?.type === SubscriptionType.Premium && (login.appData.json.preferences.autoTranslate ?? true)) { if (sub?.type === SubscriptionType.Premium && (autoTranslate ?? true)) {
translate(); translate();
} }
}, []); }, []);
@ -90,19 +92,13 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
async function pin(id: HexKey) { async function pin(id: HexKey) {
if (publisher) { if (publisher) {
const es = [...login.pinned.item, id]; //todo: PIN note
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
} }
} }
async function bookmark(id: string) { async function bookmark(id: string) {
if (publisher) { if (publisher) {
const es = [...login.bookmarked.item, id]; //todo: bookmark note
const ev = await publisher.bookmarks(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
} }
} }
@ -132,13 +128,13 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
<Icon name="share" /> <Icon name="share" />
<FormattedMessage {...messages.Share} /> <FormattedMessage {...messages.Share} />
</MenuItem> </MenuItem>
{!login.pinned.item.includes(ev.id) && !login.readonly && ( {!login.state.isOnList(EventKind.PinList, link) && !login.readonly && (
<MenuItem onClick={() => pin(ev.id)}> <MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" /> <Icon name="pin" />
<FormattedMessage {...messages.Pin} /> <FormattedMessage {...messages.Pin} />
</MenuItem> </MenuItem>
)} )}
{!login.bookmarked.item.includes(ev.id) && !login.readonly && ( {!login.state.isOnList(EventKind.BookmarksList, link) && !login.readonly && (
<MenuItem onClick={() => bookmark(ev.id)}> <MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" /> <Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} /> <FormattedMessage {...messages.Bookmark} />
@ -158,12 +154,6 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
<Icon name="relay" /> <Icon name="relay" />
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" /> <FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
</MenuItem> </MenuItem>
{ev.pubkey !== login.publicKey && !login.readonly && (
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
)}
<MenuItem onClick={() => translate()}> <MenuItem onClick={() => translate()}>
<Icon name="translate" /> <Icon name="translate" />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} /> <FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />

View File

@ -11,6 +11,7 @@ import { ZapsSummary } from "@/Components/Event/ZapsSummary";
import ZapModal from "@/Components/ZapModal/ZapModal"; import ZapModal from "@/Components/ZapModal/ZapModal";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { getDisplayName } from "@/Utils"; import { getDisplayName } from "@/Utils";
import { Zapper, ZapTarget } from "@/Utils/Zapper"; import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController"; import { ZapPoolController } from "@/Utils/ZapPoolController";
@ -23,15 +24,11 @@ export interface ZapIconProps {
} }
export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => { export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
const { const { publicKey, readonly } = useLogin(s => ({
publicKey,
readonly,
preferences: prefs,
} = useLogin(s => ({
publicKey: s.publicKey, publicKey: s.publicKey,
readonly: s.readonly, readonly: s.readonly,
preferences: s.appData.json.preferences,
})); }));
const preferences = usePreferences(s => ({ autoZap: s.autoZap, defaultZapAmount: s.defaultZapAmount }));
const walletState = useWallet(); const walletState = useWallet();
const wallet = walletState.wallet; const wallet = walletState.wallet;
const link = NostrLink.fromEvent(ev); const link = NostrLink.fromEvent(ev);
@ -75,7 +72,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
if (canFastZap && lnurl) { if (canFastZap && lnurl) {
setZapping(true); setZapping(true);
try { try {
await fastZapInner(lnurl, prefs.defaultZapAmount); await fastZapInner(lnurl, preferences.defaultZapAmount);
} catch (e) { } catch (e) {
console.warn("Fast zap failed", e); console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") { if (!(e instanceof Error) || e.message !== "User rejected") {
@ -110,13 +107,13 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
const targets = getZapTarget(); const targets = getZapTarget();
useEffect(() => { useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) { if (preferences.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getZapTarget(); const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) { if (wallet?.isReady() && lnurl) {
setZapping(true); setZapping(true);
queueMicrotask(async () => { queueMicrotask(async () => {
try { try {
await fastZapInner(lnurl, prefs.defaultZapAmount); await fastZapInner(lnurl, preferences.defaultZapAmount);
} catch { } catch {
// ignored // ignored
} finally { } finally {
@ -125,7 +122,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
}); });
} }
} }
}, [prefs.autoZap, author, zapping]); }, [preferences.autoZap, author, zapping]);
return ( return (
<> <>

View File

@ -9,6 +9,7 @@ import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton"; import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal"; import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
export interface NoteFooterProps { export interface NoteFooterProps {
replyCount?: number; replyCount?: number;
@ -25,17 +26,14 @@ export default function NoteFooter(props: NoteFooterProps) {
const { replies, reactions, zaps, reposts } = useEventReactions(link, related); const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions; const { positive } = reactions;
const { preferences: prefs, readonly } = useLogin(s => ({ const readonly = useLogin(s => s.readonly);
preferences: s.appData.json.preferences, const enableReactions = usePreferences(s => s.enableReactions);
publicKey: s.publicKey,
readonly: s.readonly,
}));
return ( return (
<div className="flex flex-row gap-4 overflow-hidden max-w-full h-6 items-center"> <div className="flex flex-row gap-4 overflow-hidden max-w-full h-6 items-center">
<ReplyButton ev={ev} replyCount={props.replyCount ?? replies.length} readonly={readonly} /> <ReplyButton ev={ev} replyCount={props.replyCount ?? replies.length} readonly={readonly} />
<RepostButton ev={ev} reposts={reposts} /> <RepostButton ev={ev} reposts={reposts} />
{prefs.enableReactions && <LikeButton ev={ev} positiveReactions={positive} />} {enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
{CONFIG.showPowIcon && <PowIcon ev={ev} />} {CONFIG.showPowIcon && <PowIcon ev={ev} />}
<FooterZapButton ev={ev} zaps={zaps} onClickZappers={() => setShowReactions(true)} /> <FooterZapButton ev={ev} zaps={zaps} onClickZappers={() => setShowReactions(true)} />
{showReactions && <ReactionsModal initialTab={1} onClose={() => setShowReactions(false)} event={ev} />} {showReactions && <ReactionsModal initialTab={1} onClose={() => setShowReactions(false)} event={ev} />}

View File

@ -9,16 +9,15 @@ import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages"; import messages from "@/Components/messages";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { useNoteCreator } from "@/State/NoteCreator"; import { useNoteCreator } from "@/State/NoteCreator";
export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: TaggedNostrEvent[] }) => { export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: TaggedNostrEvent[] }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const navigate = useNavigate(); const navigate = useNavigate();
const { publisher, system } = useEventPublisher(); const { publisher, system } = useEventPublisher();
const { publicKey, preferences: prefs } = useLogin(s => ({ const publicKey = useLogin(s => s.publicKey);
preferences: s.appData.json.preferences, const confirmReposts = usePreferences(s => s.confirmReposts);
publicKey: s.publicKey,
}));
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote })); const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const hasReposted = () => { const hasReposted = () => {
@ -27,7 +26,7 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
const repost = async () => { const repost = async () => {
if (!hasReposted() && publisher) { if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) { if (!confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev); const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost); system.BroadcastEvent(evRepost);
} }

View File

@ -1,4 +1,4 @@
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system"; import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@ -13,7 +13,6 @@ import messages from "@/Components/messages";
import ProfileImage from "@/Components/User/ProfileImage"; import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { setBookmarked, setPinned } from "@/Utils/Login";
export default function NoteHeader(props: { export default function NoteHeader(props: {
ev: TaggedNostrEvent; ev: TaggedNostrEvent;
@ -24,28 +23,21 @@ export default function NoteHeader(props: {
const [showReactions, setShowReactions] = useState(false); const [showReactions, setShowReactions] = useState(false);
const { ev, options, setTranslated } = props; const { ev, options, setTranslated } = props;
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { pinned, bookmarked } = useLogin(); const { publisher } = useEventPublisher();
const { publisher, system } = useEventPublisher();
const login = useLogin(); const login = useLogin();
async function unpin(id: HexKey) { async function unpin() {
if (options.canUnpin && publisher) { if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) { if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.item.filter(e => e !== id); await login.state.removeFromList(EventKind.PinList, NostrLink.fromEvent(ev));
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
} }
} }
} }
async function unbookmark(id: HexKey) { async function unbookmark() {
if (options.canUnbookmark && publisher) { if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) { if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.item.filter(e => e !== id); await login.state.removeFromList(EventKind.BookmarksList, NostrLink.fromEvent(ev));
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
} }
} }
} }
@ -66,7 +58,7 @@ export default function NoteHeader(props: {
{(options.showTime || options.showBookmarked) && ( {(options.showTime || options.showBookmarked) && (
<> <>
{options.showBookmarked && ( {options.showBookmarked && (
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.id)}> <div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark()}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} /> <Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div> </div>
)} )}
@ -74,7 +66,7 @@ export default function NoteHeader(props: {
</> </>
)} )}
{options.showPinned && ( {options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}> <div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin()}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} /> <Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div> </div>
)} )}

View File

@ -6,14 +6,14 @@ import { NoteProps } from "@/Components/Event/EventComponent";
import { NoteTranslation } from "@/Components/Event/Note/types"; import { NoteTranslation } from "@/Components/Event/Note/types";
import Reveal from "@/Components/Event/Reveal"; import Reveal from "@/Components/Event/Reveal";
import Text from "@/Components/Text/Text"; import Text from "@/Components/Text/Text";
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
const TEXT_TRUNCATE_LENGTH = 400; const TEXT_TRUNCATE_LENGTH = 400;
export const NoteText = memo(function InnerContent( export const NoteText = memo(function InnerContent(
props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean }, props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean },
) { ) {
const { data: ev, options, translated, showTranslation } = props; const { data: ev, options, translated, showTranslation } = props;
const appData = useLogin(s => s.appData); const showContentWarningPosts = usePreferences(s => s.showContentWarningPosts);
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const body = translated && !translated.skipped && showTranslation ? translated.text : ev?.content ?? ""; const body = translated && !translated.skipped && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && !translated.skipped && showTranslation ? `${ev.id}-translated` : ev.id; const id = translated && !translated.skipped && showTranslation ? `${ev.id}-translated` : ev.id;
@ -53,7 +53,7 @@ export const NoteText = memo(function InnerContent(
</> </>
); );
if (!appData.json.showContentWarningPosts) { if (!showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning"); const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) { if (contentWarning) {
return ( return (

View File

@ -8,6 +8,7 @@ import Spinner from "@/Components/Icons/Spinner";
import ZapModal from "@/Components/ZapModal/ZapModal"; import ZapModal from "@/Components/ZapModal/ZapModal";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { unwrap } from "@/Utils"; import { unwrap } from "@/Utils";
import { formatShort } from "@/Utils/Number"; import { formatShort } from "@/Utils/Number";
import { useWallet } from "@/Wallet"; import { useWallet } from "@/Wallet";
@ -21,13 +22,10 @@ type PollTally = "zaps" | "pubkeys";
export default function Poll(props: PollProps) { export default function Poll(props: PollProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { publisher } = useEventPublisher(); const { publisher, system } = useEventPublisher();
const { wallet } = useWallet(); const { wallet } = useWallet();
const { const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
preferences: prefs, const myPubKey = useLogin(s => s.publicKey);
publicKey: myPubKey,
relays,
} = useLogin(s => ({ preferences: s.appData.json.preferences, publicKey: s.publicKey, relays: s.relays }));
const pollerProfile = useUserProfile(props.ev.pubkey); const pollerProfile = useUserProfile(props.ev.pubkey);
const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys"); const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys");
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -45,7 +43,7 @@ export default function Poll(props: PollProps) {
ev.stopPropagation(); ev.stopPropagation();
if (voting || !publisher) return; if (voting || !publisher) return;
const amount = prefs.defaultZapAmount; const amount = defaultZapAmount;
try { try {
if (amount <= 0) { if (amount <= 0) {
throw new Error( throw new Error(
@ -62,9 +60,14 @@ export default function Poll(props: PollProps) {
} }
setVoting(opt); setVoting(opt);
const r = Object.keys(relays.item); const r = await system.requestRouter?.forReplyTo(props.ev.pubkey);
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, NostrLink.fromEvent(props.ev), undefined, eb => const zap = await publisher.zap(
eb.tag(["poll_option", opt.toString()]), amount * 1000,
props.ev.pubkey,
r ?? [],
NostrLink.fromEvent(props.ev),
undefined,
eb => eb.tag(["poll_option", opt.toString()]),
); );
const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06; const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06;
@ -121,7 +124,7 @@ export default function Poll(props: PollProps) {
defaultMessage="You are voting with {amount} sats" defaultMessage="You are voting with {amount} sats"
id="3qnJlS" id="3qnJlS"
values={{ values={{
amount: formatShort(prefs.defaultZapAmount), amount: formatShort(defaultZapAmount),
}} }}
/> />
</small> </small>

View File

@ -6,6 +6,7 @@ import { MediaElement } from "@/Components/Embed/MediaElement";
import Reveal from "@/Components/Event/Reveal"; import Reveal from "@/Components/Event/Reveal";
import useFollowsControls from "@/Hooks/useFollowControls"; import useFollowsControls from "@/Hooks/useFollowControls";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { FileExtensionRegex } from "@/Utils/Const"; import { FileExtensionRegex } from "@/Utils/Const";
interface RevealMediaProps { interface RevealMediaProps {
@ -17,15 +18,13 @@ interface RevealMediaProps {
} }
export default function RevealMedia(props: RevealMediaProps) { export default function RevealMedia(props: RevealMediaProps) {
const { preferences, publicKey } = useLogin(s => ({ const publicKey = useLogin(s => s.publicKey);
preferences: s.appData.json.preferences, const autoLoadMedia = usePreferences(s => s.autoLoadMedia);
publicKey: s.publicKey,
}));
const { isFollowing } = useFollowsControls(); const { isFollowing } = useFollowsControls();
const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !isFollowing(props.creator); const hideNonFollows = autoLoadMedia === "follows-only" && !isFollowing(props.creator);
const isMine = props.creator === publicKey; const isMine = props.creator === publicKey;
const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows); const hideMedia = autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hostname = new URL(props.link).hostname; const hostname = new URL(props.link).hostname;
const url = new URL(props.link); const url = new URL(props.link);

View File

@ -123,8 +123,6 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre> <pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
<h1>Data</h1> <h1>Data</h1>
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre> <pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
<h1>Reactions</h1>
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
</div> </div>
)} )}
{parent && ( {parent && (

View File

@ -3,9 +3,8 @@ import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon"; import Icon from "@/Components/Icons/Icon";
import { RootTabRoutePath } from "@/Pages/Root/RootTabRoutes"; import { RootTabRoutePath } from "@/Pages/Root/RootTabRoutes";
import { Newest } from "@/Utils/Login";
export function rootTabItems(base: string, pubKey: string | undefined, tags: Newest<Array<string>>) { export function rootTabItems(base: string, pubKey: string | undefined, tags: Array<string>) {
const menuItems = [ const menuItems = [
{ {
tab: "for-you", tab: "for-you",
@ -98,7 +97,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: New
{ {
tab: "tags", tab: "tags",
path: `${base}/topics`, path: `${base}/topics`,
show: tags.item.length > 0, show: tags.length > 0,
element: ( element: (
<> <>
<Icon name="hash" /> <Icon name="hash" />

View File

@ -7,26 +7,26 @@ import { useLocation, useNavigate } from "react-router-dom";
import { rootTabItems } from "@/Components/Feed/RootTabItems"; import { rootTabItems } from "@/Components/Feed/RootTabItems";
import Icon from "@/Components/Icons/Icon"; import Icon from "@/Components/Icons/Icon";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { RootTabRoutePath } from "@/Pages/Root/RootTabRoutes"; import { RootTabRoutePath } from "@/Pages/Root/RootTabRoutes";
import { EventKind } from "@snort/system";
import { unwrap } from "@snort/shared";
export function RootTabs({ base = "/" }: { base: string }) { export function RootTabs({ base = "/" }: { base: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { const { publicKey: pubKey, tags } = useLogin(s => ({
publicKey: pubKey,
tags,
preferences,
} = useLogin(s => ({
publicKey: s.publicKey, publicKey: s.publicKey,
tags: s.tags, tags: s.state.getList(EventKind.InterestSet),
preferences: s.appData.json.preferences,
})); }));
const defaultRootTab = usePreferences(s => s.defaultRootTab);
const menuItems = useMemo(() => rootTabItems(base, pubKey, tags), [base, pubKey, tags]); const hashTags = tags.filter(a => a.toEventTag()?.[0] === "t").map(a => unwrap(a.toEventTag())[1]);
const menuItems = useMemo(() => rootTabItems(base, pubKey, hashTags), [base, pubKey, tags]);
let defaultTab: RootTabRoutePath; let defaultTab: RootTabRoutePath;
if (pubKey) { if (pubKey) {
defaultTab = preferences.defaultRootTab; defaultTab = defaultRootTab;
} else { } else {
defaultTab = `trending/notes`; defaultTab = `trending/notes`;
} }

View File

@ -26,7 +26,11 @@ export interface TimelineFollowsProps {
* A list of notes by "subject" * A list of notes by "subject"
*/ */
const TimelineFollows = (props: TimelineFollowsProps) => { const TimelineFollows = (props: TimelineFollowsProps) => {
const login = useLogin(); const login = useLogin(s => ({
publicKey: s.publicKey,
feedDisplayAs: s.feedDisplayAs,
tags: s.state.getList(EventKind.InterestSet),
}));
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list"; const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial); const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt"); const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
@ -38,12 +42,12 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
items: followList, items: followList,
discriminator: login.publicKey?.slice(0, 12), discriminator: login.publicKey?.slice(0, 12),
extra: rb => { extra: rb => {
if (login.tags.item.length > 0) { if (login.tags.length > 0) {
rb.withFilter().kinds([EventKind.TextNote, EventKind.Repost]).tag("t", login.tags.item); rb.withFilter().kinds([EventKind.TextNote, EventKind.Repost]).tags(login.tags);
} }
}, },
}) as TimelineSubject, }) as TimelineSubject,
[followList, login.tags.item], [login.publicKey, followList, login.tags],
); );
const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions); const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);

View File

@ -16,12 +16,12 @@ export default function UsersFeed({ keyword, sortPopular = true }: { keyword: st
{ method: "LIMIT_UNTIL" }, { method: "LIMIT_UNTIL" },
); );
const { muted, isEventMuted } = useModeration(); const { isEventMuted } = useModeration();
const filterPosts = useCallback( const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => { (nts: readonly TaggedNostrEvent[]) => {
return nts.filter(a => !isEventMuted(a)); return nts.filter(a => !isEventMuted(a));
}, },
[muted], [isEventMuted],
); );
const usersFeed = useMemo(() => filterPosts(feed.main ?? []).map(p => p.pubkey), [feed, filterPosts]); const usersFeed = useMemo(() => filterPosts(feed.main ?? []).map(p => p.pubkey), [feed, filterPosts]);

View File

@ -2,10 +2,10 @@ import { useSyncExternalStore } from "react";
import { getLocale } from "@/Components/IntlProvider/IntlProviderUtils"; import { getLocale } from "@/Components/IntlProvider/IntlProviderUtils";
import { LangOverride } from "@/Components/IntlProvider/langStore"; import { LangOverride } from "@/Components/IntlProvider/langStore";
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
export function useLocale() { export function useLocale() {
const { language } = useLogin(s => ({ language: s.appData.json.preferences.language })); const language = usePreferences(s => s.language);
const loggedOutLang = useSyncExternalStore( const loggedOutLang = useSyncExternalStore(
c => LangOverride.hook(c), c => LangOverride.hook(c),
() => LangOverride.snapshot(), () => LangOverride.snapshot(),

View File

@ -7,6 +7,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { createPublisher, LoginStore, sessionNeedsPin } from "@/Utils/Login"; import { createPublisher, LoginStore, sessionNeedsPin } from "@/Utils/Login";
import { GetPowWorker } from "@/Utils/wasm"; import { GetPowWorker } from "@/Utils/wasm";
@ -97,6 +98,7 @@ export function PinPrompt({
export function LoginUnlock() { export function LoginUnlock() {
const login = useLogin(); const login = useLogin();
const pow = usePreferences(s => s.pow);
const { publisher } = useEventPublisher(); const { publisher } = useEventPublisher();
async function encryptMigration(pin: string) { async function encryptMigration(pin: string) {
@ -104,8 +106,8 @@ export function LoginUnlock() {
const newPin = await PinEncrypted.create(k, pin); const newPin = await PinEncrypted.create(k, pin);
const pub = EventPublisher.privateKey(k); const pub = EventPublisher.privateKey(k);
if (login.appData.json.preferences.pow) { if (pow) {
pub.pow(login.appData.json.preferences.pow, GetPowWorker()); pub.pow(pow, GetPowWorker());
} }
LoginStore.setPublisher(login.id, pub); LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({ LoginStore.updateSession({
@ -121,8 +123,8 @@ export function LoginUnlock() {
await key.unlock(pin); await key.unlock(pin);
const pub = createPublisher(login); const pub = createPublisher(login);
if (pub) { if (pub) {
if (login.appData.json.preferences.pow) { if (pow) {
pub.pow(login.appData.json.preferences.pow, GetPowWorker()); pub.pow(pow, GetPowWorker());
} }
LoginStore.setPublisher(login.id, pub); LoginStore.setPublisher(login.id, pub);
LoginStore.updateSession({ LoginStore.updateSession({

View File

@ -4,7 +4,7 @@ import { useContext, useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import Modal from "@/Components/Modal/Modal"; import Modal from "@/Components/Modal/Modal";
import useLogin from "@/Hooks/useLogin"; import useRelays from "@/Hooks/useRelays";
import AsyncButton from "./Button/AsyncButton"; import AsyncButton from "./Button/AsyncButton";
import messages from "./messages"; import messages from "./messages";
@ -12,7 +12,7 @@ import messages from "./messages";
export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) { export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) {
const [selected, setSelected] = useState<Array<string>>(); const [selected, setSelected] = useState<Array<string>>();
const system = useContext(SnortContext); const system = useContext(SnortContext);
const { relays } = useLogin(s => ({ relays: s.relays })); const relays = useRelays();
async function sendReBroadcast() { async function sendReBroadcast() {
if (selected) { if (selected) {
@ -25,8 +25,8 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: Tagged
function renderRelayCustomisation() { function renderRelayCustomisation() {
return ( return (
<div className="flex flex-col g8"> <div className="flex flex-col g8">
{Object.keys(relays.item || {}) {Object.keys(relays)
.filter(el => relays.item[el].write) .filter(el => relays[el].write)
.map((r, i, a) => ( .map((r, i, a) => (
<div key={r} className="card flex justify-between"> <div key={r} className="card flex justify-between">
<div>{r}</div> <div>{r}</div>
@ -36,7 +36,7 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: Tagged
checked={!selected || selected.includes(r)} checked={!selected || selected.includes(r)}
onChange={e => onChange={e =>
setSelected( setSelected(
e.target.checked && selected && selected.length == a.length - 1 e.target.checked && selected && selected.length === a.length - 1
? undefined ? undefined
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))), : a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
) )

View File

@ -1,17 +1,14 @@
import "./Relay.css"; import "./Relay.css";
import { unixNowMs } from "@snort/shared";
import { RelaySettings } from "@snort/system"; import { RelaySettings } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import classNames from "classnames"; import classNames from "classnames";
import { useContext, useMemo } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AsyncIcon } from "@/Components/Button/AsyncIcon"; import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import useRelayState from "@/Feed/RelayState"; import useRelayState from "@/Feed/RelayState";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { getRelayName, unwrap } from "@/Utils"; import { getRelayName } from "@/Utils";
import { removeRelay, setRelays } from "@/Utils/Login";
import { RelayFavicon } from "./RelaysMetadata"; import { RelayFavicon } from "./RelaysMetadata";
@ -21,35 +18,29 @@ export interface RelayProps {
export default function Relay(props: RelayProps) { export default function Relay(props: RelayProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const system = useContext(SnortContext); const state = useLogin(s => s.state);
const login = useLogin();
const relaySettings = unwrap(login.relays.item[props.addr] ?? system.pool.getConnection(props.addr)?.Settings ?? {});
const state = useRelayState(props.addr);
const name = useMemo(() => getRelayName(props.addr), [props.addr]); const name = useMemo(() => getRelayName(props.addr), [props.addr]);
const connection = useRelayState(props.addr);
function configure(o: RelaySettings) { const relaySettings = state.relays?.find(a => a.url === props.addr)?.settings;
setRelays( if (!relaySettings || !connection) return;
login,
{ async function configure(o: RelaySettings) {
...login.relays.item, await state.updateRelay(props.addr, o);
[props.addr]: o,
},
unixNowMs(),
);
} }
return ( return (
<> <>
<div className="relay bg-dark"> <div className="relay bg-dark">
<div className={classNames("flex items-center", state?.IsClosed === false ? "bg-success" : "bg-error")}> <div className={classNames("flex items-center", connection.isOpen ? "bg-success" : "bg-error")}>
<RelayFavicon url={props.addr} /> <RelayFavicon url={props.addr} />
</div> </div>
<div className="flex flex-col g8"> <div className="flex flex-col g8">
<div> <div>
<b>{name}</b> <b>{name}</b>
</div> </div>
{!state?.Ephemeral && ( {!connection?.Ephemeral && (
<div className="flex g8"> <div className="flex g8">
<AsyncIcon <AsyncIcon
iconName="write" iconName="write"
@ -77,13 +68,13 @@ export default function Relay(props: RelayProps) {
iconName="trash" iconName="trash"
iconSize={16} iconSize={16}
className="button-icon-sm transparent trash-icon" className="button-icon-sm transparent trash-icon"
onClick={() => removeRelay(login, props.addr)} onClick={() => state.removeRelay(props.addr)}
/> />
<AsyncIcon <AsyncIcon
iconName="gear" iconName="gear"
iconSize={16} iconSize={16}
className="button-icon-sm transparent" className="button-icon-sm transparent"
onClick={() => navigate(state?.Id ?? "")} onClick={() => navigate(connection?.Id ?? "")}
/> />
</div> </div>
)} )}

View File

@ -17,15 +17,15 @@ enum Provider {
} }
export default function SuggestedProfiles() { export default function SuggestedProfiles() {
const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.contacts })); const publicKey = useLogin(s => s.publicKey);
const [provider, setProvider] = useState(Provider.NostrBand); const [provider, setProvider] = useState(Provider.NostrBand);
const getUrlAndKey = () => { const getUrlAndKey = () => {
if (!login.publicKey) return { url: null, key: null }; if (!publicKey) return { url: null, key: null };
switch (provider) { switch (provider) {
case Provider.NostrBand: { case Provider.NostrBand: {
const api = new NostrBandApi(); const api = new NostrBandApi();
const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey)); const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, publicKey));
return { url, key: `nostr-band-${url}` }; return { url, key: `nostr-band-${url}` };
} }
default: default:

View File

@ -33,7 +33,7 @@ export default function TrendingNotes({ count = Infinity, small = false }: { cou
const ev = a.event; const ev = a.event;
if (!System.optimizer.schnorrVerify(ev)) { if (!System.optimizer.schnorrVerify(ev)) {
console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`); console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`);
return; return undefined;
} }
System.HandleEvent("*", ev as TaggedNostrEvent); System.HandleEvent("*", ev as TaggedNostrEvent);
return ev; return ev;

View File

@ -1,25 +0,0 @@
import { HexKey } from "@snort/system";
import { FormattedMessage } from "react-intl";
import useModeration from "@/Hooks/useModeration";
import messages from "../messages";
interface BlockButtonProps {
pubkey: HexKey;
}
const BlockButton = ({ pubkey }: BlockButtonProps) => {
const { block, unblock, isBlocked } = useModeration();
return isBlocked(pubkey) ? (
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
<FormattedMessage {...messages.Unblock} />
</button>
) : (
<button className="secondary" type="button" onClick={() => block(pubkey)}>
<FormattedMessage {...messages.Block} />
</button>
);
};
export default BlockButton;

View File

@ -1,15 +0,0 @@
import BlockButton from "@/Components/User/BlockButton";
import ProfilePreview from "@/Components/User/ProfilePreview";
import useModeration from "@/Hooks/useModeration";
export default function BlockList() {
const { blocked } = useModeration();
return (
<div className="main-content p">
{blocked.map(a => {
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
})}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { HexKey } from "@snort/system"; import { HexKey, NostrPrefix } from "@snort/system";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import MuteButton from "@/Components/User/MuteButton"; import MuteButton from "@/Components/User/MuteButton";
@ -11,26 +11,31 @@ export interface MutedListProps {
pubkeys: HexKey[]; pubkeys: HexKey[];
} }
export default function MutedList({ pubkeys }: MutedListProps) { export default function MutedList() {
const { isMuted, muteAll } = useModeration(); const { muteList } = useModeration();
const hasAllMuted = pubkeys.every(isMuted);
return ( return (
<div className="p"> <div className="p">
<div className="flex justify-between"> <div className="flex justify-between">
<div className="bold"> <div className="bold">
<FormattedMessage {...messages.MuteCount} values={{ n: pubkeys?.length }} /> <FormattedMessage {...messages.MuteCount} values={{ n: muteList?.length }} />
</div> </div>
<button
disabled={hasAllMuted || pubkeys.length === 0}
className="transparent"
type="button"
onClick={() => muteAll(pubkeys)}>
<FormattedMessage {...messages.MuteAll} />
</button>
</div> </div>
{pubkeys?.map(a => { {muteList?.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />; switch (a.type) {
case NostrPrefix.Profile:
case NostrPrefix.PublicKey: {
return (
<ProfilePreview
actions={<MuteButton pubkey={a.id} />}
pubkey={a.id}
options={{ about: false }}
key={a.id}
/>
);
}
}
return undefined;
})} })}
</div> </div>
); );

View File

@ -7,6 +7,7 @@ import messages from "@/Components/messages";
import { ZapType } from "@/Components/ZapModal/ZapType"; import { ZapType } from "@/Components/ZapModal/ZapType";
import { ZapTypeSelector } from "@/Components/ZapModal/ZapTypeSelector"; import { ZapTypeSelector } from "@/Components/ZapModal/ZapTypeSelector";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { formatShort } from "@/Utils/Number"; import { formatShort } from "@/Utils/Number";
import { Zapper } from "@/Utils/Zapper"; import { Zapper } from "@/Utils/Zapper";
@ -21,10 +22,8 @@ export function ZapModalInput(props: {
onChange?: (v: SendSatsInputSelection) => void; onChange?: (v: SendSatsInputSelection) => void;
onNextStage: (v: SendSatsInputSelection) => Promise<void>; onNextStage: (v: SendSatsInputSelection) => Promise<void>;
}) { }) {
const { defaultZapAmount, readonly } = useLogin(s => ({ const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
defaultZapAmount: s.appData.json.preferences.defaultZapAmount, const readonly = useLogin(s => s.readonly);
readonly: s.readonly,
}));
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const amounts: Record<string, string> = { const amounts: Record<string, string> = {
[defaultZapAmount.toString()]: "", [defaultZapAmount.toString()]: "",

View File

@ -5,15 +5,15 @@ import { useMemo } from "react";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
export function useArticles() { export function useArticles() {
const { publicKey, follows } = useLogin(); const { publicKey, follows } = useLogin(s => ({ publicKey: s.publicKey, follows: s.state.follows }));
const sub = useMemo(() => { const sub = useMemo(() => {
if (!publicKey) return null; if (!publicKey) return null;
const rb = new RequestBuilder(`articles:${publicKey}`); const rb = new RequestBuilder(`articles:${publicKey}`);
rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(follows.item).limit(20); rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(follows).limit(20);
return rb; return rb;
}, [follows.timestamp]); }, [follows, publicKey]);
return useRequestBuilder(sub); return useRequestBuilder(sub);
} }

View File

@ -5,7 +5,7 @@ import { useMemo } from "react";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
export default function useFollowsFeed(pubkey?: HexKey) { export default function useFollowsFeed(pubkey?: HexKey) {
const { publicKey, follows } = useLogin(); const { publicKey, follows } = useLogin(s => ({ publicKey: s.publicKey, follows: s.state.follows }));
const isMe = publicKey === pubkey; const isMe = publicKey === pubkey;
const sub = useMemo(() => { const sub = useMemo(() => {
@ -18,11 +18,11 @@ export default function useFollowsFeed(pubkey?: HexKey) {
const contactFeed = useRequestBuilder(sub); const contactFeed = useRequestBuilder(sub);
return useMemo(() => { return useMemo(() => {
if (isMe) { if (isMe) {
return follows.item; return follows;
} }
return getFollowing(contactFeed ?? [], pubkey); return getFollowing(contactFeed ?? [], pubkey);
}, [contactFeed, follows, pubkey]); }, [isMe, contactFeed, follows, pubkey]);
} }
export function getFollowing(notes: readonly TaggedNostrEvent[], pubkey?: HexKey) { export function getFollowing(notes: readonly TaggedNostrEvent[], pubkey?: HexKey) {

View File

@ -1,42 +1,33 @@
import { EventKind, NostrLink, parseRelayTags, RequestBuilder, TaggedNostrEvent } from "@snort/system"; import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Nip28ChatSystem } from "@/chat/nip28";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "@/Utils"; import usePreferences from "@/Hooks/usePreferences";
import { bech32ToHex, unwrap } from "@/Utils";
import { SnortPubKey } from "@/Utils/Const"; import { SnortPubKey } from "@/Utils/Const";
import { import { addSubscription } from "@/Utils/Login";
addSubscription,
LoginStore,
setBlocked,
setBookmarked,
setMuted,
setPinned,
setRelays,
setTags,
updateSession,
} from "@/Utils/Login";
import { SubscriptionEvent } from "@/Utils/Subscription"; import { SubscriptionEvent } from "@/Utils/Subscription";
/** /**
* Managed loading data for the current logged in user * Managed loading data for the current logged in user
*/ */
export default function useLoginFeed() { export default function useLoginFeed() {
const login = useLogin(); const login = useLogin();
const { publicKey: pubKey, contacts } = login; const { publicKey: pubKey } = login;
const checkSigs = usePreferences(s => s.checkSigs);
const { publisher, system } = useEventPublisher(); const { publisher, system } = useEventPublisher();
useEffect(() => { useEffect(() => {
if (login.appData.json) { system.checkSigs = checkSigs;
system.checkSigs = login.appData.json.preferences.checkSigs; }, [system, checkSigs]);
if (publisher) { useEffect(() => {
login.appData.sync(publisher.signer, system); if (publisher) {
} login.state.init(publisher.signer, system).catch(console.error);
} }
}, [login, publisher]); }, [login, publisher, system]);
const subLogin = useMemo(() => { const subLogin = useMemo(() => {
if (!login || !pubKey) return null; if (!login || !pubKey) return null;
@ -44,18 +35,7 @@ export default function useLoginFeed() {
b.withOptions({ b.withOptions({
leaveOpen: true, leaveOpen: true,
}); });
b.withFilter() b.withFilter().authors([pubKey]).kinds([EventKind.DirectMessage]);
.authors([pubKey])
.kinds([
EventKind.ContactList,
EventKind.Relays,
EventKind.MuteList,
EventKind.PinList,
EventKind.BookmarksList,
EventKind.InterestsList,
EventKind.PublicChatsList,
EventKind.DirectMessage,
]);
if (CONFIG.features.subscriptions && !login.readonly) { if (CONFIG.features.subscriptions && !login.readonly) {
b.withFilter() b.withFilter()
.relay("wss://relay.snort.social/") .relay("wss://relay.snort.social/")
@ -66,121 +46,32 @@ export default function useLoginFeed() {
} }
return b; return b;
}, [login]); }, [pubKey, login]);
const loginFeed = useRequestBuilder(subLogin); const loginFeed = useRequestBuilder(subLogin);
// update relays and follow lists // update relays and follow lists
useEffect(() => { useEffect(() => {
if (loginFeed) { if (loginFeed && publisher) {
const contactList = getNewest(loginFeed.filter(a => a.kind === EventKind.ContactList)); const subs = loginFeed.filter(
if (contactList) { a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey),
updateSession(login.id, s => { );
s.contacts = contactList.tags; Promise.all(
}); subs.map(async a => {
} const dx = await publisher.decryptDm(a);
if (dx) {
const relays = getNewest(loginFeed.filter(a => a.kind === EventKind.Relays)); const ex = JSON.parse(dx);
if (relays) { return {
const parsedRelays = parseRelayTags(relays.tags.filter(a => a[0] === "r")).map(a => [a.url, a.settings]); id: a.id,
setRelays(login, Object.fromEntries(parsedRelays), relays.created_at * 1000); ...ex,
} } as SubscriptionEvent;
}
if (publisher) { }),
const subs = loginFeed.filter( ).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey),
);
Promise.all(
subs.map(async a => {
const dx = await publisher.decryptDm(a);
if (dx) {
const ex = JSON.parse(dx);
return {
id: a.id,
...ex,
} as SubscriptionEvent;
}
}),
).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
}
} }
}, [loginFeed, publisher]); }, [login, loginFeed, publisher]);
async function handleMutedFeed(mutedFeed: TaggedNostrEvent[]) {
const latest = getNewest(mutedFeed);
if (!latest) return;
const muted = NostrLink.fromTags(latest.tags);
setMuted(
login,
muted.map(a => a.id),
latest.created_at * 1000,
);
if (latest?.content && publisher && pubKey) {
try {
const privMutes = await publisher.nip4Decrypt(latest.content, pubKey);
const blocked = JSON.parse(privMutes) as Array<Array<string>>;
const keys = blocked.filter(a => a[0] === "p").map(a => a[1]);
setBlocked(login, keys, latest.created_at * 1000);
} catch (error) {
console.debug("Failed to parse mute list", error, latest);
}
}
}
function handlePinnedFeed(pinnedFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(pinnedFeed, "e");
if (newest) {
setPinned(login, newest.keys, newest.createdAt * 1000);
}
}
function handleTagFeed(tagFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(tagFeed, "t");
if (newest) {
setTags(login, newest.keys, newest.createdAt * 1000);
}
}
function handleBookmarkFeed(bookmarkFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(bookmarkFeed, "e");
if (newest) {
setBookmarked(login, newest.keys, newest.createdAt * 1000);
}
}
function handlePublicChatsListFeed(bookmarkFeed: TaggedNostrEvent[]) {
const newest = getNewestEventTagsByKey(bookmarkFeed, "e");
if (newest) {
LoginStore.updateSession({
...login,
extraChats: newest.keys.map(Nip28ChatSystem.chatId),
});
}
}
useEffect(() => { useEffect(() => {
if (loginFeed) { system.profileLoader.TrackKeys(login.state.follows ?? []); // always track follows profiles
const mutedFeed = loginFeed.filter(a => a.kind === EventKind.MuteList); }, [system, login.state.follows]);
handleMutedFeed(mutedFeed);
const pinnedFeed = loginFeed.filter(a => a.kind === EventKind.PinList);
handlePinnedFeed(pinnedFeed);
const tagsFeed = loginFeed.filter(a => a.kind === EventKind.InterestsList);
handleTagFeed(tagsFeed);
const bookmarkFeed = loginFeed.filter(a => a.kind === EventKind.BookmarksList);
handleBookmarkFeed(bookmarkFeed);
const publicChatsFeed = loginFeed.filter(a => a.kind === EventKind.PublicChatsList);
handlePublicChatsListFeed(publicChatsFeed);
}
}, [loginFeed]);
useEffect(() => {
const pTags = contacts.filter(a => a[0] === "p").map(a => a[1]);
system.profileLoader.TrackKeys(pTags); // always track follows profiles
}, [contacts]);
} }

View File

@ -1,4 +1,4 @@
import { EventExt, EventKind, NostrLink, RequestBuilder } from "@snort/system"; import { EventKind, Nip10, NostrLink, RequestBuilder } from "@snort/system";
import { SnortContext, useRequestBuilder } from "@snort/system-react"; import { SnortContext, useRequestBuilder } from "@snort/system-react";
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
@ -39,7 +39,7 @@ export default function useThreadFeed(link: NostrLink) {
.relay(rootRelays ?? []); .relay(rootRelays ?? []);
} }
return sub; return sub;
}, [allEvents.length, rootRelays]); }, [allEvents, link, root, rootRelays]);
const store = useRequestBuilder(sub); const store = useRequestBuilder(sub);
@ -53,27 +53,21 @@ export default function useThreadFeed(link: NostrLink) {
.flat(); .flat();
setAllEvents(links); setAllEvents(links);
// load the thread structure from the current note
const current = store.find(a => link.matchesEvent(a)); const current = store.find(a => link.matchesEvent(a));
if (current) { if (current) {
const t = EventExt.extractThread(current); const t = Nip10.parseThread(current);
if (t) { if (t) {
const rootOrReplyAsRoot = t?.root ?? t?.replyTo; const rootOrReplyAsRoot = t?.root ?? t?.replyTo;
if (rootOrReplyAsRoot) { if (rootOrReplyAsRoot) {
setRoot( setRoot(rootOrReplyAsRoot);
NostrLink.fromTag([
rootOrReplyAsRoot.key,
rootOrReplyAsRoot.value ?? "",
rootOrReplyAsRoot.relay ?? "",
...(rootOrReplyAsRoot.marker ?? []),
]),
);
} }
} else { } else {
setRoot(link); setRoot(link);
} }
} }
} }
}, [store?.length]); }, [link, store, store.length]);
useEffect(() => { useEffect(() => {
if (root) { if (root) {
@ -87,12 +81,15 @@ export default function useThreadFeed(link: NostrLink) {
relays.relays.filter(a => a.settings.read).map(a => a.url), relays.relays.filter(a => a.settings.read).map(a => a.url),
3, 3,
); );
if (root.relays) {
readRelays.push(...root.relays);
}
setRootRelays(readRelays); setRootRelays(readRelays);
} }
}); });
} }
} }
}, [link, root, store?.length]); }, [link, root, store, store.length, system.relayCache]);
return store ?? []; return store ?? [];
} }

View File

@ -3,8 +3,8 @@ import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilderAdvanced } from "@snort/system-react"; import { useRequestBuilderAdvanced } from "@snort/system-react";
import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useCallback, useMemo, useSyncExternalStore } from "react";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration"; import useModeration from "@/Hooks/useModeration";
import usePreferences from "@/Hooks/usePreferences";
import useTimelineWindow from "@/Hooks/useTimelineWindow"; import useTimelineWindow from "@/Hooks/useTimelineWindow";
import { SearchRelays } from "@/Utils/Const"; import { SearchRelays } from "@/Utils/Const";
@ -30,7 +30,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
window: options.window, window: options.window,
now: options.now ?? unixNow(), now: options.now ?? unixNow(),
}); });
const pref = useLogin(s => s.appData.json.preferences); const autoShowLatest = usePreferences(s => s.autoShowLatest);
const { isEventMuted } = useModeration(); const { isEventMuted } = useModeration();
const createBuilder = useCallback(() => { const createBuilder = useCallback(() => {
@ -93,7 +93,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
} }
return rb; return rb;
} }
}, [until, since, options.method, pref, createBuilder]); }, [until, since, options.method, createBuilder]);
const mainQuery = useRequestBuilderAdvanced(sub); const mainQuery = useRequestBuilderAdvanced(sub);
const main = useSyncExternalStore( const main = useSyncExternalStore(
@ -108,7 +108,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
const subRealtime = useMemo(() => { const subRealtime = useMemo(() => {
const rb = createBuilder(); const rb = createBuilder();
if (rb && !pref.autoShowLatest && options.method !== "LIMIT_UNTIL") { if (rb && !autoShowLatest && options.method !== "LIMIT_UNTIL") {
rb.withOptions({ rb.withOptions({
leaveOpen: true, leaveOpen: true,
}); });
@ -118,7 +118,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
} }
return rb; return rb;
} }
}, [pref.autoShowLatest, createBuilder]); }, [autoShowLatest, createBuilder]);
const latestQuery = useRequestBuilderAdvanced(subRealtime); const latestQuery = useRequestBuilderAdvanced(subRealtime);
const latest = useSyncExternalStore( const latest = useSyncExternalStore(

View File

@ -1,48 +1,32 @@
import { DiffSyncTags, EventKind, NostrLink, NostrPrefix } from "@snort/system"; import { NostrLink } from "@snort/system";
import { useMemo } from "react";
import useEventPublisher from "./useEventPublisher";
import useLogin from "./useLogin"; import useLogin from "./useLogin";
/** /**
* Simple hook for adding / removing follows * Simple hook for adding / removing follows
*/ */
export default function useFollowsControls() { export default function useFollowsControls() {
const { publisher, system } = useEventPublisher(); const state = useLogin(s => s.state);
const { pubkey, contacts, relays } = useLogin(s => ({
pubkey: s.publicKey,
contacts: s.contacts,
readonly: s.readonly,
relays: s.relays.item,
}));
return useMemo(() => { return {
const link = new NostrLink(NostrPrefix.Event, "", EventKind.ContactList, pubkey); isFollowing: (pk: string) => {
const sync = new DiffSyncTags(link); return state.follows?.includes(pk);
const content = JSON.stringify(relays); },
return { addFollow: async (pk: Array<string>) => {
isFollowing: (pk: string) => { for (const p of pk) {
return contacts.some(a => a[0] === "p" && a[1] === pk); await state.follow(NostrLink.publicKey(p), false);
}, }
addFollow: async (pk: Array<string>) => { await state.saveContacts();
sync.add(pk.map(a => ["p", a])); },
if (publisher) { removeFollow: async (pk: Array<string>) => {
await sync.persist(publisher.signer, system, content); for (const p of pk) {
} await state.unfollow(NostrLink.publicKey(p), false);
}, }
removeFollow: async (pk: Array<string>) => { await state.saveContacts();
sync.remove(pk.map(a => ["p", a])); },
if (publisher) { setFollows: async (pk: Array<string>) => {
await sync.persist(publisher.signer, system, content); await state.replaceFollows(pk.map(a => NostrLink.publicKey(a)));
} },
}, followList: state.follows ?? [],
setFollows: async (pk: Array<string>) => { };
sync.replace(pk.map(a => ["p", a]));
if (publisher) {
await sync.persist(publisher.signer, system, content);
}
},
followList: contacts.filter(a => a[0] === "p").map(a => a[1]),
};
}, [contacts, relays, publisher, system]);
} }

View File

@ -1,7 +1,7 @@
import * as utils from "@noble/curves/abstract/utils"; import * as utils from "@noble/curves/abstract/utils";
import { base64 } from "@scure/base"; import { base64 } from "@scure/base";
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
import { hmacSha256, unwrap } from "@/Utils"; import { hmacSha256, unwrap } from "@/Utils";
export interface ImgProxySettings { export interface ImgProxySettings {
@ -11,10 +11,10 @@ export interface ImgProxySettings {
} }
export default function useImgProxy() { export default function useImgProxy() {
const settings = useLogin(s => s.appData.json.preferences.imgProxyConfig); const imgProxyConfig = usePreferences(s => s.imgProxyConfig);
return { return {
proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, settings, resize, sha256), proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, imgProxyConfig, resize, sha256),
}; };
} }

View File

@ -36,14 +36,6 @@ export function usePinList(pubkey: string | undefined) {
}); });
} }
export function useMuteList(pubkey: string | undefined) {
return useLinkList(`list:mute:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.MuteList]).authors([pubkey]);
}
});
}
export function useBookmarkList(pubkey: string | undefined) { export function useBookmarkList(pubkey: string | undefined) {
return useLinkListEvents(`list:bookmark:${pubkey?.slice(0, 12)}`, rb => { return useLinkListEvents(`list:bookmark:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) { if (pubkey) {

View File

@ -2,10 +2,10 @@ import { RelaySettings, SystemInterface } from "@snort/system";
import { useEffect } from "react"; import { useEffect } from "react";
import useEventPublisher from "./useEventPublisher"; import useEventPublisher from "./useEventPublisher";
import useLogin from "./useLogin"; import useRelays from "./useRelays";
export function useLoginRelays() { export function useLoginRelays() {
const relays = useLogin(s => s.relays.item); const relays = useRelays();
const { system } = useEventPublisher(); const { system } = useEventPublisher();
useEffect(() => { useEffect(() => {

View File

@ -1,80 +1,48 @@
import { HexKey, NostrEvent, TaggedNostrEvent } from "@snort/system"; import { EventKind, NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { appendDedupe } from "@/Utils"; import { dedupe } from "@snort/shared";
import { setBlocked, setMuted } from "@/Utils/Login";
export default function useModeration() { export default function useModeration() {
const login = useLogin(); const state = useLogin(s => s.state);
const { muted, blocked, appData } = login;
const { publisher, system } = useEventPublisher();
async function setMutedList(pub: HexKey[], priv: HexKey[]) { function isMuted(id: string) {
if (publisher) { const link = NostrLink.publicKey(id);
const ev = await publisher.muted(pub, priv); return state.muted.includes(link);
system.BroadcastEvent(ev); }
return ev.created_at * 1000;
async function unmute(id: string) {
const link = NostrLink.publicKey(id);
await state.unmute(link, true);
}
async function mute(id: string) {
const link = NostrLink.publicKey(id);
await state.mute(link, true);
}
async function muteAll(ids: string[]) {
const links = dedupe(ids).map(a => NostrLink.publicKey(a));
for (const link of links) {
await state.mute(link, false);
} }
return 0; await state.saveList(EventKind.MuteList);
}
function isMuted(id: HexKey) {
return muted.item.includes(id) || blocked.item.includes(id);
}
function isBlocked(id: HexKey) {
return blocked.item.includes(id);
}
async function unmute(id: HexKey) {
const newMuted = muted.item.filter(p => p !== id);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
}
async function unblock(id: HexKey) {
const newBlocked = blocked.item.filter(p => p !== id);
const ts = await setMutedList(muted.item, newBlocked);
setBlocked(login, newBlocked, ts);
}
async function mute(id: HexKey) {
const newMuted = muted.item.includes(id) ? muted.item : muted.item.concat([id]);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
}
async function block(id: HexKey) {
const newBlocked = blocked.item.includes(id) ? blocked.item : blocked.item.concat([id]);
const ts = await setMutedList(muted.item, newBlocked);
setBlocked(login, newBlocked, ts);
}
async function muteAll(ids: HexKey[]) {
const newMuted = appendDedupe(muted.item, ids);
const ts = await setMutedList(newMuted, blocked.item);
setMuted(login, newMuted, ts);
} }
function isMutedWord(word: string) { function isMutedWord(word: string) {
return appData.json.mutedWords.includes(word.toLowerCase()); return false;
} }
function isEventMuted(ev: TaggedNostrEvent | NostrEvent) { function isEventMuted(ev: TaggedNostrEvent | NostrEvent) {
return isMuted(ev.pubkey) || appData.json.mutedWords.some(w => ev.content.toLowerCase().includes(w)); return isMuted(ev.pubkey) || false;
} }
return { return {
muted: muted.item, muteList: state.muted,
mute, mute,
muteAll, muteAll,
unmute, unmute,
isMuted, isMuted,
blocked: blocked.item,
block,
unblock,
isBlocked,
isMutedWord, isMutedWord,
isEventMuted, isEventMuted,
}; };

View File

@ -1,10 +1,32 @@
import { updateAppData, UserPreferences } from "@/Utils/Login"; import { DefaultPreferences, updateAppData, UserPreferences } from "@/Utils/Login";
import useEventPublisher from "./useEventPublisher"; import useEventPublisher from "./useEventPublisher";
import useLogin from "./useLogin"; import useLogin from "./useLogin";
export default function usePreferences() { export default function usePreferences<T = UserPreferences>(selector?: (v: UserPreferences) => T): T {
const { id, pref } = useLogin(s => ({ id: s.id, pref: s.appData.json.preferences })); const defaultSelector = (v: UserPreferences) => v as unknown as T;
return useLogin(s => {
const pref = s.state.appdata?.preferences ?? {
...DefaultPreferences,
...CONFIG.defaultPreferences,
};
return (selector || defaultSelector)(pref);
});
}
export function useAllPreferences() {
const { id, pref } = useLogin(s => {
const pref = s.state.appdata?.preferences ?? {
...DefaultPreferences,
...CONFIG.defaultPreferences,
};
return {
id: s.id,
pref: pref,
};
});
const { system } = useEventPublisher(); const { system } = useEventPublisher();
return { return {

View File

@ -2,10 +2,10 @@ import { socialGraphInstance } from "@snort/system";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import fuzzySearch from "@/Db/FuzzySearch"; import fuzzySearch from "@/Db/FuzzySearch";
import useTimelineFeed from "@/Feed/TimelineFeed"; import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
import { debounce } from "@/Utils"; import { debounce } from "@/Utils";
const options = { method: "LIMIT_UNTIL" }; const options: TimelineFeedOptions = { method: "LIMIT_UNTIL" };
export default function useProfileSearch(search: string) { export default function useProfileSearch(search: string) {
const [debouncedSearch, setDebouncedSearch] = useState(search); const [debouncedSearch, setDebouncedSearch] = useState(search);
@ -17,11 +17,12 @@ export default function useProfileSearch(search: string) {
}, [search]); }, [search]);
const subject = useMemo( const subject = useMemo(
() => ({ () =>
type: "profile_keyword", ({
items: debouncedSearch ? [debouncedSearch] : [], type: "profile_keyword",
discriminator: debouncedSearch, items: debouncedSearch ? [debouncedSearch] : [],
}), discriminator: debouncedSearch,
}) as TimelineSubject,
[debouncedSearch], [debouncedSearch],
); );
const feed = useTimelineFeed(subject, options); const feed = useTimelineFeed(subject, options);

View File

@ -0,0 +1,6 @@
import useLogin from "./useLogin";
export default function useRelays() {
const relays = useLogin(s => s.state.relays);
return relays ? Object.fromEntries(relays.map(a => [a.url, a.settings])) : CONFIG.defaultRelays;
}

View File

@ -1,9 +1,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import useLogin from "./useLogin"; import usePreferences from "./usePreferences";
export function useTheme() { export function useTheme() {
const { preferences } = useLogin(s => ({ preferences: s.appData.json.preferences })); const theme = usePreferences(s => s.theme);
function setTheme(theme: "light" | "dark") { function setTheme(theme: "light" | "dark") {
const elm = document.documentElement; const elm = document.documentElement;
@ -16,17 +16,15 @@ export function useTheme() {
useEffect(() => { useEffect(() => {
const osTheme = window.matchMedia("(prefers-color-scheme: light)"); const osTheme = window.matchMedia("(prefers-color-scheme: light)");
setTheme( setTheme(theme === "system" && osTheme.matches ? "light" : theme === "light" ? "light" : "dark");
preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark",
);
osTheme.onchange = e => { osTheme.onchange = e => {
if (preferences.theme === "system") { if (theme === "system") {
setTheme(e.matches ? "light" : "dark"); setTheme(e.matches ? "light" : "dark");
} }
}; };
return () => { return () => {
osTheme.onchange = null; osTheme.onchange = null;
}; };
}, [preferences.theme]); }, [theme]);
} }

View File

@ -13,6 +13,7 @@ import Toaster from "@/Components/Toaster/Toaster";
import useLoginFeed from "@/Feed/LoginFeed"; import useLoginFeed from "@/Feed/LoginFeed";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { useLoginRelays } from "@/Hooks/useLoginRelays"; import { useLoginRelays } from "@/Hooks/useLoginRelays";
import usePreferences from "@/Hooks/usePreferences";
import { useTheme } from "@/Hooks/useTheme"; import { useTheme } from "@/Hooks/useTheme";
import { ArticlesCol, MediaCol, NotesCol, NotificationsCol } from "@/Pages/Deck/Columns"; import { ArticlesCol, MediaCol, NotesCol, NotificationsCol } from "@/Pages/Deck/Columns";
import NavSidebar from "@/Pages/Layout/NavSidebar"; import NavSidebar from "@/Pages/Layout/NavSidebar";
@ -40,8 +41,8 @@ export function SnortDeckLayout() {
const login = useLogin(s => ({ const login = useLogin(s => ({
publicKey: s.publicKey, publicKey: s.publicKey,
subscriptions: s.subscriptions, subscriptions: s.subscriptions,
telemetry: s.appData.json.preferences.telemetry,
})); }));
const telemetry = usePreferences(s => s.telemetry);
const navigate = useNavigate(); const navigate = useNavigate();
const [deckState, setDeckState] = useState<DeckState>({ const [deckState, setDeckState] = useState<DeckState>({
thread: undefined, thread: undefined,
@ -60,7 +61,7 @@ export function SnortDeckLayout() {
}, [login]); }, [login]);
useEffect(() => { useEffect(() => {
if (CONFIG.features.analytics && (login.telemetry ?? true)) { if (CONFIG.features.analytics && (telemetry ?? true)) {
trackEvent("pageview"); trackEvent("pageview");
} }
}, [location]); }, [location]);

View File

@ -1,5 +1,5 @@
import { dedupe } from "@snort/shared"; import { dedupe } from "@snort/shared";
import { EventKind, RequestBuilder } from "@snort/system"; import { EventKind, NostrHashtagLink, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import classNames from "classnames"; import classNames from "classnames";
import { useMemo } from "react"; import { useMemo } from "react";
@ -9,15 +9,22 @@ import { Link, useParams } from "react-router-dom";
import AsyncButton from "@/Components/Button/AsyncButton"; import AsyncButton from "@/Components/Button/AsyncButton";
import Timeline from "@/Components/Feed/Timeline"; import Timeline from "@/Components/Feed/Timeline";
import ProfileImage from "@/Components/User/ProfileImage"; import ProfileImage from "@/Components/User/ProfileImage";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { setTags } from "@/Utils/Login";
import { formatShort } from "@/Utils/Number"; import { formatShort } from "@/Utils/Number";
import { TimelineSubject } from "@/Feed/TimelineFeed";
const HashTagsPage = () => { const HashTagsPage = () => {
const params = useParams(); const params = useParams();
const tag = (params.tag ?? "").toLowerCase(); const tag = (params.tag ?? "").toLowerCase();
const subject = useMemo(() => ({ type: "hashtag", items: [tag], discriminator: tag }), [tag]); const subject = useMemo(
() =>
({
type: "hashtag",
items: [tag],
discriminator: tag,
}) as TimelineSubject,
[tag],
);
return ( return (
<> <>
@ -32,23 +39,10 @@ const HashTagsPage = () => {
export default HashTagsPage; export default HashTagsPage;
export function HashTagHeader({ tag, events, className }: { tag: string; events?: number; className?: string }) { export function HashTagHeader({ tag, events, className }: { tag: string; events?: number; className?: string }) {
const login = useLogin(); const state = useLogin(s => s.state);
const isFollowing = useMemo(() => { const isFollowing = useMemo(() => {
return login.tags.item.includes(tag); return state.isOnList(EventKind.InterestsList, new NostrHashtagLink(tag));
}, [login, tag]); }, [state, tag]);
const { publisher, system } = useEventPublisher();
async function followTags(ts: string[]) {
if (publisher) {
const ev = await publisher.generic(eb => {
eb.kind(EventKind.InterestsList);
ts.forEach(a => eb.tag(["t", a]));
return eb;
});
setTags(login, ts, ev.created_at * 1000);
await system.BroadcastEvent(ev);
}
}
const sub = useMemo(() => { const sub = useMemo(() => {
const rb = new RequestBuilder(`hashtag-counts:${tag}`); const rb = new RequestBuilder(`hashtag-counts:${tag}`);
@ -78,11 +72,13 @@ export function HashTagHeader({ tag, events, className }: { tag: string; events?
)} )}
</div> </div>
{isFollowing ? ( {isFollowing ? (
<AsyncButton className="secondary" onClick={() => followTags(login.tags.item.filter(t => t !== tag))}> <AsyncButton
className="secondary"
onClick={() => state.removeFromList(EventKind.InterestsList, new NostrHashtagLink(tag), true)}>
<FormattedMessage defaultMessage="Unfollow" id="izWS4J" /> <FormattedMessage defaultMessage="Unfollow" id="izWS4J" />
</AsyncButton> </AsyncButton>
) : ( ) : (
<AsyncButton onClick={() => followTags(login.tags.item.concat([tag]))}> <AsyncButton onClick={() => state.addToList(EventKind.InterestsList, new NostrHashtagLink(tag), true)}>
<FormattedMessage defaultMessage="Follow" id="ieGrWo" /> <FormattedMessage defaultMessage="Follow" id="ieGrWo" />
</AsyncButton> </AsyncButton>
)} )}

View File

@ -1,4 +1,4 @@
import { NostrLink, NostrPrefix, parseNostrLink } from "@snort/system"; import { EventKind, NostrLink, NostrPrefix, parseNostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react"; import { useEventFeed } from "@snort/system-react";
import classNames from "classnames"; import classNames from "classnames";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
@ -13,6 +13,7 @@ import useLogin from "@/Hooks/useLogin";
import { LogoHeader } from "@/Pages/Layout/LogoHeader"; import { LogoHeader } from "@/Pages/Layout/LogoHeader";
import NotificationsHeader from "@/Pages/Layout/NotificationsHeader"; import NotificationsHeader from "@/Pages/Layout/NotificationsHeader";
import { bech32ToHex } from "@/Utils"; import { bech32ToHex } from "@/Utils";
import { unwrap } from "@snort/shared";
export function Header() { export function Header() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -27,10 +28,17 @@ export function Header() {
} }
}, [pageName]); }, [pageName]);
const { publicKey, tags } = useLogin(); const { publicKey, tags } = useLogin(s => ({
publicKey: s.publicKey,
tags: s.state.getList(EventKind.InterestsList),
}));
const isRootTab = useMemo(() => { const isRootTab = useMemo(() => {
return location.pathname === "/" || rootTabItems("", publicKey, tags).some(item => item.path === location.pathname); // todo: clean this up, its also in other places
const hashTags = tags.filter(a => a.toEventTag()?.[0] === "t").map(a => unwrap(a.toEventTag())[1]);
return (
location.pathname === "/" || rootTabItems("", publicKey, hashTags).some(item => item.path === location.pathname)
);
}, [location.pathname, publicKey, tags]); }, [location.pathname, publicKey, tags]);
const scrollUp = useCallback(() => { const scrollUp = useCallback(() => {

View File

@ -13,6 +13,7 @@ import { useCommunityLeaders } from "@/Hooks/useCommunityLeaders";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut"; import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { useLoginRelays } from "@/Hooks/useLoginRelays"; import { useLoginRelays } from "@/Hooks/useLoginRelays";
import usePreferences from "@/Hooks/usePreferences";
import { useTheme } from "@/Hooks/useTheme"; import { useTheme } from "@/Hooks/useTheme";
import Footer from "@/Pages/Layout/Footer"; import Footer from "@/Pages/Layout/Footer";
import { Header } from "@/Pages/Layout/Header"; import { Header } from "@/Pages/Layout/Header";
@ -24,11 +25,11 @@ import RightColumn from "./RightColumn";
export default function Index() { export default function Index() {
const location = useLocation(); const location = useLocation();
const { id, stalker, telemetry } = useLogin(s => ({ const { id, stalker } = useLogin(s => ({
id: s.id, id: s.id,
stalker: s.stalker ?? false, stalker: s.stalker ?? false,
telemetry: s.appData.json.preferences.telemetry,
})); }));
const telemetry = usePreferences(s => s.telemetry);
useTheme(); useTheme();
useLoginRelays(); useLoginRelays();

View File

@ -8,6 +8,6 @@ import { LoginStore } from "@/Utils/Login";
export const avatar = (node: NodeObject<NodeObject<GraphNode>>) => { export const avatar = (node: NodeObject<NodeObject<GraphNode>>) => {
const login = LoginStore.snapshot(); const login = LoginStore.snapshot();
return node.profile?.picture return node.profile?.picture
? proxyImg(node.profile?.picture, login.appData.json.preferences.imgProxyConfig) ? proxyImg(node.profile?.picture, login.state.appdata?.preferences.imgProxyConfig)
: defaultAvatar(node.address); : defaultAvatar(node.address);
}; };

View File

@ -1,5 +1,4 @@
import { unwrap } from "@snort/shared"; import { EventKind, NostrLink, TaggedNostrEvent, Nip10 } from "@snort/system";
import { EventExt, EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
export function getNotificationContext(ev: TaggedNostrEvent) { export function getNotificationContext(ev: TaggedNostrEvent) {
switch (ev.kind) { switch (ev.kind) {
@ -20,10 +19,10 @@ export function getNotificationContext(ev: TaggedNostrEvent) {
} }
case EventKind.Repost: case EventKind.Repost:
case EventKind.Reaction: { case EventKind.Reaction: {
const thread = EventExt.extractThread(ev); const thread = Nip10.parseThread(ev);
const tag = unwrap(thread?.replyTo ?? thread?.root ?? { value: ev.id, key: "e" }); const tag = thread?.replyTo ?? thread?.root;
if (tag.key === "e" || tag.key === "a") { if (tag) {
return NostrLink.fromThreadTag(tag); return tag;
} else { } else {
throw new Error("Unknown thread context"); throw new Error("Unknown thread context");
} }

View File

@ -13,7 +13,7 @@ import { UserWebsiteLink } from "@/Components/User/UserWebsiteLink";
import ZapModal from "@/Components/ZapModal/ZapModal"; import ZapModal from "@/Components/ZapModal/ZapModal";
import useProfileBadges from "@/Feed/BadgesFeed"; import useProfileBadges from "@/Feed/BadgesFeed";
import useFollowsFeed from "@/Feed/FollowsFeed"; import useFollowsFeed from "@/Feed/FollowsFeed";
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
import { MusicStatus } from "@/Pages/Profile/MusicStatus"; import { MusicStatus } from "@/Pages/Profile/MusicStatus";
const ProfileDetails = ({ const ProfileDetails = ({
@ -31,9 +31,9 @@ const ProfileDetails = ({
lnurl?: LNURL; lnurl?: LNURL;
}) => { }) => {
const follows = useFollowsFeed(id); const follows = useFollowsFeed(id);
const { showStatus, showBadges } = useLogin(s => ({ const { showStatus, showBadges } = usePreferences(s => ({
showStatus: s.appData.json.preferences.showStatus ?? false, showStatus: s.showStatus ?? false,
showBadges: s.appData.json.preferences.showBadges ?? false, showBadges: s.showBadges ?? false,
})); }));
const [showLnQr, setShowLnQr] = useState<boolean>(false); const [showLnQr, setShowLnQr] = useState<boolean>(false);
const badges = useProfileBadges(showBadges ? id : undefined); const badges = useProfileBadges(showBadges ? id : undefined);

View File

@ -9,14 +9,11 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
import { ProxyImg } from "@/Components/ProxyImg"; import { ProxyImg } from "@/Components/ProxyImg";
import { SpotlightMediaModal } from "@/Components/Spotlight/SpotlightMedia"; import { SpotlightMediaModal } from "@/Components/Spotlight/SpotlightMedia";
import { Tab, TabSelector } from "@/Components/TabSelectors/TabSelectors"; import { Tab, TabSelector } from "@/Components/TabSelectors/TabSelectors";
import BlockList from "@/Components/User/BlockList";
import FollowsList from "@/Components/User/FollowListBase"; import FollowsList from "@/Components/User/FollowListBase";
import MutedList from "@/Components/User/MutedList"; import MutedList from "@/Components/User/MutedList";
import useFollowsFeed from "@/Feed/FollowsFeed"; import useFollowsFeed from "@/Feed/FollowsFeed";
import useHorizontalScroll from "@/Hooks/useHorizontalScroll"; import useHorizontalScroll from "@/Hooks/useHorizontalScroll";
import { useMuteList } from "@/Hooks/useLists";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import AvatarSection from "@/Pages/Profile/AvatarSection"; import AvatarSection from "@/Pages/Profile/AvatarSection";
import ProfileDetails from "@/Pages/Profile/ProfileDetails"; import ProfileDetails from "@/Pages/Profile/ProfileDetails";
import { import {
@ -63,18 +60,13 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
}, [user]); }, [user]);
// feeds // feeds
const { blocked } = useModeration();
const muted = useMuteList(id);
const follows = useFollowsFeed(id); const follows = useFollowsFeed(id);
// tabs // tabs
const [tab, setTab] = useState<Tab>(ProfileTabSelectors.Notes); const [tab, setTab] = useState<Tab>(ProfileTabSelectors.Notes);
const optionalTabs = [ const optionalTabs = [ProfileTabSelectors.Zaps, ProfileTabSelectors.Relays, ProfileTabSelectors.Bookmarks].filter(a =>
ProfileTabSelectors.Zaps, unwrap(a),
ProfileTabSelectors.Relays, ) as Tab[];
ProfileTabSelectors.Bookmarks,
ProfileTabSelectors.Muted,
].filter(a => unwrap(a)) as Tab[];
const horizontalScroll = useHorizontalScroll(); const horizontalScroll = useHorizontalScroll();
useEffect(() => { useEffect(() => {
@ -128,12 +120,6 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
case ProfileTabType.FOLLOWERS: { case ProfileTabType.FOLLOWERS: {
return <FollowersTab id={id} />; return <FollowersTab id={id} />;
} }
case ProfileTabType.MUTED: {
return <MutedList pubkeys={muted.map(a => a.id)} />;
}
case ProfileTabType.BLOCKED: {
return <BlockList />;
}
case ProfileTabType.RELAYS: { case ProfileTabType.RELAYS: {
return <RelaysTab id={id} />; return <RelaysTab id={id} />;
} }
@ -143,6 +129,9 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
case ProfileTabType.REACTIONS: { case ProfileTabType.REACTIONS: {
return <ReactionsTab id={id} />; return <ReactionsTab id={id} />;
} }
case ProfileTabType.MUTED: {
return <MutedList />;
}
} }
} }
@ -186,7 +175,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
ProfileTabSelectors.Follows, ProfileTabSelectors.Follows,
].map(renderTabSelector)} ].map(renderTabSelector)}
{optionalTabs.map(renderTabSelector)} {optionalTabs.map(renderTabSelector)}
{isMe && blocked.length > 0 && renderTabSelector(ProfileTabSelectors.Blocked)} {isMe && renderTabSelector(ProfileTabSelectors.Muted)}
</div> </div>
</div> </div>
<div className="main-content">{tabContent()}</div> <div className="main-content">{tabContent()}</div>

View File

@ -5,7 +5,6 @@ export enum ProfileTabType {
FOLLOWS = 3, FOLLOWS = 3,
ZAPS = 4, ZAPS = 4,
MUTED = 5, MUTED = 5,
BLOCKED = 6,
RELAYS = 7, RELAYS = 7,
BOOKMARKS = 8, BOOKMARKS = 8,
} }

View File

@ -1,12 +1,13 @@
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { RootTabRoutes } from "@/Pages/Root/RootTabRoutes"; import { RootTabRoutes } from "@/Pages/Root/RootTabRoutes";
export const DefaultTab = () => { export const DefaultTab = () => {
const { preferences, publicKey } = useLogin(s => ({ const { publicKey } = useLogin(s => ({
preferences: s.appData.json.preferences,
publicKey: s.publicKey, publicKey: s.publicKey,
})); }));
const tab = publicKey ? preferences.defaultRootTab : `trending/notes`; const defaultRootTab = usePreferences(s => s.defaultRootTab);
const tab = publicKey ? defaultRootTab : `trending/notes`;
const elm = RootTabRoutes.find(a => a.path === tab)?.element; const elm = RootTabRoutes.find(a => a.path === tab)?.element;
return elm ?? RootTabRoutes.find(a => a.path === preferences.defaultRootTab)?.element; return elm ?? RootTabRoutes.find(a => a.path === defaultRootTab)?.element;
}; };

View File

@ -15,8 +15,9 @@ import messages from "@/Pages/messages";
import { System } from "@/system"; import { System } from "@/system";
const FollowsHint = () => { const FollowsHint = () => {
const { publicKey, contacts } = useLogin(); const publicKey = useLogin(s => s.publicKey);
if (contacts.length === 0 && publicKey) { const { followList } = useFollowsControls();
if (followList.length === 0 && publicKey) {
return ( return (
<FormattedMessage <FormattedMessage
{...messages.NoFollows} {...messages.NoFollows}
@ -30,7 +31,6 @@ const FollowsHint = () => {
/> />
); );
} }
return null;
}; };
let forYouFeed = { let forYouFeed = {
@ -62,7 +62,11 @@ const getReactedByFollows = (follows: string[]) => {
export const ForYouTab = memo(function ForYouTab() { export const ForYouTab = memo(function ForYouTab() {
const [notes, setNotes] = useState<NostrEvent[]>(forYouFeed.events); const [notes, setNotes] = useState<NostrEvent[]>(forYouFeed.events);
const login = useLogin(); const login = useLogin(s => ({
feedDisplayAs: s.feedDisplayAs,
publicKey: s.publicKey,
tags: s.state.getList(EventKind.InterestSet),
}));
const displayAsInitial = login.feedDisplayAs ?? "list"; const displayAsInitial = login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial); const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const navigationType = useNavigationType(); const navigationType = useNavigationType();
@ -82,12 +86,12 @@ export const ForYouTab = memo(function ForYouTab() {
items: followList, items: followList,
discriminator: login.publicKey?.slice(0, 12), discriminator: login.publicKey?.slice(0, 12),
extra: rb => { extra: rb => {
if (login.tags.item.length > 0) { if (login.tags.length > 0) {
rb.withFilter().kinds([EventKind.TextNote]).tag("t", login.tags.item); rb.withFilter().kinds([EventKind.TextNote]).tags(login.tags);
} }
}, },
}) as TimelineSubject, }) as TimelineSubject,
[followList, login.tags.item], [login.publicKey, followList, login.tags],
); );
// also get "follows" feed so data is loaded from relays and there's a fallback if "for you" feed is empty // also get "follows" feed so data is loaded from relays and there's a fallback if "for you" feed is empty
const latestFeed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions); const latestFeed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
@ -164,7 +168,7 @@ export const ForYouTab = memo(function ForYouTab() {
const frags = useMemo(() => { const frags = useMemo(() => {
return [ return [
{ {
events: combinedFeed, events: combinedFeed as Array<TaggedNostrEvent>,
refTime: Date.now(), refTime: Date.now(),
}, },
]; ];
@ -175,7 +179,13 @@ export const ForYouTab = memo(function ForYouTab() {
<DisplayAsSelector activeSelection={displayAs} onSelect={a => setDisplayAs(a)} /> <DisplayAsSelector activeSelection={displayAs} onSelect={a => setDisplayAs(a)} />
<FollowsHint /> <FollowsHint />
<TaskList /> <TaskList />
<TimelineRenderer frags={frags} latest={[]} displayAs={displayAs} loadMore={() => latestFeed.loadMore()} /> <TimelineRenderer
frags={frags}
latest={[]}
displayAs={displayAs}
loadMore={() => latestFeed.loadMore()}
showLatest={() => {}}
/>
</> </>
); );
}); });

View File

@ -1,17 +1,19 @@
import { NostrLink } from "@snort/system"; import { NostrEvent, NostrLink } from "@snort/system";
import { useContext, useMemo } from "react"; import { useContext, useMemo } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import TimelineFollows from "@/Components/Feed/TimelineFollows"; import TimelineFollows from "@/Components/Feed/TimelineFollows";
import { TaskList } from "@/Components/Tasks/TaskList"; import { TaskList } from "@/Components/Tasks/TaskList";
import useFollowsControls from "@/Hooks/useFollowControls";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { DeckContext } from "@/Pages/Deck/DeckLayout"; import { DeckContext } from "@/Pages/Deck/DeckLayout";
import messages from "@/Pages/messages"; import messages from "@/Pages/messages";
const FollowsHint = () => { const FollowsHint = () => {
const { publicKey: pubKey, follows } = useLogin(); const publicKey = useLogin(s => s.publicKey);
if (follows.item?.length === 0 && pubKey) { const { followList } = useFollowsControls();
if (followList.length === 0 && publicKey) {
return ( return (
<FormattedMessage <FormattedMessage
{...messages.NoFollows} {...messages.NoFollows}
@ -25,15 +27,15 @@ const FollowsHint = () => {
/> />
); );
} }
return null;
}; };
export const NotesTab = () => { export const NotesTab = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const deckContext = useContext(DeckContext); const deckContext = useContext(DeckContext);
const noteOnClick = useMemo(() => { const noteOnClick = useMemo(() => {
if (deckContext) { if (deckContext) {
return ev => { return (ev: NostrEvent) => {
deckContext.setThread(NostrLink.fromEvent(ev)); deckContext.setThread(NostrLink.fromEvent(ev));
}; };
} }

View File

@ -2,15 +2,22 @@ import { useMemo } from "react";
import Timeline from "@/Components/Feed/Timeline"; import Timeline from "@/Components/Feed/Timeline";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { EventKind } from "@snort/system";
import { unwrap } from "@snort/shared";
import { TimelineSubject } from "@/Feed/TimelineFeed";
export function TopicsPage() { export function TopicsPage() {
const { tags, pubKey } = useLogin(s => ({ tags: s.tags.item, pubKey: s.publicKey })); const { tags, pubKey } = useLogin(s => ({
pubKey: s.publicKey,
tags: s.state.getList(EventKind.InterestSet),
}));
const subject = useMemo( const subject = useMemo(
() => ({ () =>
type: "hashtag", ({
items: tags, type: "hashtag",
discriminator: pubKey ?? "", items: tags.filter(a => a.toEventTag()?.[0] === "t").map(a => unwrap(a.toEventTag())[1]),
}), discriminator: pubKey ?? "",
}) as TimelineSubject,
[tags, pubKey], [tags, pubKey],
); );

View File

@ -1,9 +1,9 @@
import { useMemo, useSyncExternalStore } from "react"; import { SnortContext } from "@snort/system-react";
import { useContext, useMemo, useSyncExternalStore } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl"; import { FormattedMessage, FormattedNumber } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton"; import AsyncButton from "@/Components/Button/AsyncButton";
import useEventPublisher from "@/Hooks/useEventPublisher"; import usePreferences from "@/Hooks/usePreferences";
import useLogin from "@/Hooks/useLogin";
import { ZapPoolTarget } from "@/Pages/ZapPool/ZapPoolTarget"; import { ZapPoolTarget } from "@/Pages/ZapPool/ZapPoolTarget";
import { bech32ToHex, getRelayName, trackEvent, unwrap } from "@/Utils"; import { bech32ToHex, getRelayName, trackEvent, unwrap } from "@/Utils";
import { SnortPubKey } from "@/Utils/Const"; import { SnortPubKey } from "@/Utils/Const";
@ -19,8 +19,8 @@ const DataProviders = [
]; ];
export function ZapPoolPageInner() { export function ZapPoolPageInner() {
const login = useLogin(); const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
const { system } = useEventPublisher(); const system = useContext(SnortContext);
const zapPool = useSyncExternalStore( const zapPool = useSyncExternalStore(
c => unwrap(ZapPoolController).hook(c), c => unwrap(ZapPoolController).hook(c),
() => unwrap(ZapPoolController).snapshot(), () => unwrap(ZapPoolController).snapshot(),
@ -39,7 +39,7 @@ export function ZapPoolPageInner() {
}) })
.filter(a => a !== undefined) .filter(a => a !== undefined)
.map(unwrap); .map(unwrap);
}, [login.relays]); }, []);
const sumPending = zapPool.reduce((acc, v) => acc + v.sum, 0); const sumPending = zapPool.reduce((acc, v) => acc + v.sum, 0);
return ( return (
@ -66,7 +66,7 @@ export function ZapPoolPageInner() {
values={{ values={{
number: ( number: (
<b> <b>
<FormattedNumber value={login.appData.json.preferences.defaultZapAmount} /> <FormattedNumber value={defaultZapAmount} />
</b> </b>
), ),
}} }}
@ -79,14 +79,12 @@ export function ZapPoolPageInner() {
values={{ values={{
nIn: ( nIn: (
<b> <b>
<FormattedNumber value={login.appData.json.preferences.defaultZapAmount} /> <FormattedNumber value={defaultZapAmount} />
</b> </b>
), ),
nOut: ( nOut: (
<b> <b>
<FormattedNumber <FormattedNumber value={ZapPoolController?.calcAllocation(defaultZapAmount) ?? 0} />
value={ZapPoolController?.calcAllocation(login.appData.json.preferences.defaultZapAmount) ?? 0}
/>
</b> </b>
), ),
}} }}

View File

@ -2,14 +2,13 @@ import { useUserProfile } from "@snort/system-react";
import { FormattedMessage, FormattedNumber } from "react-intl"; import { FormattedMessage, FormattedNumber } from "react-intl";
import ProfilePreview from "@/Components/User/ProfilePreview"; import ProfilePreview from "@/Components/User/ProfilePreview";
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
import { ZapPoolController, ZapPoolRecipient } from "@/Utils/ZapPoolController"; import { ZapPoolController, ZapPoolRecipient } from "@/Utils/ZapPoolController";
function ZapPoolTargetInner({ target }: { target: ZapPoolRecipient }) { function ZapPoolTargetInner({ target }: { target: ZapPoolRecipient }) {
const login = useLogin();
const profile = useUserProfile(target.pubkey); const profile = useUserProfile(target.pubkey);
const hasAddress = profile?.lud16 || profile?.lud06; const hasAddress = profile?.lud16 || profile?.lud06;
const defaultZapMount = Math.ceil(login.appData.json.preferences.defaultZapAmount * (target.split / 100)); const defaultZapMount = usePreferences(s => s.defaultZapAmount * (target.split / 100));
return ( return (
<ProfilePreview <ProfilePreview
pubkey={target.pubkey} pubkey={target.pubkey}

View File

@ -5,15 +5,14 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { appendDedupe } from "@/Utils"; import { appendDedupe } from "@/Utils";
import { SnortAppData, updateAppData } from "@/Utils/Login"; import { SnortAppData, updateAppData } from "@/Utils/Login";
import { useAllPreferences } from "@/Hooks/usePreferences";
export default function ModerationSettingsPage() { export default function ModerationSettingsPage() {
const login = useLogin(); const state = useAllPreferences();
const [muteWord, setMuteWord] = useState(""); const [muteWord, setMuteWord] = useState("");
const appData = login.appData.json;
const { system } = useEventPublisher();
function addMutedWord() { function addMutedWord() {
updateAppData(login.id, system, ad => ({ updateAppData(login.id, ad => ({
...ad, ...ad,
mutedWords: appendDedupe(appData.mutedWords, [muteWord]), mutedWords: appendDedupe(appData.mutedWords, [muteWord]),
})); }));
@ -21,14 +20,14 @@ export default function ModerationSettingsPage() {
} }
const handleToggle = (setting: keyof SnortAppData) => { const handleToggle = (setting: keyof SnortAppData) => {
updateAppData(login.id, system, ad => ({ updateAppData(login.id, ad => ({
...ad, ...ad,
[setting]: !appData[setting], [setting]: !appData[setting],
})); }));
}; };
function removeMutedWord(word: string) { function removeMutedWord(word: string) {
updateAppData(login.id, system, ad => ({ updateAppData(login.id, ad => ({
...ad, ...ad,
mutedWords: appData.mutedWords.filter(a => a !== word), mutedWords: appData.mutedWords.filter(a => a !== word),
})); }));

View File

@ -7,7 +7,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton"; import AsyncButton from "@/Components/Button/AsyncButton";
import { AllLanguageCodes } from "@/Components/IntlProvider/IntlProviderUtils"; import { AllLanguageCodes } from "@/Components/IntlProvider/IntlProviderUtils";
import { useLocale } from "@/Components/IntlProvider/useLocale"; import { useLocale } from "@/Components/IntlProvider/useLocale";
import usePreferences from "@/Hooks/usePreferences"; import { useAllPreferences } from "@/Hooks/usePreferences";
import { unwrap } from "@/Utils"; import { unwrap } from "@/Utils";
import { DefaultImgProxy } from "@/Utils/Const"; import { DefaultImgProxy } from "@/Utils/Const";
import { UserPreferences } from "@/Utils/Login"; import { UserPreferences } from "@/Utils/Login";
@ -16,7 +16,7 @@ import messages from "./messages";
const PreferencesPage = () => { const PreferencesPage = () => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { preferences, update: updatePerf } = usePreferences(); const { preferences, update: updatePerf } = useAllPreferences();
const [pref, setPref] = useState<UserPreferences>(preferences); const [pref, setPref] = useState<UserPreferences>(preferences);
const [error, setError] = useState(""); const [error, setError] = useState("");
const { lang } = useLocale(); const { lang } = useLocale();

View File

@ -1,12 +1,12 @@
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import AsyncButton from "@/Components/Button/AsyncButton";
import ProfilePreview from "@/Components/User/ProfilePreview"; import ProfilePreview from "@/Components/User/ProfilePreview";
import useRelayState from "@/Feed/RelayState"; import useRelayState from "@/Feed/RelayState";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { parseId, unwrap } from "@/Utils"; import { parseId, unwrap } from "@/Utils";
import { removeRelay } from "@/Utils/Login";
import messages from "./messages"; import messages from "./messages";
@ -104,14 +104,13 @@ const RelayInfo = () => {
))} ))}
</div> </div>
<div className="flex mt10 justify-end"> <div className="flex mt10 justify-end">
<div <AsyncButton
className="btn error" onClick={async () => {
onClick={() => { await login.state.removeRelay(unwrap(conn).Address, true);
removeRelay(login, unwrap(conn).Address);
navigate("/settings/relays"); navigate("/settings/relays");
}}> }}>
<FormattedMessage {...messages.Remove} /> <FormattedMessage {...messages.Remove} />
</div> </AsyncButton>
</div> </div>
</div> </div>
</> </>

View File

@ -1,4 +1,4 @@
import { unixNowMs, unwrap } from "@snort/shared"; import { unwrap } from "@snort/shared";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
@ -7,31 +7,37 @@ import Relay from "@/Components/Relay/Relay";
import SnortApi, { RelayDistance } from "@/External/SnortApi"; import SnortApi, { RelayDistance } from "@/External/SnortApi";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useRelays from "@/Hooks/useRelays";
import { saveRelays } from "@/Pages/settings/saveRelays"; import { saveRelays } from "@/Pages/settings/saveRelays";
import { getCountry, getRelayName, sanitizeRelayUrl } from "@/Utils"; import { getCountry, getRelayName, sanitizeRelayUrl } from "@/Utils";
import { setRelays } from "@/Utils/Login";
import { formatShort } from "@/Utils/Number"; import { formatShort } from "@/Utils/Number";
import messages from "./messages"; import messages from "./messages";
const RelaySettingsPage = () => { const RelaySettingsPage = () => {
const { publisher, system } = useEventPublisher(); const { publisher, system } = useEventPublisher();
const login = useLogin(); const relays = useRelays();
const relays = login.relays; const { readonly, state } = useLogin(s => ({ state: s.state, readonly: s.readonly }));
const [newRelay, setNewRelay] = useState<string>(); const [newRelay, setNewRelay] = useState<string>();
const otherConnections = useMemo(() => { const otherConnections = useMemo(() => {
return [...system.pool].filter(([k]) => relays.item[k] === undefined).map(([, v]) => v); return [...system.pool].filter(([k]) => relays[k] === undefined).map(([, v]) => v);
}, [relays]); }, [system.pool, relays]);
const handleNewRelayChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleNewRelayChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = event.target.value; const inputValue = event.target.value;
const protocol = window.location.protocol; setNewRelay(inputValue);
if ((protocol === "https:" && inputValue.startsWith("wss://")) || protocol === "http:") {
setNewRelay(inputValue);
}
}; };
async function addNewRelay() {
const url = sanitizeRelayUrl(newRelay);
if (url) {
await state.addRelay(url, { read: true, write: true }, false);
setNewRelay("");
}
}
function addRelay() { function addRelay() {
return ( return (
<div className="flex flex-col g8"> <div className="flex flex-col g8">
@ -45,35 +51,24 @@ const RelaySettingsPage = () => {
value={newRelay} value={newRelay}
onChange={handleNewRelayChange} onChange={handleNewRelayChange}
/> />
<button className="secondary" onClick={() => addNewRelay()}> <AsyncButton className="secondary" onClick={() => addNewRelay()}>
<FormattedMessage {...messages.Add} /> <FormattedMessage {...messages.Add} />
</button> </AsyncButton>
</div> </div>
); );
} }
function addNewRelay() {
if ((newRelay?.length ?? 0) > 0) {
const parsed = new URL(newRelay ?? "");
const payload = {
...relays.item,
[parsed.toString()]: { read: true, write: true },
};
setRelays(login, payload, unixNowMs());
}
}
return ( return (
<div className="flex flex-col g8"> <div className="flex flex-col g8">
<h3> <h3>
<FormattedMessage {...messages.Relays} /> <FormattedMessage {...messages.Relays} />
</h3> </h3>
<div className="flex flex-col g8"> <div className="flex flex-col g8">
{Object.keys(relays.item || {}).map(a => ( {Object.keys(relays || {}).map(a => (
<Relay addr={a} key={a} /> <Relay addr={a} key={a} />
))} ))}
</div> </div>
<AsyncButton type="button" onClick={() => saveRelays(system, publisher, relays.item)} disabled={login.readonly}> <AsyncButton type="button" onClick={() => saveRelays(system, publisher, relays.item)} disabled={readonly}>
<FormattedMessage {...messages.Save} /> <FormattedMessage {...messages.Save} />
</AsyncButton> </AsyncButton>
{addRelay()} {addRelay()}
@ -96,14 +91,22 @@ export function CloseRelays() {
const [relays, setRecommendedRelays] = useState<Array<RelayDistance>>(); const [relays, setRecommendedRelays] = useState<Array<RelayDistance>>();
const country = getCountry(); const country = getCountry();
const [location, setLocation] = useState<{ lat: number; lon: number }>(country); const [location, setLocation] = useState<{ lat: number; lon: number }>(country);
const login = useLogin(); const currentRelays = useRelays();
const relayUrls = Object.keys(login.relays.item); const state = useLogin(s => s.state);
const relayUrls = Object.keys(currentRelays);
async function loadRelays() { async function loadRelays() {
const api = new SnortApi(); const api = new SnortApi();
setRecommendedRelays(await api.closeRelays(location.lat, location.lon, 10)); setRecommendedRelays(await api.closeRelays(location.lat, location.lon, 10));
} }
async function addNewRelay(newRelay: string) {
const url = sanitizeRelayUrl(newRelay);
if (url) {
await state.addRelay(url, { read: true, write: true }, false);
}
}
useEffect(() => { useEffect(() => {
loadRelays().catch(console.error); loadRelays().catch(console.error);
}, [location]); }, [location]);
@ -138,17 +141,7 @@ export function CloseRelays() {
<div key={a.url} className="bg-dark p br flex flex-col g8"> <div key={a.url} className="bg-dark p br flex flex-col g8">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="bold">{getRelayName(a.url)}</div> <div className="bold">{getRelayName(a.url)}</div>
<AsyncButton <AsyncButton onClick={() => addNewRelay(a.url)}>
onClick={async () => {
setRelays(
login,
{
...login.relays.item,
[a.url]: { read: true, write: true },
},
unixNowMs(),
);
}}>
<FormattedMessage defaultMessage="Add" id="2/2yg+" /> <FormattedMessage defaultMessage="Add" id="2/2yg+" />
</AsyncButton> </AsyncButton>
</div> </div>

View File

@ -17,14 +17,13 @@ const enum PruneStage {
} }
export function PruneFollowList() { export function PruneFollowList() {
const { followList: follows } = useFollowsControls(); const followControls = useFollowsControls();
const { system } = useEventPublisher(); const { system } = useEventPublisher();
const uniqueFollows = dedupe(follows); const uniqueFollows = dedupe(followControls.followList);
const [status, setStatus] = useState<PruneStage>(); const [status, setStatus] = useState<PruneStage>();
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [lastPost, setLastPosts] = useState<Record<string, number>>(); const [lastPost, setLastPosts] = useState<Record<string, number>>();
const [unfollow, setUnfollow] = useState<Array<string>>([]); const [unfollow, setUnfollow] = useState<Array<string>>([]);
const followControls = useFollowsControls();
async function fetchLastPosts() { async function fetchLastPosts() {
setStatus(PruneStage.FetchLastPostTimestamp); setStatus(PruneStage.FetchLastPostTimestamp);
@ -121,8 +120,8 @@ export function PruneFollowList() {
defaultMessage="{x} follows ({y} duplicates)" defaultMessage="{x} follows ({y} duplicates)"
id="iICVoL" id="iICVoL"
values={{ values={{
x: follows.length, x: followControls.followList.length,
y: follows.length - uniqueFollows.length, y: followControls.followList.length - uniqueFollows.length,
}} }}
/> />
</div> </div>

View File

@ -6,6 +6,7 @@ import { FormattedMessage, FormattedNumber } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton"; import AsyncButton from "@/Components/Button/AsyncButton";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useRelays from "@/Hooks/useRelays";
import { SearchRelays } from "@/Utils/Const"; import { SearchRelays } from "@/Utils/Const";
export default function SyncAccountTool() { export default function SyncAccountTool() {
@ -13,9 +14,10 @@ export default function SyncAccountTool() {
const login = useLogin(); const login = useLogin();
const [scan, setScan] = useState<number>(); const [scan, setScan] = useState<number>();
const [results, setResults] = useState<Array<TaggedNostrEvent>>([]); const [results, setResults] = useState<Array<TaggedNostrEvent>>([]);
const myRelays = useRelays();
async function start() { async function start() {
const relays = Object.entries(login.relays.item) const relays = Object.entries(myRelays)
.filter(([, v]) => v.write) .filter(([, v]) => v.write)
.map(([k]) => k); .map(([k]) => k);
const sync = new RangeSync(system); const sync = new RangeSync(system);

View File

@ -22,40 +22,6 @@ import { SubscriptionEvent } from "@/Utils/Subscription";
import { Nip7OsSigner } from "./Nip7OsSigner"; import { Nip7OsSigner } from "./Nip7OsSigner";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (import.meta.env.VITE_SINGLE_RELAY) {
state.relays.item = {
[import.meta.env.VITE_SINGLE_RELAY]: { read: true, write: true },
};
state.relays.timestamp = 100;
LoginStore.updateSession(state);
return;
}
if (state.relays.timestamp >= createdAt) {
return;
}
// filter out non-websocket urls
const filtered = new Map<string, RelaySettings>();
for (const [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) {
const url = sanitizeRelayUrl(k);
if (url) {
filtered.set(url, v as RelaySettings);
}
}
}
state.relays.item = Object.fromEntries(filtered.entries());
state.relays.timestamp = createdAt;
LoginStore.updateSession(state);
}
export function removeRelay(state: LoginSession, addr: string) {
delete state.relays.item[addr];
LoginStore.updateSession(state);
}
export function logout(id: string) { export function logout(id: string) {
LoginStore.removeSession(id); LoginStore.removeSession(id);
GiftsCache.clear(); GiftsCache.clear();
@ -141,33 +107,6 @@ export function generateRandomKey() {
}; };
} }
export function setTags(state: LoginSession, tags: Array<string>, ts: number) {
if (state.tags.timestamp >= ts) {
return;
}
state.tags.item = tags;
state.tags.timestamp = ts;
LoginStore.updateSession(state);
}
export function setMuted(state: LoginSession, muted: Array<string>, ts: number) {
if (state.muted.timestamp >= ts) {
return;
}
state.muted.item = muted;
state.muted.timestamp = ts;
LoginStore.updateSession(state);
}
export function setBlocked(state: LoginSession, blocked: Array<string>, ts: number) {
if (state.blocked.timestamp >= ts) {
return;
}
state.blocked.item = blocked;
state.blocked.timestamp = ts;
LoginStore.updateSession(state);
}
export function updateSession(id: string, fn: (state: LoginSession) => void) { export function updateSession(id: string, fn: (state: LoginSession) => void) {
const session = LoginStore.get(id); const session = LoginStore.get(id);
if (session) { if (session) {
@ -176,37 +115,19 @@ export function updateSession(id: string, fn: (state: LoginSession) => void) {
} }
} }
export function setPinned(state: LoginSession, pinned: Array<string>, ts: number) { export async function setAppData(state: LoginSession, data: SnortAppData) {
if (state.pinned.timestamp >= ts) {
return;
}
state.pinned.item = pinned;
state.pinned.timestamp = ts;
LoginStore.updateSession(state);
}
export function setBookmarked(state: LoginSession, bookmarked: Array<string>, ts: number) {
if (state.bookmarked.timestamp >= ts) {
return;
}
state.bookmarked.item = bookmarked;
state.bookmarked.timestamp = ts;
LoginStore.updateSession(state);
}
export async function setAppData(state: LoginSession, data: SnortAppData, system: SystemInterface) {
const pub = LoginStore.getPublisher(state.id); const pub = LoginStore.getPublisher(state.id);
if (!pub) return; if (!pub) return;
await state.appData.updateJson(data, pub.signer, system); await state.state.setAppData(data);
LoginStore.updateSession(state); LoginStore.updateSession(state);
} }
export async function updateAppData(id: string, system: SystemInterface, fn: (data: SnortAppData) => SnortAppData) { export async function updateAppData(id: string, fn: (data: SnortAppData) => SnortAppData) {
const session = LoginStore.get(id); const session = LoginStore.get(id);
if (session) { if (session?.state.appdata) {
const next = fn(session.appData.json); const next = fn(session.state.appdata);
await setAppData(session, next, system); await setAppData(session, next);
} }
} }

View File

@ -1,4 +1,4 @@
import { HexKey, JsonEventSync, KeyStorage, RelaySettings } from "@snort/system"; import { HexKey, KeyStorage, UserState } from "@snort/system";
import { DisplayAs } from "@/Components/Feed/DisplayAsSelector"; import { DisplayAs } from "@/Components/Feed/DisplayAsSelector";
import { UserPreferences } from "@/Utils/Login/index"; import { UserPreferences } from "@/Utils/Login/index";
@ -21,9 +21,6 @@ export const enum LoginSessionType {
} }
export interface SnortAppData { export interface SnortAppData {
id: string;
mutedWords: Array<string>;
showContentWarningPosts: boolean;
preferences: UserPreferences; preferences: UserPreferences;
} }
@ -64,40 +61,7 @@ export interface LoginSession {
*/ */
publicKey?: HexKey; publicKey?: HexKey;
/** state: UserState<SnortAppData>;
* All the logged in users relays
*/
relays: Newest<Record<string, RelaySettings>>;
/**
* A list of pubkeys this user follows
*/
contacts: Array<Array<string>>;
/**
* A list of tags this user follows
*/
tags: Newest<Array<string>>;
/**
* A list of event ids this user has pinned
*/
pinned: Newest<Array<string>>;
/**
* A list of event ids this user has bookmarked
*/
bookmarked: Newest<Array<string>>;
/**
* A list of pubkeys this user has muted
*/
muted: Newest<Array<HexKey>>;
/**
* A list of pubkeys this user has muted privately
*/
blocked: Newest<Array<HexKey>>;
/** /**
* Timestamp of last read notification * Timestamp of last read notification
@ -114,11 +78,6 @@ export interface LoginSession {
*/ */
remoteSignerRelays?: Array<string>; remoteSignerRelays?: Array<string>;
/**
* Snort application data
*/
appData: JsonEventSync<SnortAppData>;
/** /**
* A list of chats which we have joined (NIP-28/NIP-29) * A list of chats which we have joined (NIP-28/NIP-29)
*/ */

View File

@ -3,16 +3,14 @@ import * as utils from "@noble/curves/abstract/utils";
import * as secp from "@noble/curves/secp256k1"; import * as secp from "@noble/curves/secp256k1";
import { ExternalStore, unwrap } from "@snort/shared"; import { ExternalStore, unwrap } from "@snort/shared";
import { import {
EventKind,
EventPublisher, EventPublisher,
HexKey, HexKey,
JsonEventSync,
KeyStorage, KeyStorage,
NostrLink,
NostrPrefix,
NotEncrypted, NotEncrypted,
RelaySettings, RelaySettings,
socialGraphInstance, socialGraphInstance,
UserState,
UserStateObject,
} from "@snort/system"; } from "@snort/system";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
@ -29,8 +27,6 @@ const LoggedOut = {
item: [], item: [],
timestamp: 0, timestamp: 0,
}, },
contacts: [],
follows: [],
muted: { muted: {
item: [], item: [],
timestamp: 0, timestamp: 0,
@ -54,18 +50,15 @@ const LoggedOut = {
latestNotification: 0, latestNotification: 0,
readNotifications: 0, readNotifications: 0,
subscriptions: [], subscriptions: [],
appData: new JsonEventSync<SnortAppData>(
{
id: "",
preferences: DefaultPreferences,
mutedWords: [],
showContentWarningPosts: false,
},
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData),
true,
),
extraChats: [], extraChats: [],
stalker: false, stalker: false,
state: new UserState<SnortAppData>("", {
initAppdata: {
preferences: DefaultPreferences,
},
encryptAppdata: true,
appdataId: "snort",
}),
} as LoginSession; } as LoginSession;
export class MultiAccountStore extends ExternalStore<LoginSession> { export class MultiAccountStore extends ExternalStore<LoginSession> {
@ -75,7 +68,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
constructor() { constructor() {
super(); super();
if (typeof ServiceWorkerGlobalScope !== "undefined" && self instanceof ServiceWorkerGlobalScope) { if (typeof ServiceWorkerGlobalScope !== "undefined" && globalThis instanceof ServiceWorkerGlobalScope) {
// return if sw. we might want to use localForage (idb) to share keys between sw and app // return if sw. we might want to use localForage (idb) to share keys between sw and app
return; return;
} }
@ -103,14 +96,23 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (v.privateKeyData) { if (v.privateKeyData) {
v.privateKeyData = KeyStorage.fromPayload(v.privateKeyData as object); v.privateKeyData = KeyStorage.fromPayload(v.privateKeyData as object);
} }
v.appData = new JsonEventSync<SnortAppData>( const stateObj = v.state as unknown as UserStateObject<SnortAppData> | undefined;
v.appData as unknown as SnortAppData, const stateClass = new UserState<SnortAppData>(
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, v.publicKey), v.publicKey!,
true, {
initAppdata: stateObj?.appdata ?? {
preferences: {
...DefaultPreferences,
...CONFIG.defaultPreferences,
},
},
encryptAppdata: true,
appdataId: "snort",
},
stateObj,
); );
v.appData.on("change", () => { stateClass.on("change", () => this.#save());
this.#save(); v.state = stateClass;
});
} }
this.#loadIrisKeyIfExists(); this.#loadIrisKeyIfExists();
} }
@ -174,24 +176,22 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
item: initRelays, item: initRelays,
timestamp: 1, timestamp: 1,
}, },
appData: new JsonEventSync<SnortAppData>( state: new UserState<SnortAppData>(key, {
{ initAppdata: {
id: "",
preferences: { preferences: {
...DefaultPreferences, ...DefaultPreferences,
...CONFIG.defaultPreferences, ...CONFIG.defaultPreferences,
}, },
mutedWords: [],
showContentWarningPosts: false,
}, },
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, key), encryptAppdata: true,
true, appdataId: "snort",
), }),
remoteSignerRelays, remoteSignerRelays,
privateKeyData: privateKey, privateKeyData: privateKey,
stalker: stalker ?? false, stalker: stalker ?? false,
} as LoginSession; } as LoginSession;
newSession.state.on("change", () => this.#save());
const pub = createPublisher(newSession); const pub = createPublisher(newSession);
if (pub) { if (pub) {
this.setPublisher(newSession.id, pub); this.setPublisher(newSession.id, pub);
@ -229,20 +229,18 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
item: initRelays, item: initRelays,
timestamp: 1, timestamp: 1,
}, },
appData: new JsonEventSync<SnortAppData>( state: new UserState<SnortAppData>(pubKey, {
{ initAppdata: {
id: "",
preferences: { preferences: {
...DefaultPreferences, ...DefaultPreferences,
...CONFIG.defaultPreferences, ...CONFIG.defaultPreferences,
}, },
mutedWords: [],
showContentWarningPosts: false,
}, },
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, pubKey), encryptAppdata: true,
true, appdataId: "snort",
), }),
} as LoginSession; } as LoginSession;
newSession.state.on("change", () => this.#save());
if ("nostr_os" in window && window?.nostr_os) { if ("nostr_os" in window && window?.nostr_os) {
window?.nostr_os.saveKey(key.value); window?.nostr_os.saveKey(key.value);
@ -300,14 +298,43 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
#migrate() { #migrate() {
let didMigrate = false; let didMigrate = false;
// delete some old keys
for (const [, acc] of this.#accounts) { for (const [, acc] of this.#accounts) {
if ("item" in acc.appData) { if ("appData" in acc) {
delete acc["appData"];
didMigrate = true;
}
if ("contacts" in acc) {
delete acc["contacts"];
didMigrate = true;
}
if ("follows" in acc) {
delete acc["follows"];
didMigrate = true;
}
if ("relays" in acc) {
delete acc["relays"];
didMigrate = true;
}
if ("blocked" in acc) {
delete acc["blocked"];
didMigrate = true;
}
if ("bookmarked" in acc) {
delete acc["bookmarked"];
didMigrate = true;
}
if ("muted" in acc) {
delete acc["muted"];
didMigrate = true;
}
if ("pinned" in acc) {
delete acc["pinned"];
didMigrate = true;
}
if ("tags" in acc) {
delete acc["tags"];
didMigrate = true; didMigrate = true;
acc.appData = new JsonEventSync<SnortAppData>(
acc.appData.item as SnortAppData,
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, acc.publicKey),
true,
);
} }
} }
@ -326,17 +353,18 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (v.privateKeyData instanceof KeyStorage) { if (v.privateKeyData instanceof KeyStorage) {
toSave.push({ toSave.push({
...v, ...v,
appData: v.appData.json, state: v.state instanceof UserState ? v.state.serialize() : v.state,
privateKeyData: v.privateKeyData.toPayload(), privateKeyData: v.privateKeyData.toPayload(),
}); });
} else { } else {
toSave.push({ toSave.push({
...v, ...v,
appData: v.appData.json, state: v.state instanceof UserState ? v.state.serialize() : v.state,
}); });
} }
} }
console.debug("Trying to save", toSave);
window.localStorage.setItem(AccountStoreKey, JSON.stringify(toSave)); window.localStorage.setItem(AccountStoreKey, JSON.stringify(toSave));
this.notifyChange(); this.notifyChange();
} }

View File

@ -102,6 +102,11 @@ export interface UserPreferences {
* Hides muted notes when selected * Hides muted notes when selected
*/ */
hideMutedNotes: boolean; hideMutedNotes: boolean;
/**
* Show posts with content warning
*/
showContentWarningPosts: boolean;
} }
export const DefaultPreferences = { export const DefaultPreferences = {
@ -123,4 +128,5 @@ export const DefaultPreferences = {
checkSigs: true, checkSigs: true,
autoTranslate: true, autoTranslate: true,
hideMutedNotes: false, hideMutedNotes: false,
showContentWarningPosts: false,
} as UserPreferences; } as UserPreferences;

View File

@ -1,18 +1,22 @@
import { unwrap } from "@snort/shared"; import { Nip10, NostrLink, TaggedNostrEvent } from "@snort/system";
import { EventExt, NostrLink, TaggedNostrEvent } from "@snort/system";
/** /**
* Get the chain key as a reply event * Get the chain key as a reply event
*
* ie. Get the key for which this event is replying to
*/ */
export function replyChainKey(ev: TaggedNostrEvent) { export function replyChainKey(ev: TaggedNostrEvent) {
const t = EventExt.extractThread(ev); const t = Nip10.parseThread(ev);
return t?.replyTo?.value ?? t?.root?.value; const tag = t?.replyTo ?? t?.root;
return tag?.tagKey;
} }
/** /**
* Get the chain key of this event * Get the chain key of this event
*
* ie. Get the key which ties replies to this event
*/ */
export function chainKey(ev: TaggedNostrEvent) { export function chainKey(ev: TaggedNostrEvent) {
const link = NostrLink.fromEvent(ev); const link = NostrLink.fromEvent(ev);
return unwrap(link.toEventTag())[1]; return link.tagKey;
} }

View File

@ -12,13 +12,13 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
const location = useLocation(); const location = useLocation();
const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]); const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]);
const feed = useThreadFeed(link); const feed = useThreadFeed(link);
const { isBlocked } = useModeration(); const { isMuted } = useModeration();
const chains = useMemo(() => { const chains = useMemo(() => {
const chains = new Map<u256, Array<TaggedNostrEvent>>(); const chains = new Map<u256, Array<TaggedNostrEvent>>();
if (feed) { if (feed) {
feed feed
?.filter(a => !isBlocked(a.pubkey)) ?.filter(a => !isMuted(a.pubkey))
.forEach(v => { .forEach(v => {
const replyTo = replyChainKey(v); const replyTo = replyChainKey(v);
if (replyTo) { if (replyTo) {
@ -31,7 +31,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
}); });
} }
return chains; return chains;
}, [feed]); }, [feed, isMuted]);
// Root is the parent of the current note or the current note if its a root note or the root of the thread // Root is the parent of the current note or the current note if its a root note or the root of the thread
const root = useMemo(() => { const root = useMemo(() => {
@ -46,7 +46,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
return currentNote; return currentNote;
} }
} }
}, [feed.length, currentId, location]); }, [feed, location.state, currentId]);
const ctxValue = useMemo<ThreadContextState>(() => { const ctxValue = useMemo<ThreadContextState>(() => {
return { return {
@ -56,7 +56,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
data: feed, data: feed,
setCurrent: v => setCurrentId(v), setCurrent: v => setCurrentId(v),
}; };
}, [root, chains]); }, [currentId, root, chains, feed]);
return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>; return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>;
} }

View File

@ -3,7 +3,7 @@ import { useState } from "react";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import useEventPublisher from "@/Hooks/useEventPublisher"; import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin"; import usePreferences from "@/Hooks/usePreferences";
import { bech32ToHex, unwrap } from "@/Utils"; import { bech32ToHex, unwrap } from "@/Utils";
import { KieranPubKey } from "@/Utils/Const"; import { KieranPubKey } from "@/Utils/Const";
import NostrBuild from "@/Utils/Upload/NostrBuild"; import NostrBuild from "@/Utils/Upload/NostrBuild";
@ -65,7 +65,7 @@ export interface UploadProgress {
export type UploadStage = "starting" | "hashing" | "uploading" | "done" | undefined; export type UploadStage = "starting" | "hashing" | "uploading" | "done" | undefined;
export default function useFileUpload(): Uploader { export default function useFileUpload(): Uploader {
const fileUploader = useLogin(s => s.appData.json.preferences.fileUploader); const fileUploader = usePreferences(s => s.fileUploader);
const { publisher } = useEventPublisher(); const { publisher } = useEventPublisher();
const [progress, setProgress] = useState<Array<UploadProgress>>([]); const [progress, setProgress] = useState<Array<UploadProgress>>([]);
const [stage, setStage] = useState<UploadStage>(); const [stage, setStage] = useState<UploadStage>();

View File

@ -437,9 +437,10 @@ export function getUrlHostname(url?: string) {
} }
} }
export function sanitizeRelayUrl(url: string) { export function sanitizeRelayUrl(url?: string) {
if ((url?.length ?? 0) === 0) return;
try { try {
return new URL(url).toString(); return new URL(url!).toString();
} catch { } catch {
// ignore // ignore
} }
@ -537,7 +538,7 @@ export function trackEvent(
if ( if (
!import.meta.env.DEV && !import.meta.env.DEV &&
CONFIG.features.analytics && CONFIG.features.analytics &&
(LoginStore.snapshot().appData.json.preferences.telemetry ?? true) (LoginStore.snapshot().state.appdata?.preferences.telemetry ?? true)
) { ) {
fetch("https://pa.v0l.io/api/event", { fetch("https://pa.v0l.io/api/event", {
method: "POST", method: "POST",
@ -547,9 +548,9 @@ export function trackEvent(
body: JSON.stringify({ body: JSON.stringify({
d: CONFIG.hostname, d: CONFIG.hostname,
n: event, n: event,
r: document.referrer === location.href ? null : document.referrer, r: document.referrer === window.location.href ? null : document.referrer,
p: props, p: props,
u: e?.destination?.url ?? `${location.protocol}//${location.host}${location.pathname}`, u: e?.destination?.url ?? `${window.location.protocol}//${window.location.host}${window.location.pathname}`,
}), }),
}); });
} }

View File

@ -169,19 +169,19 @@ export function useChatSystem(chat: ChatSystem) {
const login = useLogin(); const login = useLogin();
const sub = useMemo(() => { const sub = useMemo(() => {
return chat.subscription(login); return chat.subscription(login);
}, [login.publicKey]); }, [chat, login]);
const data = useRequestBuilder(sub); const data = useRequestBuilder(sub);
const { isBlocked } = useModeration(); const { isMuted } = useModeration();
return useMemo(() => { return useMemo(() => {
if (login.publicKey) { if (login.publicKey) {
return chat.listChats( return chat.listChats(
login.publicKey, login.publicKey,
data.filter(a => !isBlocked(a.pubkey)), data.filter(a => !isMuted(a.pubkey)),
); );
} }
return []; return [];
}, [login.publicKey, data]); }, [chat, login, data, isMuted]);
} }
export function useChatSystems() { export function useChatSystems() {

View File

@ -12,7 +12,6 @@ import { ThreadRoute } from "@/Components/Event/Thread/ThreadRoute";
import { IntlProvider } from "@/Components/IntlProvider/IntlProvider"; import { IntlProvider } from "@/Components/IntlProvider/IntlProvider";
import { db } from "@/Db"; import { db } from "@/Db";
import { addCachedMetadataToFuzzySearch } from "@/Db/FuzzySearch"; import { addCachedMetadataToFuzzySearch } from "@/Db/FuzzySearch";
import { updateRelayConnections } from "@/Hooks/useLoginRelays";
import { AboutPage } from "@/Pages/About"; import { AboutPage } from "@/Pages/About";
import { SnortDeckLayout } from "@/Pages/Deck/DeckLayout"; import { SnortDeckLayout } from "@/Pages/Deck/DeckLayout";
import DonatePage from "@/Pages/Donate/DonatePage"; import DonatePage from "@/Pages/Donate/DonatePage";
@ -39,10 +38,10 @@ import { WalletSendPage } from "@/Pages/wallet/send";
import ZapPoolPage from "@/Pages/ZapPool/ZapPool"; import ZapPoolPage from "@/Pages/ZapPool/ZapPool";
import { System } from "@/system"; import { System } from "@/system";
import { storeRefCode, unwrap } from "@/Utils"; import { storeRefCode, unwrap } from "@/Utils";
import { LoginStore } from "@/Utils/Login";
import { hasWasm, wasmInit, WasmPath } from "@/Utils/wasm"; import { hasWasm, wasmInit, WasmPath } from "@/Utils/wasm";
import { Wallets } from "@/Wallet"; import { Wallets } from "@/Wallet";
import { setupWebLNWalletConfig } from "@/Wallet"; import { setupWebLNWalletConfig } from "@/Wallet";
import { LoginStore } from "./Utils/Login";
async function initSite() { async function initSite() {
storeRefCode(); storeRefCode();
@ -50,15 +49,14 @@ async function initSite() {
await wasmInit(WasmPath); await wasmInit(WasmPath);
await initRelayWorker(); await initRelayWorker();
} }
const login = LoginStore.takeSnapshot();
updateRelayConnections(System, login.relays.item).catch(console.error);
setupWebLNWalletConfig(Wallets); setupWebLNWalletConfig(Wallets);
db.ready = await db.isAvailable(); db.ready = await db.isAvailable();
if (db.ready) { if (db.ready) {
const pTags = login.contacts.filter(a => a[0] === "p").map(a => a[1]); const login = LoginStore.snapshot();
await preload(pTags); preload(login.state.follows); // dont await this
await System.PreloadSocialGraph(); System.PreloadSocialGraph(); // dont await this
} }
queueMicrotask(() => { queueMicrotask(() => {

View File

@ -1,10 +1,6 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { CacheableResponsePlugin } from "workbox-cacheable-response"; import { CacheableResponsePlugin } from "workbox-cacheable-response";
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: (string | PrecacheEntry)[];
};
import { encodeTLVEntries, NostrLink, NostrPrefix, TLVEntryType, tryParseNostrLink } from "@snort/system"; import { encodeTLVEntries, NostrLink, NostrPrefix, TLVEntryType, tryParseNostrLink } from "@snort/system";
import { clientsClaim } from "workbox-core"; import { clientsClaim } from "workbox-core";
import { ExpirationPlugin } from "workbox-expiration"; import { ExpirationPlugin } from "workbox-expiration";
@ -15,12 +11,16 @@ import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies";
import { defaultAvatar, hexToBech32 } from "@/Utils"; import { defaultAvatar, hexToBech32 } from "@/Utils";
import { formatShort } from "@/Utils/Number"; import { formatShort } from "@/Utils/Number";
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: (string | PrecacheEntry)[];
};
precacheAndRoute(self.__WB_MANIFEST); precacheAndRoute(self.__WB_MANIFEST);
clientsClaim(); clientsClaim();
// cache everything in current domain /assets because precache doesn't seem to include everything // cache everything in current domain /assets because precache doesn't seem to include everything
registerRoute( registerRoute(
({ url }) => url.origin === location.origin && url.pathname.startsWith("/assets"), ({ url }) => url.origin === window.location.origin && url.pathname.startsWith("/assets"),
new StaleWhileRevalidate({ new StaleWhileRevalidate({
cacheName: "assets-cache", cacheName: "assets-cache",
plugins: [ plugins: [

View File

@ -201,7 +201,7 @@ export async function fetchNostrAddress(name: string, domain: string, timeout =
} }
export function removeUndefined<T>(v: Array<T | undefined>) { export function removeUndefined<T>(v: Array<T | undefined>) {
return v.filter(a => a != undefined).map(a => unwrap(a)); return v.filter(a => a !== undefined).map(a => unwrap(a));
} }
/** /**

View File

@ -5,7 +5,6 @@ import { EventEmitter } from "eventemitter3";
import { Connection, RelaySettings } from "./connection"; import { Connection, RelaySettings } from "./connection";
import { NostrEvent, OkResponse, TaggedNostrEvent } from "./nostr"; import { NostrEvent, OkResponse, TaggedNostrEvent } from "./nostr";
import { SystemInterface } from "."; import { SystemInterface } from ".";
import LRUSet from "@snort/shared/src/LRUSet";
export interface NostrConnectionPoolEvents { export interface NostrConnectionPoolEvents {
connected: (address: string, wasReconnect: boolean) => void; connected: (address: string, wasReconnect: boolean) => void;
@ -38,7 +37,6 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
* All currently connected websockets * All currently connected websockets
*/ */
#sockets = new Map<string, Connection>(); #sockets = new Map<string, Connection>();
#requestedIds = new LRUSet<string>(1000);
constructor(system: SystemInterface) { constructor(system: SystemInterface) {
super(); super();
@ -49,7 +47,8 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
* Get a connection object from the pool * Get a connection object from the pool
*/ */
getConnection(id: string) { getConnection(id: string) {
return this.#sockets.get(id); const addr = unwrap(sanitizeRelayUrl(id));
return this.#sockets.get(addr);
} }
/** /**

View File

@ -51,6 +51,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
#activity: number = unixNowMs(); #activity: number = unixNowMs();
#expectAuth = false; #expectAuth = false;
#ephemeral: boolean; #ephemeral: boolean;
#closing = false;
#downCount = 0;
#activeRequests = new Set<string>();
Id: string; Id: string;
readonly Address: string; readonly Address: string;
@ -58,26 +61,22 @@ export class Connection extends EventEmitter<ConnectionEvents> {
PendingRaw: Array<object> = []; PendingRaw: Array<object> = [];
PendingRequests: Array<ConnectionQueueItem> = []; PendingRequests: Array<ConnectionQueueItem> = [];
ActiveRequests = new Set<string>();
Settings: RelaySettings; Settings: RelaySettings;
Info?: RelayInfo; Info?: RelayInfo;
ConnectTimeout: number = DefaultConnectTimeout; ConnectTimeout: number = DefaultConnectTimeout;
HasStateChange: boolean = true; HasStateChange: boolean = true;
IsClosed: boolean;
ReconnectTimer?: ReturnType<typeof setTimeout>; ReconnectTimer?: ReturnType<typeof setTimeout>;
EventsCallback: Map<u256, (msg: Array<string | boolean>) => void>; EventsCallback: Map<u256, (msg: Array<string | boolean>) => void>;
AwaitingAuth: Map<string, boolean>; AwaitingAuth: Map<string, boolean>;
Authed = false; Authed = false;
Down = true;
constructor(addr: string, options: RelaySettings, ephemeral: boolean = false) { constructor(addr: string, options: RelaySettings, ephemeral: boolean = false) {
super(); super();
this.Id = uuid(); this.Id = uuid();
this.Address = addr; this.Address = addr;
this.Settings = options; this.Settings = options;
this.IsClosed = false;
this.EventsCallback = new Map(); this.EventsCallback = new Map();
this.AwaitingAuth = new Map(); this.AwaitingAuth = new Map();
this.#ephemeral = ephemeral; this.#ephemeral = ephemeral;
@ -93,7 +92,20 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#setupEphemeral(); this.#setupEphemeral();
} }
get isOpen() {
return this.Socket?.readyState === WebSocket.OPEN;
}
get isDown() {
return this.#downCount > 0;
}
get ActiveRequests() {
return [...this.#activeRequests];
}
async connect() { async connect() {
if (this.isOpen) return;
try { try {
if (this.Info === undefined) { if (this.Info === undefined) {
const u = new URL(this.Address); const u = new URL(this.Address);
@ -116,7 +128,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
// ignored // ignored
} }
const wasReconnect = this.Socket !== null && !this.IsClosed; const wasReconnect = this.Socket !== null;
if (this.Socket) { if (this.Socket) {
this.Id = uuid(); this.Id = uuid();
this.Socket.onopen = null; this.Socket.onopen = null;
@ -125,7 +137,6 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.Socket.onclose = null; this.Socket.onclose = null;
this.Socket = null; this.Socket = null;
} }
this.IsClosed = false;
this.Socket = new WebSocket(this.Address); this.Socket = new WebSocket(this.Address);
this.Socket.onopen = () => this.#onOpen(wasReconnect); this.Socket.onopen = () => this.#onOpen(wasReconnect);
this.Socket.onmessage = e => this.#onMessage(e); this.Socket.onmessage = e => this.#onMessage(e);
@ -133,52 +144,57 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.Socket.onclose = e => this.#onClose(e); this.Socket.onclose = e => this.#onClose(e);
} }
close() { close(final = true) {
this.IsClosed = true; if (final) {
this.#closing = true;
}
this.Socket?.close(); this.Socket?.close();
} }
#onOpen(wasReconnect: boolean) { #onOpen(wasReconnect: boolean) {
this.ConnectTimeout = DefaultConnectTimeout; this.#downCount = 0;
this.#log(`Open!`); this.#log(`Open!`);
this.Down = false;
this.#setupEphemeral(); this.#setupEphemeral();
this.emit("connected", wasReconnect); this.emit("connected", wasReconnect);
this.#sendPendingRaw(); this.#sendPendingRaw();
} }
#onClose(e: WebSocket.CloseEvent) { #onClose(e: WebSocket.CloseEvent) {
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
// remote server closed the connection, dont re-connect // remote server closed the connection, dont re-connect
if (e.code === 4000) { if (!this.#closing) {
this.IsClosed = true; this.#downCount++;
this.#log(`Closed! (Remote)`); this.#reconnectTimer(e);
} else if (!this.IsClosed) {
this.ConnectTimeout = this.ConnectTimeout * this.ConnectTimeout;
this.#log(
`Closed (code=${e.code}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`,
);
this.ReconnectTimer = setTimeout(() => {
try {
this.connect();
} catch {
this.emit("disconnect", -1);
}
}, this.ConnectTimeout);
// todo: stats disconnect
} else { } else {
this.#log(`Closed!`); this.#log(`Closed!`);
this.ReconnectTimer = undefined; this.#downCount = 0;
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
} }
this.emit("disconnect", e.code); this.emit("disconnect", e.code);
this.#reset(); this.#reset();
} }
#reconnectTimer(e: WebSocket.CloseEvent) {
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
this.ConnectTimeout = this.ConnectTimeout * 2;
this.#log(
`Closed (code=${e.code}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`,
);
this.ReconnectTimer = setTimeout(() => {
try {
this.connect();
} catch {
this.emit("disconnect", -1);
}
}, this.ConnectTimeout);
}
#onMessage(e: WebSocket.MessageEvent) { #onMessage(e: WebSocket.MessageEvent) {
this.#activity = unixNowMs(); this.#activity = unixNowMs();
if ((e.data as string).length > 0) { if ((e.data as string).length > 0) {
@ -332,14 +348,13 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#expectAuth = true; this.#expectAuth = true;
this.#log("Setting expectAuth flag %o", requestKinds); this.#log("Setting expectAuth flag %o", requestKinds);
} }
if (this.ActiveRequests.size >= this.#maxSubscriptions) { if (this.#activeRequests.size >= this.#maxSubscriptions) {
this.PendingRequests.push({ this.PendingRequests.push({
obj: cmd, obj: cmd,
cb: cbSent, cb: cbSent,
}); });
this.#log("Queuing: %O", cmd); this.#log("Queuing: %O", cmd);
} else { } else {
this.ActiveRequests.add(cmd[1]);
this.#sendRequestCommand({ this.#sendRequestCommand({
obj: cmd, obj: cmd,
cb: cbSent, cb: cbSent,
@ -350,7 +365,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
} }
closeReq(id: string) { closeReq(id: string) {
if (this.ActiveRequests.delete(id)) { if (this.#activeRequests.delete(id)) {
this.send(["CLOSE", id]); this.send(["CLOSE", id]);
this.emit("eose", id); this.emit("eose", id);
this.#sendQueuedRequests(); this.#sendQueuedRequests();
@ -359,7 +374,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
} }
#sendQueuedRequests() { #sendQueuedRequests() {
const canSend = this.#maxSubscriptions - this.ActiveRequests.size; const canSend = this.#maxSubscriptions - this.#activeRequests.size;
if (canSend > 0) { if (canSend > 0) {
for (let x = 0; x < canSend; x++) { for (let x = 0; x < canSend; x++) {
const p = this.PendingRequests.shift(); const p = this.PendingRequests.shift();
@ -375,12 +390,12 @@ export class Connection extends EventEmitter<ConnectionEvents> {
try { try {
const cmd = item.obj; const cmd = item.obj;
if (cmd[0] === "REQ" || cmd[0] === "GET") { if (cmd[0] === "REQ" || cmd[0] === "GET") {
this.ActiveRequests.add(cmd[1]); this.#activeRequests.add(cmd[1]);
this.send(cmd); this.send(cmd);
} else if (cmd[0] === "SYNC") { } else if (cmd[0] === "SYNC") {
const [_, id, eventSet, ...filters] = cmd; const [_, id, eventSet, ...filters] = cmd;
const lastResortSync = () => { const lastResortSync = () => {
if (filters.some(a => a.since || a.until)) { if (filters.some(a => a.since || a.until || a.ids)) {
this.queueReq(["REQ", id, ...filters], item.cb); this.queueReq(["REQ", id, ...filters], item.cb);
} else { } else {
const latest = eventSet.reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0); const latest = eventSet.reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0);
@ -391,7 +406,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.queueReq(["REQ", id, ...newFilters], item.cb); this.queueReq(["REQ", id, ...newFilters], item.cb);
} }
}; };
if (this.Address.startsWith("wss://relay.snort.social")) { if (this.Info?.negentropy === "v1") {
const newFilters = filters; const newFilters = filters;
const neg = new NegentropyFlow(id, this, eventSet, newFilters); const neg = new NegentropyFlow(id, this, eventSet, newFilters);
neg.once("finish", filters => { neg.once("finish", filters => {
@ -419,19 +434,34 @@ export class Connection extends EventEmitter<ConnectionEvents> {
// reset connection Id on disconnect, for query-tracking // reset connection Id on disconnect, for query-tracking
this.Id = uuid(); this.Id = uuid();
this.#expectAuth = false; this.#expectAuth = false;
this.ActiveRequests.clear(); this.#log(
"Reset active=%O, pending=%O, raw=%O",
[...this.#activeRequests],
[...this.PendingRequests],
[...this.PendingRaw],
);
for (const active of this.#activeRequests) {
this.emit("closed", active, "connection closed");
}
for (const pending of this.PendingRequests) {
this.emit("closed", pending.obj[1], "connection closed");
}
for (const raw of this.PendingRaw) {
if (Array.isArray(raw) && raw[0] === "REQ") {
this.emit("closed", raw[1], "connection closed");
}
}
this.#activeRequests.clear();
this.PendingRequests = []; this.PendingRequests = [];
this.PendingRaw = []; this.PendingRaw = [];
this.emit("change"); this.emit("change");
} }
send(obj: object) { send(obj: object) {
const authPending = !this.Authed && (this.AwaitingAuth.size > 0 || this.Info?.limitation?.auth_required === true); const authPending = !this.Authed && (this.AwaitingAuth.size > 0 || this.Info?.limitation?.auth_required === true);
if (!this.Socket || this.Socket?.readyState !== WebSocket.OPEN || authPending) { if (!this.isOpen || authPending) {
this.PendingRaw.push(obj); this.PendingRaw.push(obj);
if (this.Socket?.readyState === WebSocket.CLOSED && this.Ephemeral && this.IsClosed) {
this.connect();
}
return false; return false;
} }
@ -503,11 +533,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
if (this.Ephemeral) { if (this.Ephemeral) {
this.#ephemeralCheck = setInterval(() => { this.#ephemeralCheck = setInterval(() => {
const lastActivity = unixNowMs() - this.#activity; const lastActivity = unixNowMs() - this.#activity;
if (lastActivity > 30_000 && !this.IsClosed) { if (lastActivity > 30_000 && !this.#closing) {
if (this.ActiveRequests.size > 0) { if (this.#activeRequests.size > 0) {
this.#log("Inactive connection has %d active requests! %O", this.ActiveRequests.size, this.ActiveRequests); this.#log(
"Inactive connection has %d active requests! %O",
this.#activeRequests.size,
this.#activeRequests,
);
} else { } else {
this.close(); this.close(false);
} }
} }
}, 5_000); }, 5_000);

View File

@ -1,4 +1,4 @@
import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner } from "."; import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner, NotSignedNostrEvent } from ".";
import { HashtagRegex, MentionNostrEntityRegex } from "./const"; import { HashtagRegex, MentionNostrEntityRegex } from "./const";
import { getPublicKey, jitter, unixNow } from "@snort/shared"; import { getPublicKey, jitter, unixNow } from "@snort/shared";
import { EventExt } from "./event-ext"; import { EventExt } from "./event-ext";
@ -14,6 +14,10 @@ export class EventBuilder {
#powMiner?: PowMiner; #powMiner?: PowMiner;
#jitter?: number; #jitter?: number;
get pubkey() {
return this.#pubkey;
}
/** /**
* Populate builder with values from link * Populate builder with values from link
*/ */

View File

@ -11,6 +11,7 @@ export interface Tag {
value?: string; value?: string;
relay?: string; relay?: string;
marker?: string; // NIP-10 marker?: string; // NIP-10
author?: string; // NIP-10 "pubkey-stub"
} }
export interface Thread { export interface Thread {
@ -48,9 +49,6 @@ export abstract class EventExt {
const sig = secp.schnorr.sign(e.id, key); const sig = secp.schnorr.sign(e.id, key);
e.sig = utils.bytesToHex(sig); e.sig = utils.bytesToHex(sig);
if (!secp.schnorr.verify(e.sig, e.id, e.pubkey)) {
throw new Error("Signing failed");
}
return e; return e;
} }
@ -102,10 +100,15 @@ export abstract class EventExt {
value: tag[1], value: tag[1],
} as Tag; } as Tag;
switch (ret.key) { switch (ret.key) {
case "a": case "a": {
ret.relay = tag[2];
ret.marker = tag[3];
break;
}
case "e": { case "e": {
ret.relay = tag.length > 2 ? tag[2] : undefined; ret.relay = tag[2];
ret.marker = tag.length > 3 ? tag[3] : undefined; ret.marker = tag[3];
ret.author = tag[4];
break; break;
} }
} }

View File

@ -15,6 +15,7 @@ import {
PowMiner, PowMiner,
PrivateKeySigner, PrivateKeySigner,
RelaySettings, RelaySettings,
settingsToRelayTag,
SignerSupports, SignerSupports,
TaggedNostrEvent, TaggedNostrEvent,
ToNostrEventTag, ToNostrEventTag,
@ -23,10 +24,10 @@ import {
} from "."; } from ".";
import { EventBuilder } from "./event-builder"; import { EventBuilder } from "./event-builder";
import { EventExt } from "./event-ext";
import { findTag } from "./utils"; import { findTag } from "./utils";
import { Nip7Signer } from "./impl/nip7"; import { Nip7Signer } from "./impl/nip7";
import { base64 } from "@scure/base"; import { base64 } from "@scure/base";
import { Nip10 } from "./impl/nip10";
type EventBuilderHook = (ev: EventBuilder) => EventBuilder; type EventBuilderHook = (ev: EventBuilder) => EventBuilder;
@ -202,29 +203,7 @@ export class EventPublisher {
const eb = this.#eb(EventKind.TextNote); const eb = this.#eb(EventKind.TextNote);
eb.content(msg); eb.content(msg);
const link = NostrLink.fromEvent(replyTo); Nip10.replyTo(replyTo, eb);
const thread = EventExt.extractThread(replyTo);
if (thread) {
const rootOrReplyAsRoot = thread.root || thread.replyTo;
if (rootOrReplyAsRoot) {
eb.tag([rootOrReplyAsRoot.key, rootOrReplyAsRoot.value ?? "", rootOrReplyAsRoot.relay ?? "", "root"]);
}
eb.tag([...unwrap(link.toEventTag()), "reply"]);
eb.tag(["p", replyTo.pubkey]);
for (const pk of thread.pubKeys) {
if (pk === this.#pubKey) {
continue;
}
eb.tag(["p", pk]);
}
} else {
eb.tag([...unwrap(link.toEventTag()), "root"]);
// dont tag self in replies
if (replyTo.pubkey !== this.#pubKey) {
eb.tag(["p", replyTo.pubkey]);
}
}
eb.processContent(); eb.processContent();
fnExtra?.(eb); fnExtra?.(eb);
return await this.#sign(eb); return await this.#sign(eb);
@ -247,15 +226,9 @@ export class EventPublisher {
} }
const eb = this.#eb(EventKind.Relays); const eb = this.#eb(EventKind.Relays);
for (const rx of relays) { for (const rx of relays) {
const rTag = ["r", rx.url]; const tag = settingsToRelayTag(rx);
if (rx.settings.read && !rx.settings.write) { if (tag) {
rTag.push("read"); eb.tag(tag);
}
if (rx.settings.write && !rx.settings.read) {
rTag.push("write");
}
if (rx.settings.read || rx.settings.write) {
eb.tag(rTag);
} }
} }
return await this.#sign(eb); return await this.#sign(eb);

View File

@ -0,0 +1,73 @@
import { dedupe, unwrap } from "@snort/shared";
import { EventBuilder } from "../event-builder";
import { NostrEvent } from "../nostr";
import { NostrLink } from "../nostr-link";
export interface Nip10Thread {
root?: NostrLink;
replyTo?: NostrLink;
mentions: Array<NostrLink>;
pubKeys: Array<NostrLink>;
}
/**
* Utility class which exports functions used in NIP-10
*/
export class Nip10 {
/**
* Reply to an event using NIP-10 tagging
*/
static replyTo(ev: NostrEvent, eb: EventBuilder) {
const link = NostrLink.fromEvent(ev);
const thread = Nip10.parseThread(ev);
if (thread) {
const rootOrReplyAsRoot = thread.root || thread.replyTo;
if (rootOrReplyAsRoot) {
eb.tag(unwrap(rootOrReplyAsRoot.toEventTag("root")));
}
eb.tag(unwrap(link.toEventTag("reply")));
for (const pk of thread.pubKeys) {
if (pk.id === eb.pubkey) {
continue;
}
eb.tag(unwrap(pk.toEventTag()));
}
} else {
eb.tag(unwrap(link.toEventTag("root")));
if (ev.pubkey !== eb.pubkey) {
eb.tag(["p", ev.pubkey]);
}
}
}
static parseThread(ev: NostrEvent) {
const ret = {
mentions: [],
pubKeys: [],
} as Nip10Thread;
const replyTags = ev.tags.filter(a => a[0] === "e" || a[0] === "a").map(a => NostrLink.fromTag(a));
if (replyTags.length > 0) {
const marked = replyTags.some(a => a.marker);
if (!marked) {
ret.root = replyTags[0];
if (replyTags.length > 1) {
ret.replyTo = replyTags[replyTags.length - 1];
}
if (replyTags.length > 2) {
ret.mentions = replyTags.slice(1, -1);
}
} else {
const root = replyTags.find(a => a.marker === "root");
const reply = replyTags.find(a => a.marker === "reply");
ret.root = root;
ret.replyTo = reply;
ret.mentions = replyTags.filter(a => a.marker === "mention");
}
} else {
return undefined;
}
ret.pubKeys = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1])).map(a => NostrLink.publicKey(a));
return ret;
}
}

View File

@ -1,10 +1,10 @@
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
import { NostrEvent } from "./nostr";
import { findTag } from "./utils";
import { EventExt } from "./event-ext";
import { NostrLink } from "./nostr-link";
import debug from "debug"; import debug from "debug";
import { LRUCache } from "lru-cache"; import { LRUCache } from "lru-cache";
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
import { NostrEvent } from "../nostr";
import { findTag } from "../utils";
import { NostrLink } from "../nostr-link";
import { Nip10 } from "./nip10";
const Log = debug("zaps"); const Log = debug("zaps");
const ParsedZapCache = new LRUCache<string, ParsedZap>({ max: 1000 }); const ParsedZapCache = new LRUCache<string, ParsedZap>({ max: 1000 });
@ -35,7 +35,7 @@ export function parseZap(zapReceipt: NostrEvent): ParsedZap {
// old format, ignored // old format, ignored
throw new Error("deprecated zap format"); throw new Error("deprecated zap format");
} }
const zapRequestThread = EventExt.extractThread(zapRequest); const zapRequestThread = Nip10.parseThread(zapRequest);
const requestContext = zapRequestThread?.root; const requestContext = zapRequestThread?.root;
const anonZap = zapRequest.tags.find(a => a[0] === "anon"); const anonZap = zapRequest.tags.find(a => a[0] === "anon");
@ -44,7 +44,7 @@ export function parseZap(zapReceipt: NostrEvent): ParsedZap {
id: zapReceipt.id, id: zapReceipt.id,
zapService: zapReceipt.pubkey, zapService: zapReceipt.pubkey,
amount: (invoice?.amount ?? 0) / 1000, amount: (invoice?.amount ?? 0) / 1000,
event: requestContext ? NostrLink.fromThreadTag(requestContext) : undefined, event: requestContext,
sender: zapRequest.pubkey, sender: zapRequest.pubkey,
receiver: findTag(zapRequest, "p"), receiver: findTag(zapRequest, "p"),
valid: true, valid: true,

View File

@ -30,7 +30,7 @@ export * from "./event-publisher";
export * from "./event-builder"; export * from "./event-builder";
export * from "./nostr-link"; export * from "./nostr-link";
export * from "./profile-cache"; export * from "./profile-cache";
export * from "./zaps"; export * from "./impl/nip57";
export * from "./signer"; export * from "./signer";
export * from "./text"; export * from "./text";
export * from "./pow"; export * from "./pow";
@ -39,11 +39,14 @@ export * from "./query-optimizer";
export * from "./encrypted"; export * from "./encrypted";
export * from "./outbox"; export * from "./outbox";
export * from "./sync"; export * from "./sync";
export * from "./user-state";
export * from "./impl/nip4"; export * from "./impl/nip4";
export * from "./impl/nip44";
export * from "./impl/nip7"; export * from "./impl/nip7";
export * from "./impl/nip10";
export * from "./impl/nip44";
export * from "./impl/nip46"; export * from "./impl/nip46";
export * from "./impl/nip57";
export * from "./cache/index"; export * from "./cache/index";
export * from "./cache/user-relays"; export * from "./cache/user-relays";

View File

@ -19,7 +19,7 @@ export interface ToNostrEventTag {
export class NostrHashtagLink implements ToNostrEventTag { export class NostrHashtagLink implements ToNostrEventTag {
constructor(readonly tag: string) {} constructor(readonly tag: string) {}
toEventTag(): string[] | undefined { toEventTag() {
return ["t", this.tag]; return ["t", this.tag];
} }
} }
@ -31,6 +31,7 @@ export class NostrLink implements ToNostrEventTag {
readonly kind?: number, readonly kind?: number,
readonly author?: string, readonly author?: string,
readonly relays?: Array<string>, readonly relays?: Array<string>,
readonly marker?: string,
) { ) {
if (type !== NostrPrefix.Address && !isHex(id)) { if (type !== NostrPrefix.Address && !isHex(id)) {
throw new Error("ID must be hex"); throw new Error("ID must be hex");
@ -52,22 +53,43 @@ export class NostrLink implements ToNostrEventTag {
} }
} }
toEventTag(marker?: string) { get tagKey() {
const relayEntry = this.relays?.at(0) ? [this.relays[0]] : []; if (this.type === NostrPrefix.Address) {
return `${this.kind}:${this.author}:${this.id}`;
}
return this.id;
}
/**
* Create an event tag for this link
*/
toEventTag(marker?: string) {
const suffix: Array<string> = [];
if (this.relays && this.relays.length > 0) {
suffix.push(this.relays[0]);
}
if (marker) { if (marker) {
if (relayEntry.length === 0) { if (suffix[0] === undefined) {
relayEntry.push(""); suffix.push(""); // empty relay hint
} }
relayEntry.push(marker); suffix.push(marker);
} }
if (this.type === NostrPrefix.PublicKey || this.type === NostrPrefix.Profile) { if (this.type === NostrPrefix.PublicKey || this.type === NostrPrefix.Profile) {
return ["p", this.id, ...relayEntry]; return ["p", this.id, ...suffix];
} else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) { } else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) {
return ["e", this.id, ...relayEntry]; if (this.author) {
if (suffix[0] === undefined) {
suffix.push(""); // empty relay hint
}
if (suffix[1] === undefined) {
suffix.push(""); // empty marker
}
suffix.push(this.author);
}
return ["e", this.id, ...suffix];
} else if (this.type === NostrPrefix.Address) { } else if (this.type === NostrPrefix.Address) {
return ["a", `${this.kind}:${this.author}:${this.id}`, ...relayEntry]; return ["a", `${this.kind}:${this.author}:${this.id}`, ...suffix];
} }
} }
@ -162,46 +184,38 @@ export class NostrLink implements ToNostrEventTag {
} }
} }
static fromThreadTag(tag: Tag) { static fromTag<T = NostrLink>(
const relay = tag.relay ? [tag.relay] : undefined; tag: Array<string>,
author?: string,
switch (tag.key) { kind?: number,
case "e": { fnOther?: (tag: Array<string>) => T,
return new NostrLink(NostrPrefix.Event, unwrap(tag.value), undefined, undefined, relay); ) {
}
case "p": {
return new NostrLink(NostrPrefix.Profile, unwrap(tag.value), undefined, undefined, relay);
}
case "a": {
const [kind, author, dTag] = unwrap(tag.value).split(":");
return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relay);
}
}
throw new Error(`Unknown tag kind ${tag.key}`);
}
static fromTag(tag: Array<string>, author?: string, kind?: number) {
const relays = tag.length > 2 ? [tag[2]] : undefined; const relays = tag.length > 2 ? [tag[2]] : undefined;
switch (tag[0]) { switch (tag[0]) {
case "e": { case "e": {
return new NostrLink(NostrPrefix.Event, tag[1], kind, author, relays); return new NostrLink(NostrPrefix.Event, tag[1], kind, author ?? tag[4], relays, tag[3]);
} }
case "p": { case "p": {
return new NostrLink(NostrPrefix.Profile, tag[1], kind, author, relays); return new NostrLink(NostrPrefix.Profile, tag[1], kind, author, relays);
} }
case "a": { case "a": {
const [kind, author, dTag] = tag[1].split(":"); const [kind, author, dTag] = tag[1].split(":");
return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays); return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays, tag[3]);
}
default: {
if (fnOther) {
return fnOther(tag);
}
} }
} }
throw new Error(`Unknown tag kind ${tag[0]}`); throw new Error(`Unknown tag kind ${tag[0]}`);
} }
static fromTags(tags: Array<Array<string>>) { static fromTags<T = NostrLink>(tags: ReadonlyArray<Array<string>>, fnOther?: (tag: Array<string>) => T) {
return removeUndefined( return removeUndefined(
tags.map(a => { tags.map(a => {
try { try {
return NostrLink.fromTag(a); return NostrLink.fromTag<T>(a, undefined, undefined, fnOther);
} catch { } catch {
// ignored, cant be mapped // ignored, cant be mapped
} }
@ -218,6 +232,14 @@ export class NostrLink implements ToNostrEventTag {
} }
return new NostrLink(NostrPrefix.Event, ev.id, ev.kind, ev.pubkey, relays); return new NostrLink(NostrPrefix.Event, ev.id, ev.kind, ev.pubkey, relays);
} }
static profile(pk: string, relays?: Array<string>) {
return new NostrLink(NostrPrefix.Profile, pk, undefined, undefined, relays);
}
static publicKey(pk: string, relays?: Array<string>) {
return new NostrLink(NostrPrefix.PublicKey, pk, undefined, undefined, relays);
}
} }
export function validateNostrLink(link: string): boolean { export function validateNostrLink(link: string): boolean {

View File

@ -70,8 +70,7 @@ export class KeyedReplaceableNoteStore extends HookedNoteStore {
ev.forEach(a => { ev.forEach(a => {
const keyOnEvent = this.#keyFn(a); const keyOnEvent = this.#keyFn(a);
const existing = this.#events.get(keyOnEvent); const existing = this.#events.get(keyOnEvent);
const existingCreated = existing?.created_at ?? 0; if (a.created_at > (existing?.created_at ?? 0)) {
if (a.created_at > existingCreated) {
if (existing) { if (existing) {
a.relays = dedupe([...existing.relays, ...a.relays]); a.relays = dedupe([...existing.relays, ...a.relays]);
} }

View File

@ -1,5 +1,5 @@
import { EventKind, FullRelaySettings, NostrEvent, SystemInterface, UsersRelays } from ".."; import { EventKind, FullRelaySettings, NostrEvent, SystemInterface, UsersRelays } from "..";
import { sanitizeRelayUrl } from "@snort/shared"; import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
export const DefaultPickNRelays = 2; export const DefaultPickNRelays = 2;
@ -20,6 +20,7 @@ export type EventFetcher = {
}; };
export function parseRelayTag(tag: Array<string>) { export function parseRelayTag(tag: Array<string>) {
if (tag[0] !== "r") return;
return { return {
url: sanitizeRelayUrl(tag[1]), url: sanitizeRelayUrl(tag[1]),
settings: { settings: {
@ -30,7 +31,7 @@ export function parseRelayTag(tag: Array<string>) {
} }
export function parseRelayTags(tag: Array<Array<string>>) { export function parseRelayTags(tag: Array<Array<string>>) {
return tag.map(parseRelayTag).filter(a => a !== null); return removeUndefined(tag.map(parseRelayTag));
} }
export function parseRelaysFromKind(ev: NostrEvent) { export function parseRelaysFromKind(ev: NostrEvent) {
@ -54,5 +55,21 @@ export function parseRelaysFromKind(ev: NostrEvent) {
} }
} }
/**
* Convert relay settings into NIP-65 relay tag
*/
export function settingsToRelayTag(rx: FullRelaySettings) {
const rTag = ["r", rx.url];
if (rx.settings.read && !rx.settings.write) {
rTag.push("read");
}
if (rx.settings.write && !rx.settings.read) {
rTag.push("write");
}
if (rx.settings.read || rx.settings.write) {
return rTag;
}
}
export * from "./outbox-model"; export * from "./outbox-model";
export * from "./relay-loader"; export * from "./relay-loader";

View File

@ -182,6 +182,16 @@ export class OutboxModel extends BaseRequestRouter {
return ret; return ret;
} }
async forReplyTo(pk: string, pickN?: number | undefined): Promise<string[]> {
const recipients = [pk];
await this.updateRelayLists(recipients);
const relays = this.pickTopRelays(recipients, pickN ?? DefaultPickNRelays, "read");
const ret = removeUndefined(dedupe(relays.map(a => a.relays).flat()));
this.#log("Picked: pattern=%s, input=%s, output=%O", "inbox", pk, ret);
return ret;
}
/** /**
* Update relay cache with latest relay lists * Update relay cache with latest relay lists
* @param authors The authors to update relay lists for * @param authors The authors to update relay lists for

View File

@ -4,6 +4,7 @@ import { BuiltRawReqFilter, RequestBuilder, SystemInterface, TaggedNostrEvent }
import { Query, TraceReport } from "./query"; import { Query, TraceReport } from "./query";
import { FilterCacheLayer } from "./filter-cache-layer"; import { FilterCacheLayer } from "./filter-cache-layer";
import { trimFilters } from "./request-trim"; import { trimFilters } from "./request-trim";
import { eventMatchesFilter } from "./request-matcher";
interface QueryManagerEvents { interface QueryManagerEvents {
change: () => void; change: () => void;
@ -75,26 +76,19 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
* Async fetch results * Async fetch results
*/ */
async fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void) { async fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void) {
const q = new Query(this.#system, req); const filters = req.buildRaw();
q.on("trace", r => this.emit("trace", r)); const q = this.query(req);
q.on("request", (subId, fx) => {
this.#send(q, fx);
});
if (cb) { if (cb) {
q.on("event", evs => cb(evs)); q.on("event", cb);
} }
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
q.on("loading", loading => { q.once("done", resolve);
this.#log("loading %s %o", q.id, loading);
if (!loading) {
resolve();
}
});
}); });
const results = q.feed.takeSnapshot(); const results = q.feed.takeSnapshot();
q.cleanup(); if (cb) {
this.#log("Fetch results for %s %o", q.id, results); q.off("event", cb);
return results; }
return results.filter(a => filters.some(b => eventMatchesFilter(a, b)));
} }
*[Symbol.iterator]() { *[Symbol.iterator]() {
@ -112,6 +106,7 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const data = await this.#system.cacheRelay.query(["REQ", q.id, ...qSend.filters]); const data = await this.#system.cacheRelay.query(["REQ", q.id, ...qSend.filters]);
if (data.length > 0) { if (data.length > 0) {
qSend.syncFrom = data as Array<TaggedNostrEvent>; qSend.syncFrom = data as Array<TaggedNostrEvent>;
this.#log("Adding from cache: %O", data);
q.feed.add(data as Array<TaggedNostrEvent>); q.feed.add(data as Array<TaggedNostrEvent>);
} }
} }
@ -138,6 +133,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(s, qSend); const qt = q.sendToRelay(s, qSend);
if (qt) { if (qt) {
return [qt]; return [qt];
} else {
this.#log("Query not sent to %s: %O", qSend.relay, qSend);
} }
} else { } else {
const nc = await this.#system.pool.connect(qSend.relay, { read: true, write: true }, true); const nc = await this.#system.pool.connect(qSend.relay, { read: true, write: true }, true);
@ -145,6 +142,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(nc, qSend); const qt = q.sendToRelay(nc, qSend);
if (qt) { if (qt) {
return [qt]; return [qt];
} else {
this.#log("Query not sent to %s: %O", qSend.relay, qSend);
} }
} else { } else {
console.warn("Failed to connect to new relay for:", qSend.relay, q); console.warn("Failed to connect to new relay for:", qSend.relay, q);
@ -158,6 +157,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(s, qSend); const qt = q.sendToRelay(s, qSend);
if (qt) { if (qt) {
ret.push(qt); ret.push(qt);
} else {
this.#log("Query not sent to %s: %O", a, qSend);
} }
} }
} }

View File

@ -99,11 +99,11 @@ export interface TraceReport {
} }
export interface QueryEvents { export interface QueryEvents {
loading: (v: boolean) => void;
trace: (report: TraceReport) => void; trace: (report: TraceReport) => void;
request: (subId: string, req: BuiltRawReqFilter) => void; request: (subId: string, req: BuiltRawReqFilter) => void;
event: (evs: Array<TaggedNostrEvent>) => void; event: (evs: Array<TaggedNostrEvent>) => void;
end: () => void; end: () => void;
done: () => void;
} }
const QueryCache = new LRUCache<string, Array<TaggedNostrEvent>>({ const QueryCache = new LRUCache<string, Array<TaggedNostrEvent>>({
@ -357,7 +357,7 @@ export class Query extends EventEmitter<QueryEvents> {
const isFinished = this.progress === 1; const isFinished = this.progress === 1;
if (isFinished) { if (isFinished) {
this.#log("%s loading=%s, progress=%d, traces=%O", this.id, !isFinished, this.progress, this.#tracing); this.#log("%s loading=%s, progress=%d, traces=%O", this.id, !isFinished, this.progress, this.#tracing);
this.emit("loading", !isFinished); this.emit("done");
} }
} }
@ -384,6 +384,10 @@ export class Query extends EventEmitter<QueryEvents> {
if (q.relay && q.relay !== c.Address) { if (q.relay && q.relay !== c.Address) {
return false; return false;
} }
// connection is down, dont send
if (c.isDown) {
return false;
}
// cannot send unless relay is tagged on ephemeral relay connection // cannot send unless relay is tagged on ephemeral relay connection
if (!q.relay && c.Ephemeral) { if (!q.relay && c.Ephemeral) {
this.#log("Cant send non-specific REQ to ephemeral connection %O %O %O", q, q.relay, c); this.#log("Cant send non-specific REQ to ephemeral connection %O %O %O", q, q.relay, c);

View File

@ -18,4 +18,5 @@ export interface RelayInfo {
language_tags?: Array<string>; language_tags?: Array<string>;
tags?: Array<string>; tags?: Array<string>;
posting_policy?: string; posting_policy?: string;
negentropy?: string;
} }

View File

@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid";
import { appendDedupe, dedupe, removeUndefined, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared"; import { appendDedupe, dedupe, removeUndefined, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
import EventKind from "./event-kind"; import EventKind from "./event-kind";
import { FlatReqFilter, NostrLink, NostrPrefix, SystemInterface } from "."; import { FlatReqFilter, NostrLink, NostrPrefix, SystemInterface, ToNostrEventTag } from ".";
import { ReqFilter, u256, HexKey, TaggedNostrEvent } from "./nostr"; import { ReqFilter, u256, HexKey, TaggedNostrEvent } from "./nostr";
import { RequestRouter } from "./request-router"; import { RequestRouter } from "./request-router";
@ -230,6 +230,19 @@ export class RequestFilterBuilder {
return this; return this;
} }
/**
* Query by a nostr tag
*/
tags(tags: Array<ToNostrEventTag>) {
for (const tag of tags) {
const tt = tag.toEventTag();
if (tt) {
this.tag(tt[0], [tt[1]]);
}
}
return this;
}
search(keyword?: string) { search(keyword?: string) {
if (!keyword) return this; if (!keyword) return this;
this.#filter.search = keyword; this.#filter.search = keyword;

View File

@ -6,6 +6,15 @@ import { FlatReqFilter } from "./query-optimizer";
* Request router managed splitting of requests to one or more relays, and which relay to send events to. * Request router managed splitting of requests to one or more relays, and which relay to send events to.
*/ */
export interface RequestRouter { export interface RequestRouter {
/**
* Pick relays to send an event to
* @param pk The pubkey you are replying to
* @param system Nostr system interface
* @param pickN Number of relays to pick per recipient
* @returns
*/
forReplyTo(pk: string, pickN?: number): Promise<Array<string>>;
/** /**
* Pick relays to send an event to * Pick relays to send an event to
* @param ev The reply event to send * @param ev The reply event to send
@ -39,6 +48,7 @@ export interface RequestRouter {
} }
export abstract class BaseRequestRouter implements RequestRouter { export abstract class BaseRequestRouter implements RequestRouter {
abstract forReplyTo(pk: string, pickN?: number): Promise<Array<string>>;
abstract forReply(ev: NostrEvent, pickN?: number): Promise<Array<string>>; abstract forReply(ev: NostrEvent, pickN?: number): Promise<Array<string>>;
abstract forRequest(filter: ReqFilter, pickN?: number): Array<ReqFilter>; abstract forRequest(filter: ReqFilter, pickN?: number): Array<ReqFilter>;
abstract forFlatRequest(filter: FlatReqFilter[], pickN?: number): Array<FlatReqFilter>; abstract forFlatRequest(filter: FlatReqFilter[], pickN?: number): Array<FlatReqFilter>;

View File

@ -4,7 +4,7 @@ import { EventExt } from "./event-ext";
import { Nip4WebCryptoEncryptor } from "./impl/nip4"; import { Nip4WebCryptoEncryptor } from "./impl/nip4";
import { XChaCha20Encryptor } from "./impl/nip44"; import { XChaCha20Encryptor } from "./impl/nip44";
import { MessageEncryptorVersion, decodeEncryptionPayload, encodeEncryptionPayload } from "./index"; import { MessageEncryptorVersion, decodeEncryptionPayload, encodeEncryptionPayload } from "./index";
import { NostrEvent } from "./nostr"; import { NostrEvent, NotSignedNostrEvent } from "./nostr";
import { base64 } from "@scure/base"; import { base64 } from "@scure/base";
export type SignerSupports = "nip04" | "nip44" | string; export type SignerSupports = "nip04" | "nip44" | string;
@ -16,7 +16,7 @@ export interface EventSigner {
nip4Decrypt(content: string, otherKey: string): Promise<string>; nip4Decrypt(content: string, otherKey: string): Promise<string>;
nip44Encrypt(content: string, key: string): Promise<string>; nip44Encrypt(content: string, key: string): Promise<string>;
nip44Decrypt(content: string, otherKey: string): Promise<string>; nip44Decrypt(content: string, otherKey: string): Promise<string>;
sign(ev: NostrEvent): Promise<NostrEvent>; sign(ev: NostrEvent | NotSignedNostrEvent): Promise<NostrEvent>;
get supports(): Array<SignerSupports>; get supports(): Array<SignerSupports>;
} }

View File

@ -1,100 +1,210 @@
import { EventBuilder, EventSigner, NostrLink, SystemInterface } from ".."; import { EventEmitter } from "eventemitter3";
import { SafeSync } from "./safe-sync"; import { EventBuilder, EventSigner, NostrEvent, NostrLink, NotSignedNostrEvent, SystemInterface, Tag } from "..";
import { SafeSync, SafeSyncEvents } from "./safe-sync";
import debug from "debug"; import debug from "debug";
interface TagDiff { interface TagDiff {
type: "add" | "remove" | "replace"; type: "add" | "remove" | "replace" | "update";
tag: Array<string> | Array<Array<string>>; tag: Array<string> | Array<Array<string>>;
} }
/** /**
* Add/Remove tags from event * Add/Remove tags from event
*/ */
export class DiffSyncTags { export class DiffSyncTags extends EventEmitter<SafeSyncEvents> {
#log = debug("DiffSyncTags"); #log = debug("DiffSyncTags");
#sync = new SafeSync(); #sync: SafeSync;
#changes: Array<TagDiff> = []; #changes: Array<TagDiff> = [];
#changesEncrypted: Array<TagDiff> = [];
#decryptedContent?: string;
constructor(readonly link: NostrLink) {} constructor(readonly link: NostrLink) {
super();
this.#sync = new SafeSync(link);
this.#sync.on("change", () => {
this.emit("change");
});
}
/**
* Get the raw storage event
*/
get value() {
return this.#sync.value;
}
/**
* Get the current tag set
*/
get tags() {
const next = this.#nextEvent();
return next.tags;
}
/**
* Get decrypted content
*/
get encryptedTags() {
if (this.#decryptedContent) {
const tags = JSON.parse(this.#decryptedContent) as Array<Array<string>>;
return tags;
}
return [];
}
/** /**
* Add a tag * Add a tag
*/ */
add(tag: Array<string> | Array<Array<string>>) { add(tag: Array<string> | Array<Array<string>>, encrypted = false) {
this.#changes.push({ (encrypted ? this.#changesEncrypted : this.#changes).push({
type: "add", type: "add",
tag, tag,
}); });
this.emit("change");
} }
/** /**
* Remove a tag * Remove a tag
*/ */
remove(tag: Array<string> | Array<Array<string>>) { remove(tag: Array<string> | Array<Array<string>>, encrypted = false) {
this.#changes.push({ (encrypted ? this.#changesEncrypted : this.#changes).push({
type: "remove", type: "remove",
tag, tag,
}); });
this.emit("change");
}
/**
* Update a tag (remove+add)
*/
update(tag: Array<string> | Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "update",
tag,
});
this.emit("change");
} }
/** /**
* Replace all the tags * Replace all the tags
*/ */
replace(tag: Array<Array<string>>) { replace(tag: Array<Array<string>>, encrypted = false) {
this.#changes.push({ (encrypted ? this.#changesEncrypted : this.#changes).push({
type: "replace", type: "replace",
tag, tag,
}); });
this.emit("change");
}
async sync(signer: EventSigner, system: SystemInterface) {
await this.#sync.sync(system);
if (
this.#sync.value?.content &&
this.#sync.value?.content.startsWith("[") &&
this.#sync.value?.content.endsWith("]")
) {
const decrypted = await signer.nip4Decrypt(this.#sync.value.content, await signer.getPubKey());
this.#decryptedContent = decrypted;
}
} }
/** /**
* Apply changes and save * Apply changes and save
*/ */
async persist(signer: EventSigner, system: SystemInterface, content?: string) { async persist(signer: EventSigner, system: SystemInterface, content?: string) {
const cloneChanges = [...this.#changes]; if (!this.#sync.didSync) {
this.#changes = []; await this.sync(signer, system);
}
// always start with sync const isNew = this.#sync.value === undefined;
const res = await this.#sync.sync(this.link, system); const next = this.#nextEvent(content);
// content is populated as tags, encrypt it
if (next.content.length > 0 && !content) {
next.content = await signer.nip4Encrypt(next.content, await signer.getPubKey());
}
await this.#sync.update(next, signer, system, !isNew);
}
#nextEvent(content?: string): NotSignedNostrEvent {
if (content !== undefined && this.#changesEncrypted.length > 0) {
throw new Error("Cannot have both encrypted tags and explicit content");
}
let isNew = false; let isNew = false;
let next = res ? { ...res } : undefined; let next = this.#sync.value ? { ...this.#sync.value } : undefined;
if (!next) { if (!next) {
const eb = new EventBuilder(); const eb = new EventBuilder();
eb.fromLink(this.link); eb.fromLink(this.link);
next = eb.build(); next = eb.build();
isNew = true; isNew = true;
} }
if (content) {
// apply changes onto next
this.#applyChanges(next.tags, this.#changes);
if (this.#changesEncrypted.length > 0 && !content) {
const encryptedTags = isNew ? [] : this.encryptedTags;
this.#applyChanges(encryptedTags, this.#changesEncrypted);
next.content = JSON.stringify(encryptedTags);
} else if (content) {
next.content = content; next.content = content;
} }
// apply changes onto next return next;
for (const change of cloneChanges) { }
for (const changeTag of Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>) #applyChanges(tags: Array<Array<string>>, changes: Array<TagDiff>) {
: [change.tag as Array<string>]) { for (const change of changes) {
const existing = next.tags.findIndex(a => a.every((b, i) => changeTag[i] === b)); if (change.tag.length === 0 && change.type !== "replace") continue;
switch (change.type) {
case "add": { switch (change.type) {
case "add": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing === -1) { if (existing === -1) {
next.tags.push(changeTag); tags.push(changeTag);
} else { } else {
this.#log("Tag already exists: %O", changeTag); this.#log("Tag already exists: %O", changeTag);
} }
break;
} }
case "remove": { break;
}
case "remove": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing !== -1) { if (existing !== -1) {
next.tags.splice(existing, 1); tags.splice(existing, 1);
} else { } else {
this.#log("Could not find tag to remove: %O", changeTag); this.#log("Could not find tag to remove: %O", changeTag);
} }
} }
break;
}
case "update": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing !== -1) {
tags[existing] = changeTag;
} else {
this.#log("Could not find tag to update: %O", changeTag);
}
}
break;
}
case "replace": {
tags.splice(0, tags.length);
tags.push(...(change.tag as Array<Array<string>>));
break;
} }
} }
} }
await this.#sync.update(next, signer, system, !isNew);
} }
} }

View File

@ -1,7 +1,3 @@
export interface HasId {
id: string;
}
export * from "./safe-sync"; export * from "./safe-sync";
export * from "./range-sync"; export * from "./range-sync";
export * from "./json-in-event-sync"; export * from "./json-in-event-sync";

View File

@ -1,14 +1,9 @@
import { SafeSync } from "./safe-sync"; import { SafeSync, SafeSyncEvents } from "./safe-sync";
import { HasId } from "."; import { EventBuilder, EventSigner, NostrEvent, NostrLink, SystemInterface } from "..";
import { EventBuilder, EventSigner, NostrEvent, NostrLink, NostrPrefix, SystemInterface } from "..";
import debug from "debug"; import debug from "debug";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
export interface JsonSyncEvents { export class JsonEventSync<T> extends EventEmitter<SafeSyncEvents> {
change: () => void;
}
export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents> {
#log = debug("JsonEventSync"); #log = debug("JsonEventSync");
#sync: SafeSync; #sync: SafeSync;
#json: T; #json: T;
@ -19,7 +14,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
readonly encrypt: boolean, readonly encrypt: boolean,
) { ) {
super(); super();
this.#sync = new SafeSync(); this.#sync = new SafeSync(link);
this.#json = initValue; this.#json = initValue;
this.#sync.on("change", () => this.emit("change")); this.#sync.on("change", () => this.emit("change"));
@ -31,7 +26,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
} }
async sync(signer: EventSigner, system: SystemInterface) { async sync(signer: EventSigner, system: SystemInterface) {
const res = await this.#sync.sync(this.link, system); const res = await this.#sync.sync(system);
this.#log("Sync result %O", res); this.#log("Sync result %O", res);
if (res) { if (res) {
if (this.encrypt) { if (this.encrypt) {
@ -71,6 +66,5 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
await this.#sync.update(next, signer, system, !isNew); await this.#sync.update(next, signer, system, !isNew);
this.#json = val; this.#json = val;
this.#json.id = next.id;
} }
} }

View File

@ -1,5 +1,14 @@
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { EventExt, EventSigner, EventType, NostrEvent, NostrLink, RequestBuilder, SystemInterface } from ".."; import {
EventExt,
EventSigner,
EventType,
NostrEvent,
NostrLink,
NotSignedNostrEvent,
RequestBuilder,
SystemInterface,
} from "..";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import debug from "debug"; import debug from "debug";
@ -21,6 +30,10 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
#base: NostrEvent | undefined; #base: NostrEvent | undefined;
#didSync = false; #didSync = false;
constructor(readonly link: NostrLink) {
super();
}
get value() { get value() {
return this.#base ? Object.freeze({ ...this.#base }) : undefined; return this.#base ? Object.freeze({ ...this.#base }) : undefined;
} }
@ -31,14 +44,13 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
/** /**
* Fetch the latest version * Fetch the latest version
* @param link A link to the kind
*/ */
async sync(link: NostrLink, system: SystemInterface) { async sync(system: SystemInterface) {
if (link.kind === undefined || link.author === undefined) { if (this.link.kind === undefined || this.link.author === undefined) {
throw new Error("Kind must be set"); throw new Error("Kind must be set");
} }
return await this.#sync(link, system); return await this.#sync(system);
} }
/** /**
@ -57,15 +69,23 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
* Event will be signed again inside * Event will be signed again inside
* @param ev * @param ev
*/ */
async update(next: NostrEvent, signer: EventSigner, system: SystemInterface, mustExist?: boolean) { async update(
next.id = ""; next: NostrEvent | NotSignedNostrEvent,
next.sig = ""; signer: EventSigner,
system: SystemInterface,
mustExist?: boolean,
) {
if ("sig" in next) {
next.id = "";
next.sig = "";
}
console.debug(this.#base, next); console.debug(this.#base, next);
const signed = await this.#signEvent(next, signer); const signed = await this.#signEvent(next, signer);
const link = NostrLink.fromEvent(signed);
// always attempt to get a newer version before broadcasting // always attempt to get a newer version before broadcasting
await this.#sync(link, system); await this.#sync(system);
this.#checkForUpdate(signed, mustExist ?? true); this.#checkForUpdate(signed, mustExist ?? true);
system.BroadcastEvent(signed); system.BroadcastEvent(signed);
@ -73,28 +93,21 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
this.emit("change"); this.emit("change");
} }
async #signEvent(next: NostrEvent, signer: EventSigner) { async #signEvent(next: NotSignedNostrEvent, signer: EventSigner) {
next.created_at = unixNow(); const toSign = { ...next, id: "", sig: "" } as NostrEvent;
if (this.#base) { toSign.created_at = unixNow();
const prevTag = next.tags.find(a => a[0] === "previous"); toSign.id = EventExt.createId(toSign);
if (prevTag) { return await signer.sign(toSign);
prevTag[1] = this.#base.id;
} else {
next.tags.push(["previous", this.#base.id]);
}
}
next.id = EventExt.createId(next);
return await signer.sign(next);
} }
async #sync(link: NostrLink, system: SystemInterface) { async #sync(system: SystemInterface) {
const rb = new RequestBuilder(`sync:${link.encode()}`); const rb = new RequestBuilder("sync");
const f = rb.withFilter().link(link); const f = rb.withFilter().link(this.link);
if (this.#base) { if (this.#base) {
f.since(this.#base.created_at); f.since(this.#base.created_at);
} }
const results = await system.Fetch(rb); const results = await system.Fetch(rb);
const res = results.find(a => link.matchesEvent(a)); const res = results.find(a => this.link.matchesEvent(a));
this.#log("Got result %O", res); this.#log("Got result %O", res);
if (res && res.created_at > (this.#base?.created_at ?? 0)) { if (res && res.created_at > (this.#base?.created_at ?? 0)) {
this.#base = res; this.#base = res;

Some files were not shown because too many files have changed in this diff Show More