feat: UserState
This commit is contained in:
parent
5a7657a95d
commit
80a4b5d8e6
@ -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,
|
||||
},
|
||||
};
|
44
packages/app/eslint.config.mjs
Normal file
44
packages/app/eslint.config.mjs
Normal 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,
|
||||
},
|
||||
},
|
||||
];
|
@ -106,6 +106,7 @@
|
||||
"autoprefixer": "^10.4.16",
|
||||
"config": "^3.3.9",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-formatjs": "^4.11.3",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
@ -120,6 +121,7 @@
|
||||
"tinybench": "^2.5.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-pwa": "^0.19.2",
|
||||
"vite-plugin-version-mark": "^0.0.10",
|
||||
"vitest": "^0.34.6"
|
||||
|
@ -1,10 +1,10 @@
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { MixCloudRegex } from "@/Utils/Const";
|
||||
|
||||
const MixCloudEmbed = ({ link }: { link: string }) => {
|
||||
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";
|
||||
return (
|
||||
<>
|
||||
|
@ -8,30 +8,31 @@ import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import { Toastore } from "@/Components/Toaster/Toaster";
|
||||
import FollowListBase from "@/Components/User/FollowListBase";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils";
|
||||
import { useWallet } from "@/Wallet";
|
||||
|
||||
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
|
||||
const wallet = useWallet();
|
||||
const login = useLogin();
|
||||
const { publisher } = useEventPublisher();
|
||||
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const ids = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1]));
|
||||
|
||||
async function zapAll() {
|
||||
for (const pk of ids) {
|
||||
try {
|
||||
const profile = await UserCache.get(pk);
|
||||
const amtSend = login.appData.json.preferences.defaultZapAmount;
|
||||
const amtSend = defaultZapAmount;
|
||||
const lnurl = profile?.lud16 || profile?.lud06;
|
||||
if (lnurl) {
|
||||
const svc = new LNURL(lnurl);
|
||||
await svc.load();
|
||||
|
||||
const relays = await system.requestRouter?.forReplyTo(pk);
|
||||
const zap = await publisher?.zap(
|
||||
amtSend * 1000,
|
||||
pk,
|
||||
Object.keys(login.relays.item),
|
||||
relays ?? [],
|
||||
undefined,
|
||||
`Zap from ${hexToBech32("note", ev.id)}`,
|
||||
);
|
||||
@ -74,7 +75,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
|
||||
defaultMessage="Zap all {n} sats"
|
||||
id="IVbtTS"
|
||||
values={{
|
||||
n: <FormattedNumber value={login.appData.json.preferences.defaultZapAmount * ids.length} />,
|
||||
n: <FormattedNumber value={defaultZapAmount * ids.length} />,
|
||||
}}
|
||||
/>
|
||||
</AsyncButton>
|
||||
|
@ -20,6 +20,8 @@ import { Toastore } from "@/Components/Toaster/Toaster";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import useRelays from "@/Hooks/useRelays";
|
||||
import { useNoteCreator } from "@/State/NoteCreator";
|
||||
import { openFile, trackEvent } from "@/Utils";
|
||||
import useFileUpload from "@/Utils/Upload";
|
||||
@ -56,11 +58,12 @@ const quoteNoteOptions = {
|
||||
export function NoteCreator() {
|
||||
const { formatMessage } = useIntl();
|
||||
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 publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub;
|
||||
const publisher = pow ? pub?.pow(pow, GetPowWorker()) : pub;
|
||||
const note = useNoteCreator();
|
||||
const relays = login.relays;
|
||||
|
||||
useEffect(() => {
|
||||
const draft = localStorage.getItem("msgDraft");
|
||||
@ -367,8 +370,9 @@ export function NoteCreator() {
|
||||
function renderRelayCustomisation() {
|
||||
return (
|
||||
<div className="flex flex-col g8">
|
||||
{Object.keys(relays.item || {})
|
||||
.filter(el => relays.item[el].write)
|
||||
{Object.entries(relays)
|
||||
.filter(el => el[1].write)
|
||||
.map(a => a[0])
|
||||
.map((r, i, a) => (
|
||||
<div className="p flex justify-between note-creator-relay" key={r}>
|
||||
<div>{r}</div>
|
||||
@ -523,7 +527,7 @@ export function NoteCreator() {
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center g8">
|
||||
<ProfileImage
|
||||
pubkey={login.publicKey ?? ""}
|
||||
pubkey={publicKey ?? ""}
|
||||
className="note-creator-icon"
|
||||
link=""
|
||||
showUsername={false}
|
||||
|
@ -8,21 +8,16 @@ import IconButton from "@/Components/Button/IconButton";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { saveRelays } from "@/Pages/settings/saveRelays";
|
||||
import { getRelayName } from "@/Utils";
|
||||
import { removeRelay } from "@/Utils/Login";
|
||||
|
||||
export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => void }) {
|
||||
const [r, setResult] = useState(rsp);
|
||||
const { formatMessage } = useIntl();
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { system } = useEventPublisher();
|
||||
const login = useLogin();
|
||||
|
||||
async function removeRelayFromResult(r: OkResponse) {
|
||||
if (publisher) {
|
||||
removeRelay(login, unwrap(sanitizeRelayUrl(r.relay)));
|
||||
await saveRelays(system, publisher, login.relays.item);
|
||||
}
|
||||
await login.state.removeRelay(unwrap(sanitizeRelayUrl(r.relay)), true);
|
||||
close();
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
|
||||
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);
|
||||
if (hideMutedNotes) return;
|
||||
|
||||
|
@ -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 { useEffect, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
@ -10,7 +10,7 @@ import SnortApi from "@/External/SnortApi";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
import { setBookmarked, setPinned } from "@/Utils/Login";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { getCurrentSubscription, SubscriptionType } from "@/Utils/Subscription";
|
||||
|
||||
import { ReBroadcaster } from "../../ReBroadcaster";
|
||||
@ -18,7 +18,8 @@ import { ReBroadcaster } from "../../ReBroadcaster";
|
||||
export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const login = useLogin();
|
||||
const { mute, block } = useModeration();
|
||||
const autoTranslate = usePreferences(s => s.autoTranslate);
|
||||
const { mute } = useModeration();
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const [showBroadcast, setShowBroadcast] = useState(false);
|
||||
const lang = window.navigator.language;
|
||||
@ -26,6 +27,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
|
||||
type: "language",
|
||||
});
|
||||
const isMine = ev.pubkey === login.publicKey;
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
|
||||
async function deleteEvent() {
|
||||
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
|
||||
@ -78,7 +80,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const sub = getCurrentSubscription(login.subscriptions);
|
||||
if (sub?.type === SubscriptionType.Premium && (login.appData.json.preferences.autoTranslate ?? true)) {
|
||||
if (sub?.type === SubscriptionType.Premium && (autoTranslate ?? true)) {
|
||||
translate();
|
||||
}
|
||||
}, []);
|
||||
@ -90,19 +92,13 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
|
||||
|
||||
async function pin(id: HexKey) {
|
||||
if (publisher) {
|
||||
const es = [...login.pinned.item, id];
|
||||
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
|
||||
system.BroadcastEvent(ev);
|
||||
setPinned(login, es, ev.created_at * 1000);
|
||||
//todo: PIN note
|
||||
}
|
||||
}
|
||||
|
||||
async function bookmark(id: string) {
|
||||
if (publisher) {
|
||||
const es = [...login.bookmarked.item, id];
|
||||
const ev = await publisher.bookmarks(es.map(a => new NostrLink(NostrPrefix.Note, a)));
|
||||
system.BroadcastEvent(ev);
|
||||
setBookmarked(login, es, ev.created_at * 1000);
|
||||
//todo: bookmark note
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,13 +128,13 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
|
||||
<Icon name="share" />
|
||||
<FormattedMessage {...messages.Share} />
|
||||
</MenuItem>
|
||||
{!login.pinned.item.includes(ev.id) && !login.readonly && (
|
||||
{!login.state.isOnList(EventKind.PinList, link) && !login.readonly && (
|
||||
<MenuItem onClick={() => pin(ev.id)}>
|
||||
<Icon name="pin" />
|
||||
<FormattedMessage {...messages.Pin} />
|
||||
</MenuItem>
|
||||
)}
|
||||
{!login.bookmarked.item.includes(ev.id) && !login.readonly && (
|
||||
{!login.state.isOnList(EventKind.BookmarksList, link) && !login.readonly && (
|
||||
<MenuItem onClick={() => bookmark(ev.id)}>
|
||||
<Icon name="bookmark" />
|
||||
<FormattedMessage {...messages.Bookmark} />
|
||||
@ -158,12 +154,6 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
|
||||
<Icon name="relay" />
|
||||
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
|
||||
</MenuItem>
|
||||
{ev.pubkey !== login.publicKey && !login.readonly && (
|
||||
<MenuItem onClick={() => block(ev.pubkey)}>
|
||||
<Icon name="block" />
|
||||
<FormattedMessage {...messages.Block} />
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => translate()}>
|
||||
<Icon name="translate" />
|
||||
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
|
||||
|
@ -11,6 +11,7 @@ import { ZapsSummary } from "@/Components/Event/ZapsSummary";
|
||||
import ZapModal from "@/Components/ZapModal/ZapModal";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { getDisplayName } from "@/Utils";
|
||||
import { Zapper, ZapTarget } from "@/Utils/Zapper";
|
||||
import { ZapPoolController } from "@/Utils/ZapPoolController";
|
||||
@ -23,15 +24,11 @@ export interface ZapIconProps {
|
||||
}
|
||||
|
||||
export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
|
||||
const {
|
||||
publicKey,
|
||||
readonly,
|
||||
preferences: prefs,
|
||||
} = useLogin(s => ({
|
||||
const { publicKey, readonly } = useLogin(s => ({
|
||||
publicKey: s.publicKey,
|
||||
readonly: s.readonly,
|
||||
preferences: s.appData.json.preferences,
|
||||
}));
|
||||
const preferences = usePreferences(s => ({ autoZap: s.autoZap, defaultZapAmount: s.defaultZapAmount }));
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
@ -75,7 +72,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
|
||||
if (canFastZap && lnurl) {
|
||||
setZapping(true);
|
||||
try {
|
||||
await fastZapInner(lnurl, prefs.defaultZapAmount);
|
||||
await fastZapInner(lnurl, preferences.defaultZapAmount);
|
||||
} catch (e) {
|
||||
console.warn("Fast zap failed", e);
|
||||
if (!(e instanceof Error) || e.message !== "User rejected") {
|
||||
@ -110,13 +107,13 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
|
||||
const targets = getZapTarget();
|
||||
|
||||
useEffect(() => {
|
||||
if (prefs.autoZap && !didZap && !isMine && !zapping) {
|
||||
if (preferences.autoZap && !didZap && !isMine && !zapping) {
|
||||
const lnurl = getZapTarget();
|
||||
if (wallet?.isReady() && lnurl) {
|
||||
setZapping(true);
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
await fastZapInner(lnurl, prefs.defaultZapAmount);
|
||||
await fastZapInner(lnurl, preferences.defaultZapAmount);
|
||||
} catch {
|
||||
// ignored
|
||||
} finally {
|
||||
@ -125,7 +122,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [prefs.autoZap, author, zapping]);
|
||||
}, [preferences.autoZap, author, zapping]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -9,6 +9,7 @@ import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
|
||||
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
|
||||
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
|
||||
export interface NoteFooterProps {
|
||||
replyCount?: number;
|
||||
@ -25,17 +26,14 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
|
||||
const { positive } = reactions;
|
||||
|
||||
const { preferences: prefs, readonly } = useLogin(s => ({
|
||||
preferences: s.appData.json.preferences,
|
||||
publicKey: s.publicKey,
|
||||
readonly: s.readonly,
|
||||
}));
|
||||
const readonly = useLogin(s => s.readonly);
|
||||
const enableReactions = usePreferences(s => s.enableReactions);
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<RepostButton ev={ev} reposts={reposts} />
|
||||
{prefs.enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
|
||||
{enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
|
||||
{CONFIG.showPowIcon && <PowIcon ev={ev} />}
|
||||
<FooterZapButton ev={ev} zaps={zaps} onClickZappers={() => setShowReactions(true)} />
|
||||
{showReactions && <ReactionsModal initialTab={1} onClose={() => setShowReactions(false)} event={ev} />}
|
||||
|
@ -9,16 +9,15 @@ import Icon from "@/Components/Icons/Icon";
|
||||
import messages from "@/Components/messages";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { useNoteCreator } from "@/State/NoteCreator";
|
||||
|
||||
export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: TaggedNostrEvent[] }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { publicKey, preferences: prefs } = useLogin(s => ({
|
||||
preferences: s.appData.json.preferences,
|
||||
publicKey: s.publicKey,
|
||||
}));
|
||||
const publicKey = useLogin(s => s.publicKey);
|
||||
const confirmReposts = usePreferences(s => s.confirmReposts);
|
||||
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
|
||||
|
||||
const hasReposted = () => {
|
||||
@ -27,7 +26,7 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
|
||||
|
||||
const repost = async () => {
|
||||
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);
|
||||
system.BroadcastEvent(evRepost);
|
||||
}
|
||||
|
@ -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 { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
@ -13,7 +13,6 @@ import messages from "@/Components/messages";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { setBookmarked, setPinned } from "@/Utils/Login";
|
||||
|
||||
export default function NoteHeader(props: {
|
||||
ev: TaggedNostrEvent;
|
||||
@ -24,28 +23,21 @@ export default function NoteHeader(props: {
|
||||
const [showReactions, setShowReactions] = useState(false);
|
||||
const { ev, options, setTranslated } = props;
|
||||
const { formatMessage } = useIntl();
|
||||
const { pinned, bookmarked } = useLogin();
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { publisher } = useEventPublisher();
|
||||
const login = useLogin();
|
||||
|
||||
async function unpin(id: HexKey) {
|
||||
async function unpin() {
|
||||
if (options.canUnpin && publisher) {
|
||||
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
|
||||
const es = pinned.item.filter(e => e !== id);
|
||||
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
|
||||
system.BroadcastEvent(ev);
|
||||
setPinned(login, es, ev.created_at * 1000);
|
||||
await login.state.removeFromList(EventKind.PinList, NostrLink.fromEvent(ev));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unbookmark(id: HexKey) {
|
||||
async function unbookmark() {
|
||||
if (options.canUnbookmark && publisher) {
|
||||
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
|
||||
const es = bookmarked.item.filter(e => e !== id);
|
||||
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
|
||||
system.BroadcastEvent(ev);
|
||||
setBookmarked(login, es, ev.created_at * 1000);
|
||||
await login.state.removeFromList(EventKind.BookmarksList, NostrLink.fromEvent(ev));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -66,7 +58,7 @@ export default function NoteHeader(props: {
|
||||
{(options.showTime || 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} />
|
||||
</div>
|
||||
)}
|
||||
@ -74,7 +66,7 @@ export default function NoteHeader(props: {
|
||||
</>
|
||||
)}
|
||||
{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} />
|
||||
</div>
|
||||
)}
|
||||
|
@ -6,14 +6,14 @@ import { NoteProps } from "@/Components/Event/EventComponent";
|
||||
import { NoteTranslation } from "@/Components/Event/Note/types";
|
||||
import Reveal from "@/Components/Event/Reveal";
|
||||
import Text from "@/Components/Text/Text";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
|
||||
const TEXT_TRUNCATE_LENGTH = 400;
|
||||
export const NoteText = memo(function InnerContent(
|
||||
props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean },
|
||||
) {
|
||||
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 body = translated && !translated.skipped && showTranslation ? translated.text : ev?.content ?? "";
|
||||
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");
|
||||
if (contentWarning) {
|
||||
return (
|
||||
|
@ -8,6 +8,7 @@ import Spinner from "@/Components/Icons/Spinner";
|
||||
import ZapModal from "@/Components/ZapModal/ZapModal";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { unwrap } from "@/Utils";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
import { useWallet } from "@/Wallet";
|
||||
@ -21,13 +22,10 @@ type PollTally = "zaps" | "pubkeys";
|
||||
|
||||
export default function Poll(props: PollProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { publisher } = useEventPublisher();
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { wallet } = useWallet();
|
||||
const {
|
||||
preferences: prefs,
|
||||
publicKey: myPubKey,
|
||||
relays,
|
||||
} = useLogin(s => ({ preferences: s.appData.json.preferences, publicKey: s.publicKey, relays: s.relays }));
|
||||
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
|
||||
const myPubKey = useLogin(s => s.publicKey);
|
||||
const pollerProfile = useUserProfile(props.ev.pubkey);
|
||||
const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys");
|
||||
const [error, setError] = useState("");
|
||||
@ -45,7 +43,7 @@ export default function Poll(props: PollProps) {
|
||||
ev.stopPropagation();
|
||||
if (voting || !publisher) return;
|
||||
|
||||
const amount = prefs.defaultZapAmount;
|
||||
const amount = defaultZapAmount;
|
||||
try {
|
||||
if (amount <= 0) {
|
||||
throw new Error(
|
||||
@ -62,9 +60,14 @@ export default function Poll(props: PollProps) {
|
||||
}
|
||||
|
||||
setVoting(opt);
|
||||
const r = Object.keys(relays.item);
|
||||
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, NostrLink.fromEvent(props.ev), undefined, eb =>
|
||||
eb.tag(["poll_option", opt.toString()]),
|
||||
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 => eb.tag(["poll_option", opt.toString()]),
|
||||
);
|
||||
|
||||
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"
|
||||
id="3qnJlS"
|
||||
values={{
|
||||
amount: formatShort(prefs.defaultZapAmount),
|
||||
amount: formatShort(defaultZapAmount),
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
|
@ -6,6 +6,7 @@ import { MediaElement } from "@/Components/Embed/MediaElement";
|
||||
import Reveal from "@/Components/Event/Reveal";
|
||||
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { FileExtensionRegex } from "@/Utils/Const";
|
||||
|
||||
interface RevealMediaProps {
|
||||
@ -17,15 +18,13 @@ interface RevealMediaProps {
|
||||
}
|
||||
|
||||
export default function RevealMedia(props: RevealMediaProps) {
|
||||
const { preferences, publicKey } = useLogin(s => ({
|
||||
preferences: s.appData.json.preferences,
|
||||
publicKey: s.publicKey,
|
||||
}));
|
||||
const publicKey = useLogin(s => s.publicKey);
|
||||
const autoLoadMedia = usePreferences(s => s.autoLoadMedia);
|
||||
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 hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows);
|
||||
const hideMedia = autoLoadMedia === "none" || (!isMine && hideNonFollows);
|
||||
const hostname = new URL(props.link).hostname;
|
||||
|
||||
const url = new URL(props.link);
|
||||
|
@ -123,8 +123,6 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
|
||||
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
|
||||
<h1>Data</h1>
|
||||
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
|
||||
<h1>Reactions</h1>
|
||||
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
|
||||
</div>
|
||||
)}
|
||||
{parent && (
|
||||
|
@ -3,9 +3,8 @@ import { FormattedMessage } from "react-intl";
|
||||
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
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 = [
|
||||
{
|
||||
tab: "for-you",
|
||||
@ -98,7 +97,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: New
|
||||
{
|
||||
tab: "tags",
|
||||
path: `${base}/topics`,
|
||||
show: tags.item.length > 0,
|
||||
show: tags.length > 0,
|
||||
element: (
|
||||
<>
|
||||
<Icon name="hash" />
|
||||
|
@ -7,26 +7,26 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { rootTabItems } from "@/Components/Feed/RootTabItems";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { RootTabRoutePath } from "@/Pages/Root/RootTabRoutes";
|
||||
import { EventKind } from "@snort/system";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
export function RootTabs({ base = "/" }: { base: string }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
publicKey: pubKey,
|
||||
tags,
|
||||
preferences,
|
||||
} = useLogin(s => ({
|
||||
const { publicKey: pubKey, tags } = useLogin(s => ({
|
||||
publicKey: s.publicKey,
|
||||
tags: s.tags,
|
||||
preferences: s.appData.json.preferences,
|
||||
tags: s.state.getList(EventKind.InterestSet),
|
||||
}));
|
||||
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;
|
||||
if (pubKey) {
|
||||
defaultTab = preferences.defaultRootTab;
|
||||
defaultTab = defaultRootTab;
|
||||
} else {
|
||||
defaultTab = `trending/notes`;
|
||||
}
|
||||
|
@ -26,7 +26,11 @@ export interface TimelineFollowsProps {
|
||||
* A list of notes by "subject"
|
||||
*/
|
||||
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 [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
|
||||
@ -38,12 +42,12 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
||||
items: followList,
|
||||
discriminator: login.publicKey?.slice(0, 12),
|
||||
extra: rb => {
|
||||
if (login.tags.item.length > 0) {
|
||||
rb.withFilter().kinds([EventKind.TextNote, EventKind.Repost]).tag("t", login.tags.item);
|
||||
if (login.tags.length > 0) {
|
||||
rb.withFilter().kinds([EventKind.TextNote, EventKind.Repost]).tags(login.tags);
|
||||
}
|
||||
},
|
||||
}) as TimelineSubject,
|
||||
[followList, login.tags.item],
|
||||
[login.publicKey, followList, login.tags],
|
||||
);
|
||||
const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
|
||||
|
||||
|
@ -16,12 +16,12 @@ export default function UsersFeed({ keyword, sortPopular = true }: { keyword: st
|
||||
{ method: "LIMIT_UNTIL" },
|
||||
);
|
||||
|
||||
const { muted, isEventMuted } = useModeration();
|
||||
const { isEventMuted } = useModeration();
|
||||
const filterPosts = useCallback(
|
||||
(nts: readonly TaggedNostrEvent[]) => {
|
||||
return nts.filter(a => !isEventMuted(a));
|
||||
},
|
||||
[muted],
|
||||
[isEventMuted],
|
||||
);
|
||||
const usersFeed = useMemo(() => filterPosts(feed.main ?? []).map(p => p.pubkey), [feed, filterPosts]);
|
||||
|
||||
|
@ -2,10 +2,10 @@ import { useSyncExternalStore } from "react";
|
||||
|
||||
import { getLocale } from "@/Components/IntlProvider/IntlProviderUtils";
|
||||
import { LangOverride } from "@/Components/IntlProvider/langStore";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
|
||||
export function useLocale() {
|
||||
const { language } = useLogin(s => ({ language: s.appData.json.preferences.language }));
|
||||
const language = usePreferences(s => s.language);
|
||||
const loggedOutLang = useSyncExternalStore(
|
||||
c => LangOverride.hook(c),
|
||||
() => LangOverride.snapshot(),
|
||||
|
@ -7,6 +7,7 @@ import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { createPublisher, LoginStore, sessionNeedsPin } from "@/Utils/Login";
|
||||
import { GetPowWorker } from "@/Utils/wasm";
|
||||
|
||||
@ -97,6 +98,7 @@ export function PinPrompt({
|
||||
|
||||
export function LoginUnlock() {
|
||||
const login = useLogin();
|
||||
const pow = usePreferences(s => s.pow);
|
||||
const { publisher } = useEventPublisher();
|
||||
|
||||
async function encryptMigration(pin: string) {
|
||||
@ -104,8 +106,8 @@ export function LoginUnlock() {
|
||||
const newPin = await PinEncrypted.create(k, pin);
|
||||
|
||||
const pub = EventPublisher.privateKey(k);
|
||||
if (login.appData.json.preferences.pow) {
|
||||
pub.pow(login.appData.json.preferences.pow, GetPowWorker());
|
||||
if (pow) {
|
||||
pub.pow(pow, GetPowWorker());
|
||||
}
|
||||
LoginStore.setPublisher(login.id, pub);
|
||||
LoginStore.updateSession({
|
||||
@ -121,8 +123,8 @@ export function LoginUnlock() {
|
||||
await key.unlock(pin);
|
||||
const pub = createPublisher(login);
|
||||
if (pub) {
|
||||
if (login.appData.json.preferences.pow) {
|
||||
pub.pow(login.appData.json.preferences.pow, GetPowWorker());
|
||||
if (pow) {
|
||||
pub.pow(pow, GetPowWorker());
|
||||
}
|
||||
LoginStore.setPublisher(login.id, pub);
|
||||
LoginStore.updateSession({
|
||||
|
@ -4,7 +4,7 @@ import { useContext, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import Modal from "@/Components/Modal/Modal";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useRelays from "@/Hooks/useRelays";
|
||||
|
||||
import AsyncButton from "./Button/AsyncButton";
|
||||
import messages from "./messages";
|
||||
@ -12,7 +12,7 @@ import messages from "./messages";
|
||||
export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) {
|
||||
const [selected, setSelected] = useState<Array<string>>();
|
||||
const system = useContext(SnortContext);
|
||||
const { relays } = useLogin(s => ({ relays: s.relays }));
|
||||
const relays = useRelays();
|
||||
|
||||
async function sendReBroadcast() {
|
||||
if (selected) {
|
||||
@ -25,8 +25,8 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: Tagged
|
||||
function renderRelayCustomisation() {
|
||||
return (
|
||||
<div className="flex flex-col g8">
|
||||
{Object.keys(relays.item || {})
|
||||
.filter(el => relays.item[el].write)
|
||||
{Object.keys(relays)
|
||||
.filter(el => relays[el].write)
|
||||
.map((r, i, a) => (
|
||||
<div key={r} className="card flex justify-between">
|
||||
<div>{r}</div>
|
||||
@ -36,7 +36,7 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: Tagged
|
||||
checked={!selected || selected.includes(r)}
|
||||
onChange={e =>
|
||||
setSelected(
|
||||
e.target.checked && selected && selected.length == a.length - 1
|
||||
e.target.checked && selected && selected.length === a.length - 1
|
||||
? undefined
|
||||
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
|
||||
)
|
||||
|
@ -1,17 +1,14 @@
|
||||
import "./Relay.css";
|
||||
|
||||
import { unixNowMs } from "@snort/shared";
|
||||
import { RelaySettings } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import classNames from "classnames";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
|
||||
import useRelayState from "@/Feed/RelayState";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { getRelayName, unwrap } from "@/Utils";
|
||||
import { removeRelay, setRelays } from "@/Utils/Login";
|
||||
import { getRelayName } from "@/Utils";
|
||||
|
||||
import { RelayFavicon } from "./RelaysMetadata";
|
||||
|
||||
@ -21,35 +18,29 @@ export interface RelayProps {
|
||||
|
||||
export default function Relay(props: RelayProps) {
|
||||
const navigate = useNavigate();
|
||||
const system = useContext(SnortContext);
|
||||
const login = useLogin();
|
||||
const state = useLogin(s => s.state);
|
||||
|
||||
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 connection = useRelayState(props.addr);
|
||||
|
||||
function configure(o: RelaySettings) {
|
||||
setRelays(
|
||||
login,
|
||||
{
|
||||
...login.relays.item,
|
||||
[props.addr]: o,
|
||||
},
|
||||
unixNowMs(),
|
||||
);
|
||||
const relaySettings = state.relays?.find(a => a.url === props.addr)?.settings;
|
||||
if (!relaySettings || !connection) return;
|
||||
|
||||
async function configure(o: RelaySettings) {
|
||||
await state.updateRelay(props.addr, o);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
</div>
|
||||
<div className="flex flex-col g8">
|
||||
<div>
|
||||
<b>{name}</b>
|
||||
</div>
|
||||
{!state?.Ephemeral && (
|
||||
{!connection?.Ephemeral && (
|
||||
<div className="flex g8">
|
||||
<AsyncIcon
|
||||
iconName="write"
|
||||
@ -77,13 +68,13 @@ export default function Relay(props: RelayProps) {
|
||||
iconName="trash"
|
||||
iconSize={16}
|
||||
className="button-icon-sm transparent trash-icon"
|
||||
onClick={() => removeRelay(login, props.addr)}
|
||||
onClick={() => state.removeRelay(props.addr)}
|
||||
/>
|
||||
<AsyncIcon
|
||||
iconName="gear"
|
||||
iconSize={16}
|
||||
className="button-icon-sm transparent"
|
||||
onClick={() => navigate(state?.Id ?? "")}
|
||||
onClick={() => navigate(connection?.Id ?? "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -17,15 +17,15 @@ enum Provider {
|
||||
}
|
||||
|
||||
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 getUrlAndKey = () => {
|
||||
if (!login.publicKey) return { url: null, key: null };
|
||||
if (!publicKey) return { url: null, key: null };
|
||||
switch (provider) {
|
||||
case Provider.NostrBand: {
|
||||
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}` };
|
||||
}
|
||||
default:
|
||||
|
@ -33,7 +33,7 @@ export default function TrendingNotes({ count = Infinity, small = false }: { cou
|
||||
const ev = a.event;
|
||||
if (!System.optimizer.schnorrVerify(ev)) {
|
||||
console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
System.HandleEvent("*", ev as TaggedNostrEvent);
|
||||
return ev;
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { HexKey } from "@snort/system";
|
||||
import { HexKey, NostrPrefix } from "@snort/system";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import MuteButton from "@/Components/User/MuteButton";
|
||||
@ -11,26 +11,31 @@ export interface MutedListProps {
|
||||
pubkeys: HexKey[];
|
||||
}
|
||||
|
||||
export default function MutedList({ pubkeys }: MutedListProps) {
|
||||
const { isMuted, muteAll } = useModeration();
|
||||
const hasAllMuted = pubkeys.every(isMuted);
|
||||
export default function MutedList() {
|
||||
const { muteList } = useModeration();
|
||||
|
||||
return (
|
||||
<div className="p">
|
||||
<div className="flex justify-between">
|
||||
<div className="bold">
|
||||
<FormattedMessage {...messages.MuteCount} values={{ n: pubkeys?.length }} />
|
||||
<FormattedMessage {...messages.MuteCount} values={{ n: muteList?.length }} />
|
||||
</div>
|
||||
<button
|
||||
disabled={hasAllMuted || pubkeys.length === 0}
|
||||
className="transparent"
|
||||
type="button"
|
||||
onClick={() => muteAll(pubkeys)}>
|
||||
<FormattedMessage {...messages.MuteAll} />
|
||||
</button>
|
||||
</div>
|
||||
{pubkeys?.map(a => {
|
||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
|
||||
{muteList?.map(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>
|
||||
);
|
||||
|
@ -7,6 +7,7 @@ import messages from "@/Components/messages";
|
||||
import { ZapType } from "@/Components/ZapModal/ZapType";
|
||||
import { ZapTypeSelector } from "@/Components/ZapModal/ZapTypeSelector";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
import { Zapper } from "@/Utils/Zapper";
|
||||
|
||||
@ -21,10 +22,8 @@ export function ZapModalInput(props: {
|
||||
onChange?: (v: SendSatsInputSelection) => void;
|
||||
onNextStage: (v: SendSatsInputSelection) => Promise<void>;
|
||||
}) {
|
||||
const { defaultZapAmount, readonly } = useLogin(s => ({
|
||||
defaultZapAmount: s.appData.json.preferences.defaultZapAmount,
|
||||
readonly: s.readonly,
|
||||
}));
|
||||
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
|
||||
const readonly = useLogin(s => s.readonly);
|
||||
const { formatMessage } = useIntl();
|
||||
const amounts: Record<string, string> = {
|
||||
[defaultZapAmount.toString()]: "",
|
||||
|
@ -5,15 +5,15 @@ import { useMemo } from "react";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
export function useArticles() {
|
||||
const { publicKey, follows } = useLogin();
|
||||
const { publicKey, follows } = useLogin(s => ({ publicKey: s.publicKey, follows: s.state.follows }));
|
||||
|
||||
const sub = useMemo(() => {
|
||||
if (!publicKey) return null;
|
||||
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;
|
||||
}, [follows.timestamp]);
|
||||
}, [follows, publicKey]);
|
||||
|
||||
return useRequestBuilder(sub);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { useMemo } from "react";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
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 sub = useMemo(() => {
|
||||
@ -18,11 +18,11 @@ export default function useFollowsFeed(pubkey?: HexKey) {
|
||||
const contactFeed = useRequestBuilder(sub);
|
||||
return useMemo(() => {
|
||||
if (isMe) {
|
||||
return follows.item;
|
||||
return follows;
|
||||
}
|
||||
|
||||
return getFollowing(contactFeed ?? [], pubkey);
|
||||
}, [contactFeed, follows, pubkey]);
|
||||
}, [isMe, contactFeed, follows, pubkey]);
|
||||
}
|
||||
|
||||
export function getFollowing(notes: readonly TaggedNostrEvent[], pubkey?: HexKey) {
|
||||
|
@ -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 { useEffect, useMemo } from "react";
|
||||
|
||||
import { Nip28ChatSystem } from "@/chat/nip28";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
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 {
|
||||
addSubscription,
|
||||
LoginStore,
|
||||
setBlocked,
|
||||
setBookmarked,
|
||||
setMuted,
|
||||
setPinned,
|
||||
setRelays,
|
||||
setTags,
|
||||
updateSession,
|
||||
} from "@/Utils/Login";
|
||||
import { addSubscription } from "@/Utils/Login";
|
||||
import { SubscriptionEvent } from "@/Utils/Subscription";
|
||||
|
||||
/**
|
||||
* Managed loading data for the current logged in user
|
||||
*/
|
||||
export default function useLoginFeed() {
|
||||
const login = useLogin();
|
||||
const { publicKey: pubKey, contacts } = login;
|
||||
const { publicKey: pubKey } = login;
|
||||
const checkSigs = usePreferences(s => s.checkSigs);
|
||||
const { publisher, system } = useEventPublisher();
|
||||
|
||||
useEffect(() => {
|
||||
if (login.appData.json) {
|
||||
system.checkSigs = login.appData.json.preferences.checkSigs;
|
||||
system.checkSigs = checkSigs;
|
||||
}, [system, checkSigs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (publisher) {
|
||||
login.appData.sync(publisher.signer, system);
|
||||
login.state.init(publisher.signer, system).catch(console.error);
|
||||
}
|
||||
}
|
||||
}, [login, publisher]);
|
||||
|
||||
}, [login, publisher, system]);
|
||||
const subLogin = useMemo(() => {
|
||||
if (!login || !pubKey) return null;
|
||||
|
||||
@ -44,18 +35,7 @@ export default function useLoginFeed() {
|
||||
b.withOptions({
|
||||
leaveOpen: true,
|
||||
});
|
||||
b.withFilter()
|
||||
.authors([pubKey])
|
||||
.kinds([
|
||||
EventKind.ContactList,
|
||||
EventKind.Relays,
|
||||
EventKind.MuteList,
|
||||
EventKind.PinList,
|
||||
EventKind.BookmarksList,
|
||||
EventKind.InterestsList,
|
||||
EventKind.PublicChatsList,
|
||||
EventKind.DirectMessage,
|
||||
]);
|
||||
b.withFilter().authors([pubKey]).kinds([EventKind.DirectMessage]);
|
||||
if (CONFIG.features.subscriptions && !login.readonly) {
|
||||
b.withFilter()
|
||||
.relay("wss://relay.snort.social/")
|
||||
@ -66,27 +46,13 @@ export default function useLoginFeed() {
|
||||
}
|
||||
|
||||
return b;
|
||||
}, [login]);
|
||||
}, [pubKey, login]);
|
||||
|
||||
const loginFeed = useRequestBuilder(subLogin);
|
||||
|
||||
// update relays and follow lists
|
||||
useEffect(() => {
|
||||
if (loginFeed) {
|
||||
const contactList = getNewest(loginFeed.filter(a => a.kind === EventKind.ContactList));
|
||||
if (contactList) {
|
||||
updateSession(login.id, s => {
|
||||
s.contacts = contactList.tags;
|
||||
});
|
||||
}
|
||||
|
||||
const relays = getNewest(loginFeed.filter(a => a.kind === EventKind.Relays));
|
||||
if (relays) {
|
||||
const parsedRelays = parseRelayTags(relays.tags.filter(a => a[0] === "r")).map(a => [a.url, a.settings]);
|
||||
setRelays(login, Object.fromEntries(parsedRelays), relays.created_at * 1000);
|
||||
}
|
||||
|
||||
if (publisher) {
|
||||
if (loginFeed && publisher) {
|
||||
const subs = loginFeed.filter(
|
||||
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey),
|
||||
);
|
||||
@ -103,84 +69,9 @@ export default function useLoginFeed() {
|
||||
}),
|
||||
).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
|
||||
}
|
||||
}
|
||||
}, [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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [login, loginFeed, publisher]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginFeed) {
|
||||
const mutedFeed = loginFeed.filter(a => a.kind === EventKind.MuteList);
|
||||
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]);
|
||||
system.profileLoader.TrackKeys(login.state.follows ?? []); // always track follows profiles
|
||||
}, [system, login.state.follows]);
|
||||
}
|
||||
|
@ -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 { useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
@ -39,7 +39,7 @@ export default function useThreadFeed(link: NostrLink) {
|
||||
.relay(rootRelays ?? []);
|
||||
}
|
||||
return sub;
|
||||
}, [allEvents.length, rootRelays]);
|
||||
}, [allEvents, link, root, rootRelays]);
|
||||
|
||||
const store = useRequestBuilder(sub);
|
||||
|
||||
@ -53,27 +53,21 @@ export default function useThreadFeed(link: NostrLink) {
|
||||
.flat();
|
||||
setAllEvents(links);
|
||||
|
||||
// load the thread structure from the current note
|
||||
const current = store.find(a => link.matchesEvent(a));
|
||||
if (current) {
|
||||
const t = EventExt.extractThread(current);
|
||||
const t = Nip10.parseThread(current);
|
||||
if (t) {
|
||||
const rootOrReplyAsRoot = t?.root ?? t?.replyTo;
|
||||
if (rootOrReplyAsRoot) {
|
||||
setRoot(
|
||||
NostrLink.fromTag([
|
||||
rootOrReplyAsRoot.key,
|
||||
rootOrReplyAsRoot.value ?? "",
|
||||
rootOrReplyAsRoot.relay ?? "",
|
||||
...(rootOrReplyAsRoot.marker ?? []),
|
||||
]),
|
||||
);
|
||||
setRoot(rootOrReplyAsRoot);
|
||||
}
|
||||
} else {
|
||||
setRoot(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [store?.length]);
|
||||
}, [link, store, store.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (root) {
|
||||
@ -87,12 +81,15 @@ export default function useThreadFeed(link: NostrLink) {
|
||||
relays.relays.filter(a => a.settings.read).map(a => a.url),
|
||||
3,
|
||||
);
|
||||
if (root.relays) {
|
||||
readRelays.push(...root.relays);
|
||||
}
|
||||
setRootRelays(readRelays);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [link, root, store?.length]);
|
||||
}, [link, root, store, store.length, system.relayCache]);
|
||||
|
||||
return store ?? [];
|
||||
}
|
||||
|
@ -3,8 +3,8 @@ import { EventKind, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilderAdvanced } from "@snort/system-react";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import useTimelineWindow from "@/Hooks/useTimelineWindow";
|
||||
import { SearchRelays } from "@/Utils/Const";
|
||||
|
||||
@ -30,7 +30,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
window: options.window,
|
||||
now: options.now ?? unixNow(),
|
||||
});
|
||||
const pref = useLogin(s => s.appData.json.preferences);
|
||||
const autoShowLatest = usePreferences(s => s.autoShowLatest);
|
||||
const { isEventMuted } = useModeration();
|
||||
|
||||
const createBuilder = useCallback(() => {
|
||||
@ -93,7 +93,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
}
|
||||
return rb;
|
||||
}
|
||||
}, [until, since, options.method, pref, createBuilder]);
|
||||
}, [until, since, options.method, createBuilder]);
|
||||
|
||||
const mainQuery = useRequestBuilderAdvanced(sub);
|
||||
const main = useSyncExternalStore(
|
||||
@ -108,7 +108,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
|
||||
const subRealtime = useMemo(() => {
|
||||
const rb = createBuilder();
|
||||
if (rb && !pref.autoShowLatest && options.method !== "LIMIT_UNTIL") {
|
||||
if (rb && !autoShowLatest && options.method !== "LIMIT_UNTIL") {
|
||||
rb.withOptions({
|
||||
leaveOpen: true,
|
||||
});
|
||||
@ -118,7 +118,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
}
|
||||
return rb;
|
||||
}
|
||||
}, [pref.autoShowLatest, createBuilder]);
|
||||
}, [autoShowLatest, createBuilder]);
|
||||
|
||||
const latestQuery = useRequestBuilderAdvanced(subRealtime);
|
||||
const latest = useSyncExternalStore(
|
||||
|
@ -1,48 +1,32 @@
|
||||
import { DiffSyncTags, EventKind, NostrLink, NostrPrefix } from "@snort/system";
|
||||
import { useMemo } from "react";
|
||||
import { NostrLink } from "@snort/system";
|
||||
|
||||
import useEventPublisher from "./useEventPublisher";
|
||||
import useLogin from "./useLogin";
|
||||
|
||||
/**
|
||||
* Simple hook for adding / removing follows
|
||||
*/
|
||||
export default function useFollowsControls() {
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { pubkey, contacts, relays } = useLogin(s => ({
|
||||
pubkey: s.publicKey,
|
||||
contacts: s.contacts,
|
||||
readonly: s.readonly,
|
||||
relays: s.relays.item,
|
||||
}));
|
||||
const state = useLogin(s => s.state);
|
||||
|
||||
return useMemo(() => {
|
||||
const link = new NostrLink(NostrPrefix.Event, "", EventKind.ContactList, pubkey);
|
||||
const sync = new DiffSyncTags(link);
|
||||
const content = JSON.stringify(relays);
|
||||
return {
|
||||
isFollowing: (pk: string) => {
|
||||
return contacts.some(a => a[0] === "p" && a[1] === pk);
|
||||
return state.follows?.includes(pk);
|
||||
},
|
||||
addFollow: async (pk: Array<string>) => {
|
||||
sync.add(pk.map(a => ["p", a]));
|
||||
if (publisher) {
|
||||
await sync.persist(publisher.signer, system, content);
|
||||
for (const p of pk) {
|
||||
await state.follow(NostrLink.publicKey(p), false);
|
||||
}
|
||||
await state.saveContacts();
|
||||
},
|
||||
removeFollow: async (pk: Array<string>) => {
|
||||
sync.remove(pk.map(a => ["p", a]));
|
||||
if (publisher) {
|
||||
await sync.persist(publisher.signer, system, content);
|
||||
for (const p of pk) {
|
||||
await state.unfollow(NostrLink.publicKey(p), false);
|
||||
}
|
||||
await state.saveContacts();
|
||||
},
|
||||
setFollows: async (pk: Array<string>) => {
|
||||
sync.replace(pk.map(a => ["p", a]));
|
||||
if (publisher) {
|
||||
await sync.persist(publisher.signer, system, content);
|
||||
}
|
||||
await state.replaceFollows(pk.map(a => NostrLink.publicKey(a)));
|
||||
},
|
||||
followList: contacts.filter(a => a[0] === "p").map(a => a[1]),
|
||||
followList: state.follows ?? [],
|
||||
};
|
||||
}, [contacts, relays, publisher, system]);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
import { base64 } from "@scure/base";
|
||||
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { hmacSha256, unwrap } from "@/Utils";
|
||||
|
||||
export interface ImgProxySettings {
|
||||
@ -11,10 +11,10 @@ export interface ImgProxySettings {
|
||||
}
|
||||
|
||||
export default function useImgProxy() {
|
||||
const settings = useLogin(s => s.appData.json.preferences.imgProxyConfig);
|
||||
const imgProxyConfig = usePreferences(s => s.imgProxyConfig);
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
return useLinkListEvents(`list:bookmark:${pubkey?.slice(0, 12)}`, rb => {
|
||||
if (pubkey) {
|
||||
|
@ -2,10 +2,10 @@ import { RelaySettings, SystemInterface } from "@snort/system";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import useEventPublisher from "./useEventPublisher";
|
||||
import useLogin from "./useLogin";
|
||||
import useRelays from "./useRelays";
|
||||
|
||||
export function useLoginRelays() {
|
||||
const relays = useLogin(s => s.relays.item);
|
||||
const relays = useRelays();
|
||||
const { system } = useEventPublisher();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -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 { appendDedupe } from "@/Utils";
|
||||
import { setBlocked, setMuted } from "@/Utils/Login";
|
||||
import { dedupe } from "@snort/shared";
|
||||
|
||||
export default function useModeration() {
|
||||
const login = useLogin();
|
||||
const { muted, blocked, appData } = login;
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const state = useLogin(s => s.state);
|
||||
|
||||
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
|
||||
if (publisher) {
|
||||
const ev = await publisher.muted(pub, priv);
|
||||
system.BroadcastEvent(ev);
|
||||
return ev.created_at * 1000;
|
||||
}
|
||||
return 0;
|
||||
function isMuted(id: string) {
|
||||
const link = NostrLink.publicKey(id);
|
||||
return state.muted.includes(link);
|
||||
}
|
||||
|
||||
function isMuted(id: HexKey) {
|
||||
return muted.item.includes(id) || blocked.item.includes(id);
|
||||
async function unmute(id: string) {
|
||||
const link = NostrLink.publicKey(id);
|
||||
await state.unmute(link, true);
|
||||
}
|
||||
|
||||
function isBlocked(id: HexKey) {
|
||||
return blocked.item.includes(id);
|
||||
async function mute(id: string) {
|
||||
const link = NostrLink.publicKey(id);
|
||||
await state.mute(link, true);
|
||||
}
|
||||
|
||||
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 muteAll(ids: string[]) {
|
||||
const links = dedupe(ids).map(a => NostrLink.publicKey(a));
|
||||
for (const link of links) {
|
||||
await state.mute(link, false);
|
||||
}
|
||||
|
||||
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);
|
||||
await state.saveList(EventKind.MuteList);
|
||||
}
|
||||
|
||||
function isMutedWord(word: string) {
|
||||
return appData.json.mutedWords.includes(word.toLowerCase());
|
||||
return false;
|
||||
}
|
||||
|
||||
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 {
|
||||
muted: muted.item,
|
||||
muteList: state.muted,
|
||||
mute,
|
||||
muteAll,
|
||||
unmute,
|
||||
isMuted,
|
||||
blocked: blocked.item,
|
||||
block,
|
||||
unblock,
|
||||
isBlocked,
|
||||
isMutedWord,
|
||||
isEventMuted,
|
||||
};
|
||||
|
@ -1,10 +1,32 @@
|
||||
import { updateAppData, UserPreferences } from "@/Utils/Login";
|
||||
import { DefaultPreferences, updateAppData, UserPreferences } from "@/Utils/Login";
|
||||
|
||||
import useEventPublisher from "./useEventPublisher";
|
||||
import useLogin from "./useLogin";
|
||||
|
||||
export default function usePreferences() {
|
||||
const { id, pref } = useLogin(s => ({ id: s.id, pref: s.appData.json.preferences }));
|
||||
export default function usePreferences<T = UserPreferences>(selector?: (v: UserPreferences) => T): T {
|
||||
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();
|
||||
|
||||
return {
|
||||
|
@ -2,10 +2,10 @@ import { socialGraphInstance } from "@snort/system";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import fuzzySearch from "@/Db/FuzzySearch";
|
||||
import useTimelineFeed from "@/Feed/TimelineFeed";
|
||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
||||
import { debounce } from "@/Utils";
|
||||
|
||||
const options = { method: "LIMIT_UNTIL" };
|
||||
const options: TimelineFeedOptions = { method: "LIMIT_UNTIL" };
|
||||
|
||||
export default function useProfileSearch(search: string) {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(search);
|
||||
@ -17,11 +17,12 @@ export default function useProfileSearch(search: string) {
|
||||
}, [search]);
|
||||
|
||||
const subject = useMemo(
|
||||
() => ({
|
||||
() =>
|
||||
({
|
||||
type: "profile_keyword",
|
||||
items: debouncedSearch ? [debouncedSearch] : [],
|
||||
discriminator: debouncedSearch,
|
||||
}),
|
||||
}) as TimelineSubject,
|
||||
[debouncedSearch],
|
||||
);
|
||||
const feed = useTimelineFeed(subject, options);
|
||||
|
6
packages/app/src/Hooks/useRelays.tsx
Normal file
6
packages/app/src/Hooks/useRelays.tsx
Normal 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;
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import useLogin from "./useLogin";
|
||||
import usePreferences from "./usePreferences";
|
||||
|
||||
export function useTheme() {
|
||||
const { preferences } = useLogin(s => ({ preferences: s.appData.json.preferences }));
|
||||
const theme = usePreferences(s => s.theme);
|
||||
|
||||
function setTheme(theme: "light" | "dark") {
|
||||
const elm = document.documentElement;
|
||||
@ -16,17 +16,15 @@ export function useTheme() {
|
||||
|
||||
useEffect(() => {
|
||||
const osTheme = window.matchMedia("(prefers-color-scheme: light)");
|
||||
setTheme(
|
||||
preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark",
|
||||
);
|
||||
setTheme(theme === "system" && osTheme.matches ? "light" : theme === "light" ? "light" : "dark");
|
||||
|
||||
osTheme.onchange = e => {
|
||||
if (preferences.theme === "system") {
|
||||
if (theme === "system") {
|
||||
setTheme(e.matches ? "light" : "dark");
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
osTheme.onchange = null;
|
||||
};
|
||||
}, [preferences.theme]);
|
||||
}, [theme]);
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import Toaster from "@/Components/Toaster/Toaster";
|
||||
import useLoginFeed from "@/Feed/LoginFeed";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { useLoginRelays } from "@/Hooks/useLoginRelays";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { useTheme } from "@/Hooks/useTheme";
|
||||
import { ArticlesCol, MediaCol, NotesCol, NotificationsCol } from "@/Pages/Deck/Columns";
|
||||
import NavSidebar from "@/Pages/Layout/NavSidebar";
|
||||
@ -40,8 +41,8 @@ export function SnortDeckLayout() {
|
||||
const login = useLogin(s => ({
|
||||
publicKey: s.publicKey,
|
||||
subscriptions: s.subscriptions,
|
||||
telemetry: s.appData.json.preferences.telemetry,
|
||||
}));
|
||||
const telemetry = usePreferences(s => s.telemetry);
|
||||
const navigate = useNavigate();
|
||||
const [deckState, setDeckState] = useState<DeckState>({
|
||||
thread: undefined,
|
||||
@ -60,7 +61,7 @@ export function SnortDeckLayout() {
|
||||
}, [login]);
|
||||
|
||||
useEffect(() => {
|
||||
if (CONFIG.features.analytics && (login.telemetry ?? true)) {
|
||||
if (CONFIG.features.analytics && (telemetry ?? true)) {
|
||||
trackEvent("pageview");
|
||||
}
|
||||
}, [location]);
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 classNames from "classnames";
|
||||
import { useMemo } from "react";
|
||||
@ -9,15 +9,22 @@ import { Link, useParams } from "react-router-dom";
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import Timeline from "@/Components/Feed/Timeline";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { setTags } from "@/Utils/Login";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
import { TimelineSubject } from "@/Feed/TimelineFeed";
|
||||
|
||||
const HashTagsPage = () => {
|
||||
const params = useParams();
|
||||
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 (
|
||||
<>
|
||||
@ -32,23 +39,10 @@ const HashTagsPage = () => {
|
||||
export default HashTagsPage;
|
||||
|
||||
export function HashTagHeader({ tag, events, className }: { tag: string; events?: number; className?: string }) {
|
||||
const login = useLogin();
|
||||
const state = useLogin(s => s.state);
|
||||
const isFollowing = useMemo(() => {
|
||||
return login.tags.item.includes(tag);
|
||||
}, [login, 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);
|
||||
}
|
||||
}
|
||||
return state.isOnList(EventKind.InterestsList, new NostrHashtagLink(tag));
|
||||
}, [state, tag]);
|
||||
|
||||
const sub = useMemo(() => {
|
||||
const rb = new RequestBuilder(`hashtag-counts:${tag}`);
|
||||
@ -78,11 +72,13 @@ export function HashTagHeader({ tag, events, className }: { tag: string; events?
|
||||
)}
|
||||
</div>
|
||||
{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" />
|
||||
</AsyncButton>
|
||||
) : (
|
||||
<AsyncButton onClick={() => followTags(login.tags.item.concat([tag]))}>
|
||||
<AsyncButton onClick={() => state.addToList(EventKind.InterestsList, new NostrHashtagLink(tag), true)}>
|
||||
<FormattedMessage defaultMessage="Follow" id="ieGrWo" />
|
||||
</AsyncButton>
|
||||
)}
|
||||
|
@ -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 classNames from "classnames";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
@ -13,6 +13,7 @@ import useLogin from "@/Hooks/useLogin";
|
||||
import { LogoHeader } from "@/Pages/Layout/LogoHeader";
|
||||
import NotificationsHeader from "@/Pages/Layout/NotificationsHeader";
|
||||
import { bech32ToHex } from "@/Utils";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
export function Header() {
|
||||
const navigate = useNavigate();
|
||||
@ -27,10 +28,17 @@ export function Header() {
|
||||
}
|
||||
}, [pageName]);
|
||||
|
||||
const { publicKey, tags } = useLogin();
|
||||
const { publicKey, tags } = useLogin(s => ({
|
||||
publicKey: s.publicKey,
|
||||
tags: s.state.getList(EventKind.InterestsList),
|
||||
}));
|
||||
|
||||
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]);
|
||||
|
||||
const scrollUp = useCallback(() => {
|
||||
|
@ -13,6 +13,7 @@ import { useCommunityLeaders } from "@/Hooks/useCommunityLeaders";
|
||||
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { useLoginRelays } from "@/Hooks/useLoginRelays";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { useTheme } from "@/Hooks/useTheme";
|
||||
import Footer from "@/Pages/Layout/Footer";
|
||||
import { Header } from "@/Pages/Layout/Header";
|
||||
@ -24,11 +25,11 @@ import RightColumn from "./RightColumn";
|
||||
|
||||
export default function Index() {
|
||||
const location = useLocation();
|
||||
const { id, stalker, telemetry } = useLogin(s => ({
|
||||
const { id, stalker } = useLogin(s => ({
|
||||
id: s.id,
|
||||
stalker: s.stalker ?? false,
|
||||
telemetry: s.appData.json.preferences.telemetry,
|
||||
}));
|
||||
const telemetry = usePreferences(s => s.telemetry);
|
||||
|
||||
useTheme();
|
||||
useLoginRelays();
|
||||
|
@ -8,6 +8,6 @@ import { LoginStore } from "@/Utils/Login";
|
||||
export const avatar = (node: NodeObject<NodeObject<GraphNode>>) => {
|
||||
const login = LoginStore.snapshot();
|
||||
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);
|
||||
};
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { EventExt, EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { EventKind, NostrLink, TaggedNostrEvent, Nip10 } from "@snort/system";
|
||||
|
||||
export function getNotificationContext(ev: TaggedNostrEvent) {
|
||||
switch (ev.kind) {
|
||||
@ -20,10 +19,10 @@ export function getNotificationContext(ev: TaggedNostrEvent) {
|
||||
}
|
||||
case EventKind.Repost:
|
||||
case EventKind.Reaction: {
|
||||
const thread = EventExt.extractThread(ev);
|
||||
const tag = unwrap(thread?.replyTo ?? thread?.root ?? { value: ev.id, key: "e" });
|
||||
if (tag.key === "e" || tag.key === "a") {
|
||||
return NostrLink.fromThreadTag(tag);
|
||||
const thread = Nip10.parseThread(ev);
|
||||
const tag = thread?.replyTo ?? thread?.root;
|
||||
if (tag) {
|
||||
return tag;
|
||||
} else {
|
||||
throw new Error("Unknown thread context");
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import { UserWebsiteLink } from "@/Components/User/UserWebsiteLink";
|
||||
import ZapModal from "@/Components/ZapModal/ZapModal";
|
||||
import useProfileBadges from "@/Feed/BadgesFeed";
|
||||
import useFollowsFeed from "@/Feed/FollowsFeed";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { MusicStatus } from "@/Pages/Profile/MusicStatus";
|
||||
|
||||
const ProfileDetails = ({
|
||||
@ -31,9 +31,9 @@ const ProfileDetails = ({
|
||||
lnurl?: LNURL;
|
||||
}) => {
|
||||
const follows = useFollowsFeed(id);
|
||||
const { showStatus, showBadges } = useLogin(s => ({
|
||||
showStatus: s.appData.json.preferences.showStatus ?? false,
|
||||
showBadges: s.appData.json.preferences.showBadges ?? false,
|
||||
const { showStatus, showBadges } = usePreferences(s => ({
|
||||
showStatus: s.showStatus ?? false,
|
||||
showBadges: s.showBadges ?? false,
|
||||
}));
|
||||
const [showLnQr, setShowLnQr] = useState<boolean>(false);
|
||||
const badges = useProfileBadges(showBadges ? id : undefined);
|
||||
|
@ -9,14 +9,11 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { ProxyImg } from "@/Components/ProxyImg";
|
||||
import { SpotlightMediaModal } from "@/Components/Spotlight/SpotlightMedia";
|
||||
import { Tab, TabSelector } from "@/Components/TabSelectors/TabSelectors";
|
||||
import BlockList from "@/Components/User/BlockList";
|
||||
import FollowsList from "@/Components/User/FollowListBase";
|
||||
import MutedList from "@/Components/User/MutedList";
|
||||
import useFollowsFeed from "@/Feed/FollowsFeed";
|
||||
import useHorizontalScroll from "@/Hooks/useHorizontalScroll";
|
||||
import { useMuteList } from "@/Hooks/useLists";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
import AvatarSection from "@/Pages/Profile/AvatarSection";
|
||||
import ProfileDetails from "@/Pages/Profile/ProfileDetails";
|
||||
import {
|
||||
@ -63,18 +60,13 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
|
||||
}, [user]);
|
||||
|
||||
// feeds
|
||||
const { blocked } = useModeration();
|
||||
const muted = useMuteList(id);
|
||||
const follows = useFollowsFeed(id);
|
||||
|
||||
// tabs
|
||||
const [tab, setTab] = useState<Tab>(ProfileTabSelectors.Notes);
|
||||
const optionalTabs = [
|
||||
ProfileTabSelectors.Zaps,
|
||||
ProfileTabSelectors.Relays,
|
||||
ProfileTabSelectors.Bookmarks,
|
||||
ProfileTabSelectors.Muted,
|
||||
].filter(a => unwrap(a)) as Tab[];
|
||||
const optionalTabs = [ProfileTabSelectors.Zaps, ProfileTabSelectors.Relays, ProfileTabSelectors.Bookmarks].filter(a =>
|
||||
unwrap(a),
|
||||
) as Tab[];
|
||||
const horizontalScroll = useHorizontalScroll();
|
||||
|
||||
useEffect(() => {
|
||||
@ -128,12 +120,6 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
|
||||
case ProfileTabType.FOLLOWERS: {
|
||||
return <FollowersTab id={id} />;
|
||||
}
|
||||
case ProfileTabType.MUTED: {
|
||||
return <MutedList pubkeys={muted.map(a => a.id)} />;
|
||||
}
|
||||
case ProfileTabType.BLOCKED: {
|
||||
return <BlockList />;
|
||||
}
|
||||
case ProfileTabType.RELAYS: {
|
||||
return <RelaysTab id={id} />;
|
||||
}
|
||||
@ -143,6 +129,9 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
|
||||
case ProfileTabType.REACTIONS: {
|
||||
return <ReactionsTab id={id} />;
|
||||
}
|
||||
case ProfileTabType.MUTED: {
|
||||
return <MutedList />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,7 +175,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
|
||||
ProfileTabSelectors.Follows,
|
||||
].map(renderTabSelector)}
|
||||
{optionalTabs.map(renderTabSelector)}
|
||||
{isMe && blocked.length > 0 && renderTabSelector(ProfileTabSelectors.Blocked)}
|
||||
{isMe && renderTabSelector(ProfileTabSelectors.Muted)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="main-content">{tabContent()}</div>
|
||||
|
@ -5,7 +5,6 @@ export enum ProfileTabType {
|
||||
FOLLOWS = 3,
|
||||
ZAPS = 4,
|
||||
MUTED = 5,
|
||||
BLOCKED = 6,
|
||||
RELAYS = 7,
|
||||
BOOKMARKS = 8,
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { RootTabRoutes } from "@/Pages/Root/RootTabRoutes";
|
||||
|
||||
export const DefaultTab = () => {
|
||||
const { preferences, publicKey } = useLogin(s => ({
|
||||
preferences: s.appData.json.preferences,
|
||||
const { publicKey } = useLogin(s => ({
|
||||
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;
|
||||
return elm ?? RootTabRoutes.find(a => a.path === preferences.defaultRootTab)?.element;
|
||||
return elm ?? RootTabRoutes.find(a => a.path === defaultRootTab)?.element;
|
||||
};
|
||||
|
@ -15,8 +15,9 @@ import messages from "@/Pages/messages";
|
||||
import { System } from "@/system";
|
||||
|
||||
const FollowsHint = () => {
|
||||
const { publicKey, contacts } = useLogin();
|
||||
if (contacts.length === 0 && publicKey) {
|
||||
const publicKey = useLogin(s => s.publicKey);
|
||||
const { followList } = useFollowsControls();
|
||||
if (followList.length === 0 && publicKey) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
{...messages.NoFollows}
|
||||
@ -30,7 +31,6 @@ const FollowsHint = () => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let forYouFeed = {
|
||||
@ -62,7 +62,11 @@ const getReactedByFollows = (follows: string[]) => {
|
||||
|
||||
export const ForYouTab = memo(function ForYouTab() {
|
||||
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 [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||
const navigationType = useNavigationType();
|
||||
@ -82,12 +86,12 @@ export const ForYouTab = memo(function ForYouTab() {
|
||||
items: followList,
|
||||
discriminator: login.publicKey?.slice(0, 12),
|
||||
extra: rb => {
|
||||
if (login.tags.item.length > 0) {
|
||||
rb.withFilter().kinds([EventKind.TextNote]).tag("t", login.tags.item);
|
||||
if (login.tags.length > 0) {
|
||||
rb.withFilter().kinds([EventKind.TextNote]).tags(login.tags);
|
||||
}
|
||||
},
|
||||
}) 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
|
||||
const latestFeed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
|
||||
@ -164,7 +168,7 @@ export const ForYouTab = memo(function ForYouTab() {
|
||||
const frags = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
events: combinedFeed,
|
||||
events: combinedFeed as Array<TaggedNostrEvent>,
|
||||
refTime: Date.now(),
|
||||
},
|
||||
];
|
||||
@ -175,7 +179,13 @@ export const ForYouTab = memo(function ForYouTab() {
|
||||
<DisplayAsSelector activeSelection={displayAs} onSelect={a => setDisplayAs(a)} />
|
||||
<FollowsHint />
|
||||
<TaskList />
|
||||
<TimelineRenderer frags={frags} latest={[]} displayAs={displayAs} loadMore={() => latestFeed.loadMore()} />
|
||||
<TimelineRenderer
|
||||
frags={frags}
|
||||
latest={[]}
|
||||
displayAs={displayAs}
|
||||
loadMore={() => latestFeed.loadMore()}
|
||||
showLatest={() => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -1,17 +1,19 @@
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { NostrEvent, NostrLink } from "@snort/system";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import TimelineFollows from "@/Components/Feed/TimelineFollows";
|
||||
import { TaskList } from "@/Components/Tasks/TaskList";
|
||||
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { DeckContext } from "@/Pages/Deck/DeckLayout";
|
||||
import messages from "@/Pages/messages";
|
||||
|
||||
const FollowsHint = () => {
|
||||
const { publicKey: pubKey, follows } = useLogin();
|
||||
if (follows.item?.length === 0 && pubKey) {
|
||||
const publicKey = useLogin(s => s.publicKey);
|
||||
const { followList } = useFollowsControls();
|
||||
if (followList.length === 0 && publicKey) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
{...messages.NoFollows}
|
||||
@ -25,15 +27,15 @@ const FollowsHint = () => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const NotesTab = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const deckContext = useContext(DeckContext);
|
||||
|
||||
const noteOnClick = useMemo(() => {
|
||||
if (deckContext) {
|
||||
return ev => {
|
||||
return (ev: NostrEvent) => {
|
||||
deckContext.setThread(NostrLink.fromEvent(ev));
|
||||
};
|
||||
}
|
||||
|
@ -2,15 +2,22 @@ import { useMemo } from "react";
|
||||
|
||||
import Timeline from "@/Components/Feed/Timeline";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { EventKind } from "@snort/system";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { TimelineSubject } from "@/Feed/TimelineFeed";
|
||||
|
||||
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(
|
||||
() => ({
|
||||
() =>
|
||||
({
|
||||
type: "hashtag",
|
||||
items: tags,
|
||||
items: tags.filter(a => a.toEventTag()?.[0] === "t").map(a => unwrap(a.toEventTag())[1]),
|
||||
discriminator: pubKey ?? "",
|
||||
}),
|
||||
}) as TimelineSubject,
|
||||
[tags, pubKey],
|
||||
);
|
||||
|
||||
|
@ -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 AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { ZapPoolTarget } from "@/Pages/ZapPool/ZapPoolTarget";
|
||||
import { bech32ToHex, getRelayName, trackEvent, unwrap } from "@/Utils";
|
||||
import { SnortPubKey } from "@/Utils/Const";
|
||||
@ -19,8 +19,8 @@ const DataProviders = [
|
||||
];
|
||||
|
||||
export function ZapPoolPageInner() {
|
||||
const login = useLogin();
|
||||
const { system } = useEventPublisher();
|
||||
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
|
||||
const system = useContext(SnortContext);
|
||||
const zapPool = useSyncExternalStore(
|
||||
c => unwrap(ZapPoolController).hook(c),
|
||||
() => unwrap(ZapPoolController).snapshot(),
|
||||
@ -39,7 +39,7 @@ export function ZapPoolPageInner() {
|
||||
})
|
||||
.filter(a => a !== undefined)
|
||||
.map(unwrap);
|
||||
}, [login.relays]);
|
||||
}, []);
|
||||
|
||||
const sumPending = zapPool.reduce((acc, v) => acc + v.sum, 0);
|
||||
return (
|
||||
@ -66,7 +66,7 @@ export function ZapPoolPageInner() {
|
||||
values={{
|
||||
number: (
|
||||
<b>
|
||||
<FormattedNumber value={login.appData.json.preferences.defaultZapAmount} />
|
||||
<FormattedNumber value={defaultZapAmount} />
|
||||
</b>
|
||||
),
|
||||
}}
|
||||
@ -79,14 +79,12 @@ export function ZapPoolPageInner() {
|
||||
values={{
|
||||
nIn: (
|
||||
<b>
|
||||
<FormattedNumber value={login.appData.json.preferences.defaultZapAmount} />
|
||||
<FormattedNumber value={defaultZapAmount} />
|
||||
</b>
|
||||
),
|
||||
nOut: (
|
||||
<b>
|
||||
<FormattedNumber
|
||||
value={ZapPoolController?.calcAllocation(login.appData.json.preferences.defaultZapAmount) ?? 0}
|
||||
/>
|
||||
<FormattedNumber value={ZapPoolController?.calcAllocation(defaultZapAmount) ?? 0} />
|
||||
</b>
|
||||
),
|
||||
}}
|
||||
|
@ -2,14 +2,13 @@ import { useUserProfile } from "@snort/system-react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
|
||||
import ProfilePreview from "@/Components/User/ProfilePreview";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { ZapPoolController, ZapPoolRecipient } from "@/Utils/ZapPoolController";
|
||||
|
||||
function ZapPoolTargetInner({ target }: { target: ZapPoolRecipient }) {
|
||||
const login = useLogin();
|
||||
const profile = useUserProfile(target.pubkey);
|
||||
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 (
|
||||
<ProfilePreview
|
||||
pubkey={target.pubkey}
|
||||
|
@ -5,15 +5,14 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { appendDedupe } from "@/Utils";
|
||||
import { SnortAppData, updateAppData } from "@/Utils/Login";
|
||||
import { useAllPreferences } from "@/Hooks/usePreferences";
|
||||
|
||||
export default function ModerationSettingsPage() {
|
||||
const login = useLogin();
|
||||
const state = useAllPreferences();
|
||||
const [muteWord, setMuteWord] = useState("");
|
||||
const appData = login.appData.json;
|
||||
const { system } = useEventPublisher();
|
||||
|
||||
function addMutedWord() {
|
||||
updateAppData(login.id, system, ad => ({
|
||||
updateAppData(login.id, ad => ({
|
||||
...ad,
|
||||
mutedWords: appendDedupe(appData.mutedWords, [muteWord]),
|
||||
}));
|
||||
@ -21,14 +20,14 @@ export default function ModerationSettingsPage() {
|
||||
}
|
||||
|
||||
const handleToggle = (setting: keyof SnortAppData) => {
|
||||
updateAppData(login.id, system, ad => ({
|
||||
updateAppData(login.id, ad => ({
|
||||
...ad,
|
||||
[setting]: !appData[setting],
|
||||
}));
|
||||
};
|
||||
|
||||
function removeMutedWord(word: string) {
|
||||
updateAppData(login.id, system, ad => ({
|
||||
updateAppData(login.id, ad => ({
|
||||
...ad,
|
||||
mutedWords: appData.mutedWords.filter(a => a !== word),
|
||||
}));
|
||||
|
@ -7,7 +7,7 @@ import { FormattedMessage, useIntl } from "react-intl";
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import { AllLanguageCodes } from "@/Components/IntlProvider/IntlProviderUtils";
|
||||
import { useLocale } from "@/Components/IntlProvider/useLocale";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { useAllPreferences } from "@/Hooks/usePreferences";
|
||||
import { unwrap } from "@/Utils";
|
||||
import { DefaultImgProxy } from "@/Utils/Const";
|
||||
import { UserPreferences } from "@/Utils/Login";
|
||||
@ -16,7 +16,7 @@ import messages from "./messages";
|
||||
|
||||
const PreferencesPage = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { preferences, update: updatePerf } = usePreferences();
|
||||
const { preferences, update: updatePerf } = useAllPreferences();
|
||||
const [pref, setPref] = useState<UserPreferences>(preferences);
|
||||
const [error, setError] = useState("");
|
||||
const { lang } = useLocale();
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import ProfilePreview from "@/Components/User/ProfilePreview";
|
||||
import useRelayState from "@/Feed/RelayState";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { parseId, unwrap } from "@/Utils";
|
||||
import { removeRelay } from "@/Utils/Login";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
@ -104,14 +104,13 @@ const RelayInfo = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex mt10 justify-end">
|
||||
<div
|
||||
className="btn error"
|
||||
onClick={() => {
|
||||
removeRelay(login, unwrap(conn).Address);
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
await login.state.removeRelay(unwrap(conn).Address, true);
|
||||
navigate("/settings/relays");
|
||||
}}>
|
||||
<FormattedMessage {...messages.Remove} />
|
||||
</div>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { unixNowMs, unwrap } from "@snort/shared";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
@ -7,31 +7,37 @@ import Relay from "@/Components/Relay/Relay";
|
||||
import SnortApi, { RelayDistance } from "@/External/SnortApi";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useRelays from "@/Hooks/useRelays";
|
||||
import { saveRelays } from "@/Pages/settings/saveRelays";
|
||||
import { getCountry, getRelayName, sanitizeRelayUrl } from "@/Utils";
|
||||
import { setRelays } from "@/Utils/Login";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
const RelaySettingsPage = () => {
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const login = useLogin();
|
||||
const relays = login.relays;
|
||||
const relays = useRelays();
|
||||
const { readonly, state } = useLogin(s => ({ state: s.state, readonly: s.readonly }));
|
||||
const [newRelay, setNewRelay] = useState<string>();
|
||||
|
||||
const otherConnections = useMemo(() => {
|
||||
return [...system.pool].filter(([k]) => relays.item[k] === undefined).map(([, v]) => v);
|
||||
}, [relays]);
|
||||
return [...system.pool].filter(([k]) => relays[k] === undefined).map(([, v]) => v);
|
||||
}, [system.pool, relays]);
|
||||
|
||||
const handleNewRelayChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = event.target.value;
|
||||
const protocol = window.location.protocol;
|
||||
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() {
|
||||
return (
|
||||
<div className="flex flex-col g8">
|
||||
@ -45,35 +51,24 @@ const RelaySettingsPage = () => {
|
||||
value={newRelay}
|
||||
onChange={handleNewRelayChange}
|
||||
/>
|
||||
<button className="secondary" onClick={() => addNewRelay()}>
|
||||
<AsyncButton className="secondary" onClick={() => addNewRelay()}>
|
||||
<FormattedMessage {...messages.Add} />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</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 (
|
||||
<div className="flex flex-col g8">
|
||||
<h3>
|
||||
<FormattedMessage {...messages.Relays} />
|
||||
</h3>
|
||||
<div className="flex flex-col g8">
|
||||
{Object.keys(relays.item || {}).map(a => (
|
||||
{Object.keys(relays || {}).map(a => (
|
||||
<Relay addr={a} key={a} />
|
||||
))}
|
||||
</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} />
|
||||
</AsyncButton>
|
||||
{addRelay()}
|
||||
@ -96,14 +91,22 @@ export function CloseRelays() {
|
||||
const [relays, setRecommendedRelays] = useState<Array<RelayDistance>>();
|
||||
const country = getCountry();
|
||||
const [location, setLocation] = useState<{ lat: number; lon: number }>(country);
|
||||
const login = useLogin();
|
||||
const relayUrls = Object.keys(login.relays.item);
|
||||
const currentRelays = useRelays();
|
||||
const state = useLogin(s => s.state);
|
||||
const relayUrls = Object.keys(currentRelays);
|
||||
|
||||
async function loadRelays() {
|
||||
const api = new SnortApi();
|
||||
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(() => {
|
||||
loadRelays().catch(console.error);
|
||||
}, [location]);
|
||||
@ -138,17 +141,7 @@ export function CloseRelays() {
|
||||
<div key={a.url} className="bg-dark p br flex flex-col g8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="bold">{getRelayName(a.url)}</div>
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
setRelays(
|
||||
login,
|
||||
{
|
||||
...login.relays.item,
|
||||
[a.url]: { read: true, write: true },
|
||||
},
|
||||
unixNowMs(),
|
||||
);
|
||||
}}>
|
||||
<AsyncButton onClick={() => addNewRelay(a.url)}>
|
||||
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
|
@ -17,14 +17,13 @@ const enum PruneStage {
|
||||
}
|
||||
|
||||
export function PruneFollowList() {
|
||||
const { followList: follows } = useFollowsControls();
|
||||
const followControls = useFollowsControls();
|
||||
const { system } = useEventPublisher();
|
||||
const uniqueFollows = dedupe(follows);
|
||||
const uniqueFollows = dedupe(followControls.followList);
|
||||
const [status, setStatus] = useState<PruneStage>();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [lastPost, setLastPosts] = useState<Record<string, number>>();
|
||||
const [unfollow, setUnfollow] = useState<Array<string>>([]);
|
||||
const followControls = useFollowsControls();
|
||||
|
||||
async function fetchLastPosts() {
|
||||
setStatus(PruneStage.FetchLastPostTimestamp);
|
||||
@ -121,8 +120,8 @@ export function PruneFollowList() {
|
||||
defaultMessage="{x} follows ({y} duplicates)"
|
||||
id="iICVoL"
|
||||
values={{
|
||||
x: follows.length,
|
||||
y: follows.length - uniqueFollows.length,
|
||||
x: followControls.followList.length,
|
||||
y: followControls.followList.length - uniqueFollows.length,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -6,6 +6,7 @@ import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useRelays from "@/Hooks/useRelays";
|
||||
import { SearchRelays } from "@/Utils/Const";
|
||||
|
||||
export default function SyncAccountTool() {
|
||||
@ -13,9 +14,10 @@ export default function SyncAccountTool() {
|
||||
const login = useLogin();
|
||||
const [scan, setScan] = useState<number>();
|
||||
const [results, setResults] = useState<Array<TaggedNostrEvent>>([]);
|
||||
const myRelays = useRelays();
|
||||
|
||||
async function start() {
|
||||
const relays = Object.entries(login.relays.item)
|
||||
const relays = Object.entries(myRelays)
|
||||
.filter(([, v]) => v.write)
|
||||
.map(([k]) => k);
|
||||
const sync = new RangeSync(system);
|
||||
|
@ -22,40 +22,6 @@ import { SubscriptionEvent } from "@/Utils/Subscription";
|
||||
|
||||
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) {
|
||||
LoginStore.removeSession(id);
|
||||
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) {
|
||||
const session = LoginStore.get(id);
|
||||
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) {
|
||||
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) {
|
||||
export async function setAppData(state: LoginSession, data: SnortAppData) {
|
||||
const pub = LoginStore.getPublisher(state.id);
|
||||
if (!pub) return;
|
||||
|
||||
await state.appData.updateJson(data, pub.signer, system);
|
||||
await state.state.setAppData(data);
|
||||
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);
|
||||
if (session) {
|
||||
const next = fn(session.appData.json);
|
||||
await setAppData(session, next, system);
|
||||
if (session?.state.appdata) {
|
||||
const next = fn(session.state.appdata);
|
||||
await setAppData(session, next);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 { UserPreferences } from "@/Utils/Login/index";
|
||||
@ -21,9 +21,6 @@ export const enum LoginSessionType {
|
||||
}
|
||||
|
||||
export interface SnortAppData {
|
||||
id: string;
|
||||
mutedWords: Array<string>;
|
||||
showContentWarningPosts: boolean;
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
@ -64,40 +61,7 @@ export interface LoginSession {
|
||||
*/
|
||||
publicKey?: HexKey;
|
||||
|
||||
/**
|
||||
* 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>>;
|
||||
state: UserState<SnortAppData>;
|
||||
|
||||
/**
|
||||
* Timestamp of last read notification
|
||||
@ -114,11 +78,6 @@ export interface LoginSession {
|
||||
*/
|
||||
remoteSignerRelays?: Array<string>;
|
||||
|
||||
/**
|
||||
* Snort application data
|
||||
*/
|
||||
appData: JsonEventSync<SnortAppData>;
|
||||
|
||||
/**
|
||||
* A list of chats which we have joined (NIP-28/NIP-29)
|
||||
*/
|
||||
|
@ -3,16 +3,14 @@ import * as utils from "@noble/curves/abstract/utils";
|
||||
import * as secp from "@noble/curves/secp256k1";
|
||||
import { ExternalStore, unwrap } from "@snort/shared";
|
||||
import {
|
||||
EventKind,
|
||||
EventPublisher,
|
||||
HexKey,
|
||||
JsonEventSync,
|
||||
KeyStorage,
|
||||
NostrLink,
|
||||
NostrPrefix,
|
||||
NotEncrypted,
|
||||
RelaySettings,
|
||||
socialGraphInstance,
|
||||
UserState,
|
||||
UserStateObject,
|
||||
} from "@snort/system";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
@ -29,8 +27,6 @@ const LoggedOut = {
|
||||
item: [],
|
||||
timestamp: 0,
|
||||
},
|
||||
contacts: [],
|
||||
follows: [],
|
||||
muted: {
|
||||
item: [],
|
||||
timestamp: 0,
|
||||
@ -54,18 +50,15 @@ const LoggedOut = {
|
||||
latestNotification: 0,
|
||||
readNotifications: 0,
|
||||
subscriptions: [],
|
||||
appData: new JsonEventSync<SnortAppData>(
|
||||
{
|
||||
id: "",
|
||||
preferences: DefaultPreferences,
|
||||
mutedWords: [],
|
||||
showContentWarningPosts: false,
|
||||
},
|
||||
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData),
|
||||
true,
|
||||
),
|
||||
extraChats: [],
|
||||
stalker: false,
|
||||
state: new UserState<SnortAppData>("", {
|
||||
initAppdata: {
|
||||
preferences: DefaultPreferences,
|
||||
},
|
||||
encryptAppdata: true,
|
||||
appdataId: "snort",
|
||||
}),
|
||||
} as LoginSession;
|
||||
|
||||
export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
@ -75,7 +68,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
|
||||
constructor() {
|
||||
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;
|
||||
}
|
||||
@ -103,14 +96,23 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
if (v.privateKeyData) {
|
||||
v.privateKeyData = KeyStorage.fromPayload(v.privateKeyData as object);
|
||||
}
|
||||
v.appData = new JsonEventSync<SnortAppData>(
|
||||
v.appData as unknown as SnortAppData,
|
||||
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, v.publicKey),
|
||||
true,
|
||||
const stateObj = v.state as unknown as UserStateObject<SnortAppData> | undefined;
|
||||
const stateClass = new UserState<SnortAppData>(
|
||||
v.publicKey!,
|
||||
{
|
||||
initAppdata: stateObj?.appdata ?? {
|
||||
preferences: {
|
||||
...DefaultPreferences,
|
||||
...CONFIG.defaultPreferences,
|
||||
},
|
||||
},
|
||||
encryptAppdata: true,
|
||||
appdataId: "snort",
|
||||
},
|
||||
stateObj,
|
||||
);
|
||||
v.appData.on("change", () => {
|
||||
this.#save();
|
||||
});
|
||||
stateClass.on("change", () => this.#save());
|
||||
v.state = stateClass;
|
||||
}
|
||||
this.#loadIrisKeyIfExists();
|
||||
}
|
||||
@ -174,24 +176,22 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
item: initRelays,
|
||||
timestamp: 1,
|
||||
},
|
||||
appData: new JsonEventSync<SnortAppData>(
|
||||
{
|
||||
id: "",
|
||||
state: new UserState<SnortAppData>(key, {
|
||||
initAppdata: {
|
||||
preferences: {
|
||||
...DefaultPreferences,
|
||||
...CONFIG.defaultPreferences,
|
||||
},
|
||||
mutedWords: [],
|
||||
showContentWarningPosts: false,
|
||||
},
|
||||
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, key),
|
||||
true,
|
||||
),
|
||||
encryptAppdata: true,
|
||||
appdataId: "snort",
|
||||
}),
|
||||
remoteSignerRelays,
|
||||
privateKeyData: privateKey,
|
||||
stalker: stalker ?? false,
|
||||
} as LoginSession;
|
||||
|
||||
newSession.state.on("change", () => this.#save());
|
||||
const pub = createPublisher(newSession);
|
||||
if (pub) {
|
||||
this.setPublisher(newSession.id, pub);
|
||||
@ -229,20 +229,18 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
item: initRelays,
|
||||
timestamp: 1,
|
||||
},
|
||||
appData: new JsonEventSync<SnortAppData>(
|
||||
{
|
||||
id: "",
|
||||
state: new UserState<SnortAppData>(pubKey, {
|
||||
initAppdata: {
|
||||
preferences: {
|
||||
...DefaultPreferences,
|
||||
...CONFIG.defaultPreferences,
|
||||
},
|
||||
mutedWords: [],
|
||||
showContentWarningPosts: false,
|
||||
},
|
||||
new NostrLink(NostrPrefix.Address, "snort", EventKind.AppData, pubKey),
|
||||
true,
|
||||
),
|
||||
encryptAppdata: true,
|
||||
appdataId: "snort",
|
||||
}),
|
||||
} as LoginSession;
|
||||
newSession.state.on("change", () => this.#save());
|
||||
|
||||
if ("nostr_os" in window && window?.nostr_os) {
|
||||
window?.nostr_os.saveKey(key.value);
|
||||
@ -300,14 +298,43 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
|
||||
#migrate() {
|
||||
let didMigrate = false;
|
||||
|
||||
// delete some old keys
|
||||
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;
|
||||
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) {
|
||||
toSave.push({
|
||||
...v,
|
||||
appData: v.appData.json,
|
||||
state: v.state instanceof UserState ? v.state.serialize() : v.state,
|
||||
privateKeyData: v.privateKeyData.toPayload(),
|
||||
});
|
||||
} else {
|
||||
toSave.push({
|
||||
...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));
|
||||
this.notifyChange();
|
||||
}
|
||||
|
@ -102,6 +102,11 @@ export interface UserPreferences {
|
||||
* Hides muted notes when selected
|
||||
*/
|
||||
hideMutedNotes: boolean;
|
||||
|
||||
/**
|
||||
* Show posts with content warning
|
||||
*/
|
||||
showContentWarningPosts: boolean;
|
||||
}
|
||||
|
||||
export const DefaultPreferences = {
|
||||
@ -123,4 +128,5 @@ export const DefaultPreferences = {
|
||||
checkSigs: true,
|
||||
autoTranslate: true,
|
||||
hideMutedNotes: false,
|
||||
showContentWarningPosts: false,
|
||||
} as UserPreferences;
|
||||
|
@ -1,18 +1,22 @@
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { EventExt, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { Nip10, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
/**
|
||||
* Get the chain key as a reply event
|
||||
*
|
||||
* ie. Get the key for which this event is replying to
|
||||
*/
|
||||
export function replyChainKey(ev: TaggedNostrEvent) {
|
||||
const t = EventExt.extractThread(ev);
|
||||
return t?.replyTo?.value ?? t?.root?.value;
|
||||
const t = Nip10.parseThread(ev);
|
||||
const tag = t?.replyTo ?? t?.root;
|
||||
return tag?.tagKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the chain key of this event
|
||||
*
|
||||
* ie. Get the key which ties replies to this event
|
||||
*/
|
||||
export function chainKey(ev: TaggedNostrEvent) {
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
return unwrap(link.toEventTag())[1];
|
||||
return link.tagKey;
|
||||
}
|
||||
|
@ -12,13 +12,13 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
|
||||
const location = useLocation();
|
||||
const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]);
|
||||
const feed = useThreadFeed(link);
|
||||
const { isBlocked } = useModeration();
|
||||
const { isMuted } = useModeration();
|
||||
|
||||
const chains = useMemo(() => {
|
||||
const chains = new Map<u256, Array<TaggedNostrEvent>>();
|
||||
if (feed) {
|
||||
feed
|
||||
?.filter(a => !isBlocked(a.pubkey))
|
||||
?.filter(a => !isMuted(a.pubkey))
|
||||
.forEach(v => {
|
||||
const replyTo = replyChainKey(v);
|
||||
if (replyTo) {
|
||||
@ -31,7 +31,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
|
||||
});
|
||||
}
|
||||
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
|
||||
const root = useMemo(() => {
|
||||
@ -46,7 +46,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
|
||||
return currentNote;
|
||||
}
|
||||
}
|
||||
}, [feed.length, currentId, location]);
|
||||
}, [feed, location.state, currentId]);
|
||||
|
||||
const ctxValue = useMemo<ThreadContextState>(() => {
|
||||
return {
|
||||
@ -56,7 +56,7 @@ export function ThreadContextWrapper({ link, children }: { link: NostrLink; chil
|
||||
data: feed,
|
||||
setCurrent: v => setCurrentId(v),
|
||||
};
|
||||
}, [root, chains]);
|
||||
}, [currentId, root, chains, feed]);
|
||||
|
||||
return <ThreadContext.Provider value={ctxValue}>{children}</ThreadContext.Provider>;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import usePreferences from "@/Hooks/usePreferences";
|
||||
import { bech32ToHex, unwrap } from "@/Utils";
|
||||
import { KieranPubKey } from "@/Utils/Const";
|
||||
import NostrBuild from "@/Utils/Upload/NostrBuild";
|
||||
@ -65,7 +65,7 @@ export interface UploadProgress {
|
||||
export type UploadStage = "starting" | "hashing" | "uploading" | "done" | undefined;
|
||||
|
||||
export default function useFileUpload(): Uploader {
|
||||
const fileUploader = useLogin(s => s.appData.json.preferences.fileUploader);
|
||||
const fileUploader = usePreferences(s => s.fileUploader);
|
||||
const { publisher } = useEventPublisher();
|
||||
const [progress, setProgress] = useState<Array<UploadProgress>>([]);
|
||||
const [stage, setStage] = useState<UploadStage>();
|
||||
|
@ -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 {
|
||||
return new URL(url).toString();
|
||||
return new URL(url!).toString();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -537,7 +538,7 @@ export function trackEvent(
|
||||
if (
|
||||
!import.meta.env.DEV &&
|
||||
CONFIG.features.analytics &&
|
||||
(LoginStore.snapshot().appData.json.preferences.telemetry ?? true)
|
||||
(LoginStore.snapshot().state.appdata?.preferences.telemetry ?? true)
|
||||
) {
|
||||
fetch("https://pa.v0l.io/api/event", {
|
||||
method: "POST",
|
||||
@ -547,9 +548,9 @@ export function trackEvent(
|
||||
body: JSON.stringify({
|
||||
d: CONFIG.hostname,
|
||||
n: event,
|
||||
r: document.referrer === location.href ? null : document.referrer,
|
||||
r: document.referrer === window.location.href ? null : document.referrer,
|
||||
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}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
@ -169,19 +169,19 @@ export function useChatSystem(chat: ChatSystem) {
|
||||
const login = useLogin();
|
||||
const sub = useMemo(() => {
|
||||
return chat.subscription(login);
|
||||
}, [login.publicKey]);
|
||||
}, [chat, login]);
|
||||
const data = useRequestBuilder(sub);
|
||||
const { isBlocked } = useModeration();
|
||||
const { isMuted } = useModeration();
|
||||
|
||||
return useMemo(() => {
|
||||
if (login.publicKey) {
|
||||
return chat.listChats(
|
||||
login.publicKey,
|
||||
data.filter(a => !isBlocked(a.pubkey)),
|
||||
data.filter(a => !isMuted(a.pubkey)),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}, [login.publicKey, data]);
|
||||
}, [chat, login, data, isMuted]);
|
||||
}
|
||||
|
||||
export function useChatSystems() {
|
||||
|
@ -12,7 +12,6 @@ import { ThreadRoute } from "@/Components/Event/Thread/ThreadRoute";
|
||||
import { IntlProvider } from "@/Components/IntlProvider/IntlProvider";
|
||||
import { db } from "@/Db";
|
||||
import { addCachedMetadataToFuzzySearch } from "@/Db/FuzzySearch";
|
||||
import { updateRelayConnections } from "@/Hooks/useLoginRelays";
|
||||
import { AboutPage } from "@/Pages/About";
|
||||
import { SnortDeckLayout } from "@/Pages/Deck/DeckLayout";
|
||||
import DonatePage from "@/Pages/Donate/DonatePage";
|
||||
@ -39,10 +38,10 @@ import { WalletSendPage } from "@/Pages/wallet/send";
|
||||
import ZapPoolPage from "@/Pages/ZapPool/ZapPool";
|
||||
import { System } from "@/system";
|
||||
import { storeRefCode, unwrap } from "@/Utils";
|
||||
import { LoginStore } from "@/Utils/Login";
|
||||
import { hasWasm, wasmInit, WasmPath } from "@/Utils/wasm";
|
||||
import { Wallets } from "@/Wallet";
|
||||
import { setupWebLNWalletConfig } from "@/Wallet";
|
||||
import { LoginStore } from "./Utils/Login";
|
||||
|
||||
async function initSite() {
|
||||
storeRefCode();
|
||||
@ -50,15 +49,14 @@ async function initSite() {
|
||||
await wasmInit(WasmPath);
|
||||
await initRelayWorker();
|
||||
}
|
||||
const login = LoginStore.takeSnapshot();
|
||||
updateRelayConnections(System, login.relays.item).catch(console.error);
|
||||
|
||||
setupWebLNWalletConfig(Wallets);
|
||||
|
||||
db.ready = await db.isAvailable();
|
||||
if (db.ready) {
|
||||
const pTags = login.contacts.filter(a => a[0] === "p").map(a => a[1]);
|
||||
await preload(pTags);
|
||||
await System.PreloadSocialGraph();
|
||||
const login = LoginStore.snapshot();
|
||||
preload(login.state.follows); // dont await this
|
||||
System.PreloadSocialGraph(); // dont await this
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
|
@ -1,10 +1,6 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { CacheableResponsePlugin } from "workbox-cacheable-response";
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: (string | PrecacheEntry)[];
|
||||
};
|
||||
|
||||
import { encodeTLVEntries, NostrLink, NostrPrefix, TLVEntryType, tryParseNostrLink } from "@snort/system";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
import { ExpirationPlugin } from "workbox-expiration";
|
||||
@ -15,12 +11,16 @@ import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies";
|
||||
import { defaultAvatar, hexToBech32 } from "@/Utils";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: (string | PrecacheEntry)[];
|
||||
};
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
clientsClaim();
|
||||
|
||||
// cache everything in current domain /assets because precache doesn't seem to include everything
|
||||
registerRoute(
|
||||
({ url }) => url.origin === location.origin && url.pathname.startsWith("/assets"),
|
||||
({ url }) => url.origin === window.location.origin && url.pathname.startsWith("/assets"),
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: "assets-cache",
|
||||
plugins: [
|
||||
|
@ -201,7 +201,7 @@ export async function fetchNostrAddress(name: string, domain: string, timeout =
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -5,7 +5,6 @@ import { EventEmitter } from "eventemitter3";
|
||||
import { Connection, RelaySettings } from "./connection";
|
||||
import { NostrEvent, OkResponse, TaggedNostrEvent } from "./nostr";
|
||||
import { SystemInterface } from ".";
|
||||
import LRUSet from "@snort/shared/src/LRUSet";
|
||||
|
||||
export interface NostrConnectionPoolEvents {
|
||||
connected: (address: string, wasReconnect: boolean) => void;
|
||||
@ -38,7 +37,6 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
|
||||
* All currently connected websockets
|
||||
*/
|
||||
#sockets = new Map<string, Connection>();
|
||||
#requestedIds = new LRUSet<string>(1000);
|
||||
|
||||
constructor(system: SystemInterface) {
|
||||
super();
|
||||
@ -49,7 +47,8 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
|
||||
* Get a connection object from the pool
|
||||
*/
|
||||
getConnection(id: string) {
|
||||
return this.#sockets.get(id);
|
||||
const addr = unwrap(sanitizeRelayUrl(id));
|
||||
return this.#sockets.get(addr);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,6 +51,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
#activity: number = unixNowMs();
|
||||
#expectAuth = false;
|
||||
#ephemeral: boolean;
|
||||
#closing = false;
|
||||
#downCount = 0;
|
||||
#activeRequests = new Set<string>();
|
||||
|
||||
Id: string;
|
||||
readonly Address: string;
|
||||
@ -58,26 +61,22 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
|
||||
PendingRaw: Array<object> = [];
|
||||
PendingRequests: Array<ConnectionQueueItem> = [];
|
||||
ActiveRequests = new Set<string>();
|
||||
|
||||
Settings: RelaySettings;
|
||||
Info?: RelayInfo;
|
||||
ConnectTimeout: number = DefaultConnectTimeout;
|
||||
HasStateChange: boolean = true;
|
||||
IsClosed: boolean;
|
||||
ReconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
EventsCallback: Map<u256, (msg: Array<string | boolean>) => void>;
|
||||
|
||||
AwaitingAuth: Map<string, boolean>;
|
||||
Authed = false;
|
||||
Down = true;
|
||||
|
||||
constructor(addr: string, options: RelaySettings, ephemeral: boolean = false) {
|
||||
super();
|
||||
this.Id = uuid();
|
||||
this.Address = addr;
|
||||
this.Settings = options;
|
||||
this.IsClosed = false;
|
||||
this.EventsCallback = new Map();
|
||||
this.AwaitingAuth = new Map();
|
||||
this.#ephemeral = ephemeral;
|
||||
@ -93,7 +92,20 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
this.#setupEphemeral();
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.Socket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
get isDown() {
|
||||
return this.#downCount > 0;
|
||||
}
|
||||
|
||||
get ActiveRequests() {
|
||||
return [...this.#activeRequests];
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.isOpen) return;
|
||||
try {
|
||||
if (this.Info === undefined) {
|
||||
const u = new URL(this.Address);
|
||||
@ -116,7 +128,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
// ignored
|
||||
}
|
||||
|
||||
const wasReconnect = this.Socket !== null && !this.IsClosed;
|
||||
const wasReconnect = this.Socket !== null;
|
||||
if (this.Socket) {
|
||||
this.Id = uuid();
|
||||
this.Socket.onopen = null;
|
||||
@ -125,7 +137,6 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
this.Socket.onclose = null;
|
||||
this.Socket = null;
|
||||
}
|
||||
this.IsClosed = false;
|
||||
this.Socket = new WebSocket(this.Address);
|
||||
this.Socket.onopen = () => this.#onOpen(wasReconnect);
|
||||
this.Socket.onmessage = e => this.#onMessage(e);
|
||||
@ -133,32 +144,45 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
this.Socket.onclose = e => this.#onClose(e);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.IsClosed = true;
|
||||
close(final = true) {
|
||||
if (final) {
|
||||
this.#closing = true;
|
||||
}
|
||||
this.Socket?.close();
|
||||
}
|
||||
|
||||
#onOpen(wasReconnect: boolean) {
|
||||
this.ConnectTimeout = DefaultConnectTimeout;
|
||||
this.#downCount = 0;
|
||||
this.#log(`Open!`);
|
||||
this.Down = false;
|
||||
this.#setupEphemeral();
|
||||
this.emit("connected", wasReconnect);
|
||||
this.#sendPendingRaw();
|
||||
}
|
||||
|
||||
#onClose(e: WebSocket.CloseEvent) {
|
||||
// remote server closed the connection, dont re-connect
|
||||
if (!this.#closing) {
|
||||
this.#downCount++;
|
||||
this.#reconnectTimer(e);
|
||||
} else {
|
||||
this.#log(`Closed!`);
|
||||
this.#downCount = 0;
|
||||
if (this.ReconnectTimer) {
|
||||
clearTimeout(this.ReconnectTimer);
|
||||
this.ReconnectTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// remote server closed the connection, dont re-connect
|
||||
if (e.code === 4000) {
|
||||
this.IsClosed = true;
|
||||
this.#log(`Closed! (Remote)`);
|
||||
} else if (!this.IsClosed) {
|
||||
this.ConnectTimeout = this.ConnectTimeout * this.ConnectTimeout;
|
||||
this.emit("disconnect", e.code);
|
||||
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`,
|
||||
);
|
||||
@ -169,14 +193,6 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
this.emit("disconnect", -1);
|
||||
}
|
||||
}, this.ConnectTimeout);
|
||||
// todo: stats disconnect
|
||||
} else {
|
||||
this.#log(`Closed!`);
|
||||
this.ReconnectTimer = undefined;
|
||||
}
|
||||
|
||||
this.emit("disconnect", e.code);
|
||||
this.#reset();
|
||||
}
|
||||
|
||||
#onMessage(e: WebSocket.MessageEvent) {
|
||||
@ -332,14 +348,13 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
this.#expectAuth = true;
|
||||
this.#log("Setting expectAuth flag %o", requestKinds);
|
||||
}
|
||||
if (this.ActiveRequests.size >= this.#maxSubscriptions) {
|
||||
if (this.#activeRequests.size >= this.#maxSubscriptions) {
|
||||
this.PendingRequests.push({
|
||||
obj: cmd,
|
||||
cb: cbSent,
|
||||
});
|
||||
this.#log("Queuing: %O", cmd);
|
||||
} else {
|
||||
this.ActiveRequests.add(cmd[1]);
|
||||
this.#sendRequestCommand({
|
||||
obj: cmd,
|
||||
cb: cbSent,
|
||||
@ -350,7 +365,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
}
|
||||
|
||||
closeReq(id: string) {
|
||||
if (this.ActiveRequests.delete(id)) {
|
||||
if (this.#activeRequests.delete(id)) {
|
||||
this.send(["CLOSE", id]);
|
||||
this.emit("eose", id);
|
||||
this.#sendQueuedRequests();
|
||||
@ -359,7 +374,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
}
|
||||
|
||||
#sendQueuedRequests() {
|
||||
const canSend = this.#maxSubscriptions - this.ActiveRequests.size;
|
||||
const canSend = this.#maxSubscriptions - this.#activeRequests.size;
|
||||
if (canSend > 0) {
|
||||
for (let x = 0; x < canSend; x++) {
|
||||
const p = this.PendingRequests.shift();
|
||||
@ -375,12 +390,12 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
try {
|
||||
const cmd = item.obj;
|
||||
if (cmd[0] === "REQ" || cmd[0] === "GET") {
|
||||
this.ActiveRequests.add(cmd[1]);
|
||||
this.#activeRequests.add(cmd[1]);
|
||||
this.send(cmd);
|
||||
} else if (cmd[0] === "SYNC") {
|
||||
const [_, id, eventSet, ...filters] = cmd;
|
||||
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);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
};
|
||||
if (this.Address.startsWith("wss://relay.snort.social")) {
|
||||
if (this.Info?.negentropy === "v1") {
|
||||
const newFilters = filters;
|
||||
const neg = new NegentropyFlow(id, this, eventSet, newFilters);
|
||||
neg.once("finish", filters => {
|
||||
@ -419,19 +434,34 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
// reset connection Id on disconnect, for query-tracking
|
||||
this.Id = uuid();
|
||||
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.PendingRaw = [];
|
||||
|
||||
this.emit("change");
|
||||
}
|
||||
|
||||
send(obj: object) {
|
||||
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);
|
||||
if (this.Socket?.readyState === WebSocket.CLOSED && this.Ephemeral && this.IsClosed) {
|
||||
this.connect();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -503,11 +533,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
||||
if (this.Ephemeral) {
|
||||
this.#ephemeralCheck = setInterval(() => {
|
||||
const lastActivity = unixNowMs() - this.#activity;
|
||||
if (lastActivity > 30_000 && !this.IsClosed) {
|
||||
if (this.ActiveRequests.size > 0) {
|
||||
this.#log("Inactive connection has %d active requests! %O", this.ActiveRequests.size, this.ActiveRequests);
|
||||
if (lastActivity > 30_000 && !this.#closing) {
|
||||
if (this.#activeRequests.size > 0) {
|
||||
this.#log(
|
||||
"Inactive connection has %d active requests! %O",
|
||||
this.#activeRequests.size,
|
||||
this.#activeRequests,
|
||||
);
|
||||
} else {
|
||||
this.close();
|
||||
this.close(false);
|
||||
}
|
||||
}
|
||||
}, 5_000);
|
||||
|
@ -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 { getPublicKey, jitter, unixNow } from "@snort/shared";
|
||||
import { EventExt } from "./event-ext";
|
||||
@ -14,6 +14,10 @@ export class EventBuilder {
|
||||
#powMiner?: PowMiner;
|
||||
#jitter?: number;
|
||||
|
||||
get pubkey() {
|
||||
return this.#pubkey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate builder with values from link
|
||||
*/
|
||||
|
@ -11,6 +11,7 @@ export interface Tag {
|
||||
value?: string;
|
||||
relay?: string;
|
||||
marker?: string; // NIP-10
|
||||
author?: string; // NIP-10 "pubkey-stub"
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
@ -48,9 +49,6 @@ export abstract class EventExt {
|
||||
|
||||
const sig = secp.schnorr.sign(e.id, key);
|
||||
e.sig = utils.bytesToHex(sig);
|
||||
if (!secp.schnorr.verify(e.sig, e.id, e.pubkey)) {
|
||||
throw new Error("Signing failed");
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
@ -102,10 +100,15 @@ export abstract class EventExt {
|
||||
value: tag[1],
|
||||
} as Tag;
|
||||
switch (ret.key) {
|
||||
case "a":
|
||||
case "a": {
|
||||
ret.relay = tag[2];
|
||||
ret.marker = tag[3];
|
||||
break;
|
||||
}
|
||||
case "e": {
|
||||
ret.relay = tag.length > 2 ? tag[2] : undefined;
|
||||
ret.marker = tag.length > 3 ? tag[3] : undefined;
|
||||
ret.relay = tag[2];
|
||||
ret.marker = tag[3];
|
||||
ret.author = tag[4];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
PowMiner,
|
||||
PrivateKeySigner,
|
||||
RelaySettings,
|
||||
settingsToRelayTag,
|
||||
SignerSupports,
|
||||
TaggedNostrEvent,
|
||||
ToNostrEventTag,
|
||||
@ -23,10 +24,10 @@ import {
|
||||
} from ".";
|
||||
|
||||
import { EventBuilder } from "./event-builder";
|
||||
import { EventExt } from "./event-ext";
|
||||
import { findTag } from "./utils";
|
||||
import { Nip7Signer } from "./impl/nip7";
|
||||
import { base64 } from "@scure/base";
|
||||
import { Nip10 } from "./impl/nip10";
|
||||
|
||||
type EventBuilderHook = (ev: EventBuilder) => EventBuilder;
|
||||
|
||||
@ -202,29 +203,7 @@ export class EventPublisher {
|
||||
const eb = this.#eb(EventKind.TextNote);
|
||||
eb.content(msg);
|
||||
|
||||
const link = NostrLink.fromEvent(replyTo);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Nip10.replyTo(replyTo, eb);
|
||||
eb.processContent();
|
||||
fnExtra?.(eb);
|
||||
return await this.#sign(eb);
|
||||
@ -247,15 +226,9 @@ export class EventPublisher {
|
||||
}
|
||||
const eb = this.#eb(EventKind.Relays);
|
||||
for (const rx of relays) {
|
||||
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) {
|
||||
eb.tag(rTag);
|
||||
const tag = settingsToRelayTag(rx);
|
||||
if (tag) {
|
||||
eb.tag(tag);
|
||||
}
|
||||
}
|
||||
return await this.#sign(eb);
|
||||
|
73
packages/system/src/impl/nip10.ts
Normal file
73
packages/system/src/impl/nip10.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 { 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 ParsedZapCache = new LRUCache<string, ParsedZap>({ max: 1000 });
|
||||
@ -35,7 +35,7 @@ export function parseZap(zapReceipt: NostrEvent): ParsedZap {
|
||||
// old format, ignored
|
||||
throw new Error("deprecated zap format");
|
||||
}
|
||||
const zapRequestThread = EventExt.extractThread(zapRequest);
|
||||
const zapRequestThread = Nip10.parseThread(zapRequest);
|
||||
const requestContext = zapRequestThread?.root;
|
||||
|
||||
const anonZap = zapRequest.tags.find(a => a[0] === "anon");
|
||||
@ -44,7 +44,7 @@ export function parseZap(zapReceipt: NostrEvent): ParsedZap {
|
||||
id: zapReceipt.id,
|
||||
zapService: zapReceipt.pubkey,
|
||||
amount: (invoice?.amount ?? 0) / 1000,
|
||||
event: requestContext ? NostrLink.fromThreadTag(requestContext) : undefined,
|
||||
event: requestContext,
|
||||
sender: zapRequest.pubkey,
|
||||
receiver: findTag(zapRequest, "p"),
|
||||
valid: true,
|
@ -30,7 +30,7 @@ export * from "./event-publisher";
|
||||
export * from "./event-builder";
|
||||
export * from "./nostr-link";
|
||||
export * from "./profile-cache";
|
||||
export * from "./zaps";
|
||||
export * from "./impl/nip57";
|
||||
export * from "./signer";
|
||||
export * from "./text";
|
||||
export * from "./pow";
|
||||
@ -39,11 +39,14 @@ export * from "./query-optimizer";
|
||||
export * from "./encrypted";
|
||||
export * from "./outbox";
|
||||
export * from "./sync";
|
||||
export * from "./user-state";
|
||||
|
||||
export * from "./impl/nip4";
|
||||
export * from "./impl/nip44";
|
||||
export * from "./impl/nip7";
|
||||
export * from "./impl/nip10";
|
||||
export * from "./impl/nip44";
|
||||
export * from "./impl/nip46";
|
||||
export * from "./impl/nip57";
|
||||
|
||||
export * from "./cache/index";
|
||||
export * from "./cache/user-relays";
|
||||
|
@ -19,7 +19,7 @@ export interface ToNostrEventTag {
|
||||
export class NostrHashtagLink implements ToNostrEventTag {
|
||||
constructor(readonly tag: string) {}
|
||||
|
||||
toEventTag(): string[] | undefined {
|
||||
toEventTag() {
|
||||
return ["t", this.tag];
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,7 @@ export class NostrLink implements ToNostrEventTag {
|
||||
readonly kind?: number,
|
||||
readonly author?: string,
|
||||
readonly relays?: Array<string>,
|
||||
readonly marker?: string,
|
||||
) {
|
||||
if (type !== NostrPrefix.Address && !isHex(id)) {
|
||||
throw new Error("ID must be hex");
|
||||
@ -52,22 +53,43 @@ export class NostrLink implements ToNostrEventTag {
|
||||
}
|
||||
}
|
||||
|
||||
toEventTag(marker?: string) {
|
||||
const relayEntry = this.relays?.at(0) ? [this.relays[0]] : [];
|
||||
|
||||
if (marker) {
|
||||
if (relayEntry.length === 0) {
|
||||
relayEntry.push("");
|
||||
get tagKey() {
|
||||
if (this.type === NostrPrefix.Address) {
|
||||
return `${this.kind}:${this.author}:${this.id}`;
|
||||
}
|
||||
relayEntry.push(marker);
|
||||
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 (suffix[0] === undefined) {
|
||||
suffix.push(""); // empty relay hint
|
||||
}
|
||||
suffix.push(marker);
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const relay = tag.relay ? [tag.relay] : undefined;
|
||||
|
||||
switch (tag.key) {
|
||||
case "e": {
|
||||
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) {
|
||||
static fromTag<T = NostrLink>(
|
||||
tag: Array<string>,
|
||||
author?: string,
|
||||
kind?: number,
|
||||
fnOther?: (tag: Array<string>) => T,
|
||||
) {
|
||||
const relays = tag.length > 2 ? [tag[2]] : undefined;
|
||||
switch (tag[0]) {
|
||||
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": {
|
||||
return new NostrLink(NostrPrefix.Profile, tag[1], kind, author, relays);
|
||||
}
|
||||
case "a": {
|
||||
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]}`);
|
||||
}
|
||||
|
||||
static fromTags(tags: Array<Array<string>>) {
|
||||
static fromTags<T = NostrLink>(tags: ReadonlyArray<Array<string>>, fnOther?: (tag: Array<string>) => T) {
|
||||
return removeUndefined(
|
||||
tags.map(a => {
|
||||
try {
|
||||
return NostrLink.fromTag(a);
|
||||
return NostrLink.fromTag<T>(a, undefined, undefined, fnOther);
|
||||
} catch {
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -70,8 +70,7 @@ export class KeyedReplaceableNoteStore extends HookedNoteStore {
|
||||
ev.forEach(a => {
|
||||
const keyOnEvent = this.#keyFn(a);
|
||||
const existing = this.#events.get(keyOnEvent);
|
||||
const existingCreated = existing?.created_at ?? 0;
|
||||
if (a.created_at > existingCreated) {
|
||||
if (a.created_at > (existing?.created_at ?? 0)) {
|
||||
if (existing) {
|
||||
a.relays = dedupe([...existing.relays, ...a.relays]);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { EventKind, FullRelaySettings, NostrEvent, SystemInterface, UsersRelays } from "..";
|
||||
import { sanitizeRelayUrl } from "@snort/shared";
|
||||
import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
|
||||
|
||||
export const DefaultPickNRelays = 2;
|
||||
|
||||
@ -20,6 +20,7 @@ export type EventFetcher = {
|
||||
};
|
||||
|
||||
export function parseRelayTag(tag: Array<string>) {
|
||||
if (tag[0] !== "r") return;
|
||||
return {
|
||||
url: sanitizeRelayUrl(tag[1]),
|
||||
settings: {
|
||||
@ -30,7 +31,7 @@ export function parseRelayTag(tag: 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) {
|
||||
@ -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 "./relay-loader";
|
||||
|
@ -182,6 +182,16 @@ export class OutboxModel extends BaseRequestRouter {
|
||||
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
|
||||
* @param authors The authors to update relay lists for
|
||||
|
@ -4,6 +4,7 @@ import { BuiltRawReqFilter, RequestBuilder, SystemInterface, TaggedNostrEvent }
|
||||
import { Query, TraceReport } from "./query";
|
||||
import { FilterCacheLayer } from "./filter-cache-layer";
|
||||
import { trimFilters } from "./request-trim";
|
||||
import { eventMatchesFilter } from "./request-matcher";
|
||||
|
||||
interface QueryManagerEvents {
|
||||
change: () => void;
|
||||
@ -75,26 +76,19 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
|
||||
* Async fetch results
|
||||
*/
|
||||
async fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void) {
|
||||
const q = new Query(this.#system, req);
|
||||
q.on("trace", r => this.emit("trace", r));
|
||||
q.on("request", (subId, fx) => {
|
||||
this.#send(q, fx);
|
||||
});
|
||||
const filters = req.buildRaw();
|
||||
const q = this.query(req);
|
||||
if (cb) {
|
||||
q.on("event", evs => cb(evs));
|
||||
q.on("event", cb);
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
q.on("loading", loading => {
|
||||
this.#log("loading %s %o", q.id, loading);
|
||||
if (!loading) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
q.once("done", resolve);
|
||||
});
|
||||
const results = q.feed.takeSnapshot();
|
||||
q.cleanup();
|
||||
this.#log("Fetch results for %s %o", q.id, results);
|
||||
return results;
|
||||
if (cb) {
|
||||
q.off("event", cb);
|
||||
}
|
||||
return results.filter(a => filters.some(b => eventMatchesFilter(a, b)));
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
@ -112,6 +106,7 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
|
||||
const data = await this.#system.cacheRelay.query(["REQ", q.id, ...qSend.filters]);
|
||||
if (data.length > 0) {
|
||||
qSend.syncFrom = data as Array<TaggedNostrEvent>;
|
||||
this.#log("Adding from cache: %O", data);
|
||||
q.feed.add(data as Array<TaggedNostrEvent>);
|
||||
}
|
||||
}
|
||||
@ -138,6 +133,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
|
||||
const qt = q.sendToRelay(s, qSend);
|
||||
if (qt) {
|
||||
return [qt];
|
||||
} else {
|
||||
this.#log("Query not sent to %s: %O", qSend.relay, qSend);
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
if (qt) {
|
||||
return [qt];
|
||||
} else {
|
||||
this.#log("Query not sent to %s: %O", qSend.relay, qSend);
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
if (qt) {
|
||||
ret.push(qt);
|
||||
} else {
|
||||
this.#log("Query not sent to %s: %O", a, qSend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -99,11 +99,11 @@ export interface TraceReport {
|
||||
}
|
||||
|
||||
export interface QueryEvents {
|
||||
loading: (v: boolean) => void;
|
||||
trace: (report: TraceReport) => void;
|
||||
request: (subId: string, req: BuiltRawReqFilter) => void;
|
||||
event: (evs: Array<TaggedNostrEvent>) => void;
|
||||
end: () => void;
|
||||
done: () => void;
|
||||
}
|
||||
|
||||
const QueryCache = new LRUCache<string, Array<TaggedNostrEvent>>({
|
||||
@ -357,7 +357,7 @@ export class Query extends EventEmitter<QueryEvents> {
|
||||
const isFinished = this.progress === 1;
|
||||
if (isFinished) {
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
// connection is down, dont send
|
||||
if (c.isDown) {
|
||||
return false;
|
||||
}
|
||||
// cannot send unless relay is tagged on ephemeral relay connection
|
||||
if (!q.relay && c.Ephemeral) {
|
||||
this.#log("Cant send non-specific REQ to ephemeral connection %O %O %O", q, q.relay, c);
|
||||
|
@ -18,4 +18,5 @@ export interface RelayInfo {
|
||||
language_tags?: Array<string>;
|
||||
tags?: Array<string>;
|
||||
posting_policy?: string;
|
||||
negentropy?: string;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid";
|
||||
import { appendDedupe, dedupe, removeUndefined, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
|
||||
|
||||
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 { RequestRouter } from "./request-router";
|
||||
|
||||
@ -230,6 +230,19 @@ export class RequestFilterBuilder {
|
||||
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) {
|
||||
if (!keyword) return this;
|
||||
this.#filter.search = keyword;
|
||||
|
@ -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.
|
||||
*/
|
||||
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
|
||||
* @param ev The reply event to send
|
||||
@ -39,6 +48,7 @@ export interface 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 forRequest(filter: ReqFilter, pickN?: number): Array<ReqFilter>;
|
||||
abstract forFlatRequest(filter: FlatReqFilter[], pickN?: number): Array<FlatReqFilter>;
|
||||
|
@ -4,7 +4,7 @@ import { EventExt } from "./event-ext";
|
||||
import { Nip4WebCryptoEncryptor } from "./impl/nip4";
|
||||
import { XChaCha20Encryptor } from "./impl/nip44";
|
||||
import { MessageEncryptorVersion, decodeEncryptionPayload, encodeEncryptionPayload } from "./index";
|
||||
import { NostrEvent } from "./nostr";
|
||||
import { NostrEvent, NotSignedNostrEvent } from "./nostr";
|
||||
import { base64 } from "@scure/base";
|
||||
|
||||
export type SignerSupports = "nip04" | "nip44" | string;
|
||||
@ -16,7 +16,7 @@ export interface EventSigner {
|
||||
nip4Decrypt(content: string, otherKey: string): Promise<string>;
|
||||
nip44Encrypt(content: string, key: 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>;
|
||||
}
|
||||
|
||||
|
@ -1,100 +1,210 @@
|
||||
import { EventBuilder, EventSigner, NostrLink, SystemInterface } from "..";
|
||||
import { SafeSync } from "./safe-sync";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { EventBuilder, EventSigner, NostrEvent, NostrLink, NotSignedNostrEvent, SystemInterface, Tag } from "..";
|
||||
import { SafeSync, SafeSyncEvents } from "./safe-sync";
|
||||
import debug from "debug";
|
||||
|
||||
interface TagDiff {
|
||||
type: "add" | "remove" | "replace";
|
||||
type: "add" | "remove" | "replace" | "update";
|
||||
tag: Array<string> | Array<Array<string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/Remove tags from event
|
||||
*/
|
||||
export class DiffSyncTags {
|
||||
export class DiffSyncTags extends EventEmitter<SafeSyncEvents> {
|
||||
#log = debug("DiffSyncTags");
|
||||
#sync = new SafeSync();
|
||||
#sync: SafeSync;
|
||||
#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(tag: Array<string> | Array<Array<string>>) {
|
||||
this.#changes.push({
|
||||
add(tag: Array<string> | Array<Array<string>>, encrypted = false) {
|
||||
(encrypted ? this.#changesEncrypted : this.#changes).push({
|
||||
type: "add",
|
||||
tag,
|
||||
});
|
||||
this.emit("change");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tag
|
||||
*/
|
||||
remove(tag: Array<string> | Array<Array<string>>) {
|
||||
this.#changes.push({
|
||||
remove(tag: Array<string> | Array<Array<string>>, encrypted = false) {
|
||||
(encrypted ? this.#changesEncrypted : this.#changes).push({
|
||||
type: "remove",
|
||||
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(tag: Array<Array<string>>) {
|
||||
this.#changes.push({
|
||||
replace(tag: Array<Array<string>>, encrypted = false) {
|
||||
(encrypted ? this.#changesEncrypted : this.#changes).push({
|
||||
type: "replace",
|
||||
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
|
||||
*/
|
||||
async persist(signer: EventSigner, system: SystemInterface, content?: string) {
|
||||
const cloneChanges = [...this.#changes];
|
||||
this.#changes = [];
|
||||
if (!this.#sync.didSync) {
|
||||
await this.sync(signer, system);
|
||||
}
|
||||
|
||||
// always start with sync
|
||||
const res = await this.#sync.sync(this.link, system);
|
||||
const isNew = this.#sync.value === undefined;
|
||||
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 next = res ? { ...res } : undefined;
|
||||
let next = this.#sync.value ? { ...this.#sync.value } : undefined;
|
||||
if (!next) {
|
||||
const eb = new EventBuilder();
|
||||
eb.fromLink(this.link);
|
||||
next = eb.build();
|
||||
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;
|
||||
}
|
||||
|
||||
// apply changes onto next
|
||||
for (const change of cloneChanges) {
|
||||
for (const changeTag of Array.isArray(change.tag[0])
|
||||
? (change.tag as Array<Array<string>>)
|
||||
: [change.tag as Array<string>]) {
|
||||
const existing = next.tags.findIndex(a => a.every((b, i) => changeTag[i] === b));
|
||||
return next;
|
||||
}
|
||||
|
||||
#applyChanges(tags: Array<Array<string>>, changes: Array<TagDiff>) {
|
||||
for (const change of changes) {
|
||||
if (change.tag.length === 0 && change.type !== "replace") continue;
|
||||
|
||||
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) {
|
||||
next.tags.push(changeTag);
|
||||
tags.push(changeTag);
|
||||
} else {
|
||||
this.#log("Tag already exists: %O", changeTag);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
next.tags.splice(existing, 1);
|
||||
tags.splice(existing, 1);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,3 @@
|
||||
export interface HasId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export * from "./safe-sync";
|
||||
export * from "./range-sync";
|
||||
export * from "./json-in-event-sync";
|
||||
|
@ -1,14 +1,9 @@
|
||||
import { SafeSync } from "./safe-sync";
|
||||
import { HasId } from ".";
|
||||
import { EventBuilder, EventSigner, NostrEvent, NostrLink, NostrPrefix, SystemInterface } from "..";
|
||||
import { SafeSync, SafeSyncEvents } from "./safe-sync";
|
||||
import { EventBuilder, EventSigner, NostrEvent, NostrLink, SystemInterface } from "..";
|
||||
import debug from "debug";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
export interface JsonSyncEvents {
|
||||
change: () => void;
|
||||
}
|
||||
|
||||
export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents> {
|
||||
export class JsonEventSync<T> extends EventEmitter<SafeSyncEvents> {
|
||||
#log = debug("JsonEventSync");
|
||||
#sync: SafeSync;
|
||||
#json: T;
|
||||
@ -19,7 +14,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
|
||||
readonly encrypt: boolean,
|
||||
) {
|
||||
super();
|
||||
this.#sync = new SafeSync();
|
||||
this.#sync = new SafeSync(link);
|
||||
this.#json = initValue;
|
||||
|
||||
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) {
|
||||
const res = await this.#sync.sync(this.link, system);
|
||||
const res = await this.#sync.sync(system);
|
||||
this.#log("Sync result %O", res);
|
||||
if (res) {
|
||||
if (this.encrypt) {
|
||||
@ -71,6 +66,5 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
|
||||
|
||||
await this.#sync.update(next, signer, system, !isNew);
|
||||
this.#json = val;
|
||||
this.#json.id = next.id;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,14 @@
|
||||
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 debug from "debug";
|
||||
|
||||
@ -21,6 +30,10 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
||||
#base: NostrEvent | undefined;
|
||||
#didSync = false;
|
||||
|
||||
constructor(readonly link: NostrLink) {
|
||||
super();
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.#base ? Object.freeze({ ...this.#base }) : undefined;
|
||||
}
|
||||
@ -31,14 +44,13 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
||||
|
||||
/**
|
||||
* Fetch the latest version
|
||||
* @param link A link to the kind
|
||||
*/
|
||||
async sync(link: NostrLink, system: SystemInterface) {
|
||||
if (link.kind === undefined || link.author === undefined) {
|
||||
async sync(system: SystemInterface) {
|
||||
if (this.link.kind === undefined || this.link.author === undefined) {
|
||||
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
|
||||
* @param ev
|
||||
*/
|
||||
async update(next: NostrEvent, signer: EventSigner, system: SystemInterface, mustExist?: boolean) {
|
||||
async update(
|
||||
next: NostrEvent | NotSignedNostrEvent,
|
||||
signer: EventSigner,
|
||||
system: SystemInterface,
|
||||
mustExist?: boolean,
|
||||
) {
|
||||
if ("sig" in next) {
|
||||
next.id = "";
|
||||
next.sig = "";
|
||||
}
|
||||
|
||||
console.debug(this.#base, next);
|
||||
|
||||
const signed = await this.#signEvent(next, signer);
|
||||
const link = NostrLink.fromEvent(signed);
|
||||
|
||||
// always attempt to get a newer version before broadcasting
|
||||
await this.#sync(link, system);
|
||||
await this.#sync(system);
|
||||
this.#checkForUpdate(signed, mustExist ?? true);
|
||||
|
||||
system.BroadcastEvent(signed);
|
||||
@ -73,28 +93,21 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
|
||||
this.emit("change");
|
||||
}
|
||||
|
||||
async #signEvent(next: NostrEvent, signer: EventSigner) {
|
||||
next.created_at = unixNow();
|
||||
if (this.#base) {
|
||||
const prevTag = next.tags.find(a => a[0] === "previous");
|
||||
if (prevTag) {
|
||||
prevTag[1] = this.#base.id;
|
||||
} else {
|
||||
next.tags.push(["previous", this.#base.id]);
|
||||
}
|
||||
}
|
||||
next.id = EventExt.createId(next);
|
||||
return await signer.sign(next);
|
||||
async #signEvent(next: NotSignedNostrEvent, signer: EventSigner) {
|
||||
const toSign = { ...next, id: "", sig: "" } as NostrEvent;
|
||||
toSign.created_at = unixNow();
|
||||
toSign.id = EventExt.createId(toSign);
|
||||
return await signer.sign(toSign);
|
||||
}
|
||||
|
||||
async #sync(link: NostrLink, system: SystemInterface) {
|
||||
const rb = new RequestBuilder(`sync:${link.encode()}`);
|
||||
const f = rb.withFilter().link(link);
|
||||
async #sync(system: SystemInterface) {
|
||||
const rb = new RequestBuilder("sync");
|
||||
const f = rb.withFilter().link(this.link);
|
||||
if (this.#base) {
|
||||
f.since(this.#base.created_at);
|
||||
}
|
||||
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);
|
||||
if (res && res.created_at > (this.#base?.created_at ?? 0)) {
|
||||
this.#base = res;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user