Compare commits

...

11 Commits

43 changed files with 614 additions and 469 deletions

View File

@ -1,15 +1,21 @@
import { NostrLink } from "@snort/system";
import { useReactions } from "@snort/system-react";
import { useArticles } from "Feed/ArticlesFeed";
import { orderDescending } from "SnortUtils";
import Note from "../Event/Note";
import { useReactions } from "Feed/Reactions";
import { useContext } from "react";
import { DeckContext } from "Pages/DeckLayout";
export default function Articles() {
const data = useArticles();
const deck = useContext(DeckContext);
const related = useReactions("articles:reactions", data.data?.map(v => NostrLink.fromEvent(v)) ?? []);
const related = useReactions(
"articles:reactions",
data.data?.map(v => NostrLink.fromEvent(v)) ?? [],
undefined,
true,
);
return (
<>

View File

@ -1,8 +1,8 @@
import { FormattedMessage } from "react-intl";
import { NostrEvent, NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { findTag } from "SnortUtils";
import { useEventFeed } from "Feed/EventFeed";
import PageSpinner from "Element/PageSpinner";
import Reveal from "Element/Event/Reveal";
import { MediaElement } from "Element/Embed/MediaElement";

View File

@ -49,7 +49,7 @@ export default function Note(props: NoteProps) {
if (ev.kind === EventKind.ZapstrTrack) {
return <ZapstrEmbed ev={ev} />;
}
if (ev.kind === EventKind.PubkeyLists || ev.kind === EventKind.ContactList) {
if (ev.kind === EventKind.CategorizedPeople || ev.kind === EventKind.ContactList) {
return <PubkeyList ev={ev} className={className} />;
}
if (ev.kind === EventKind.LiveEvent) {

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { HexKey, Lists, NostrLink, TaggedNostrEvent } from "@snort/system";
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import Icon from "Icons/Icon";
@ -96,16 +96,19 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
async function pin(id: HexKey) {
if (publisher) {
const es = [...login.pinned.item, id];
const ev = await publisher.noteList(es, Lists.Pinned);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: HexKey) {
async function bookmark(id: string) {
if (publisher) {
const es = [...login.bookmarked.item, id];
const ev = await publisher.noteList(es, Lists.Bookmarked);
const ev = await publisher.bookmarks(
es.map(a => new NostrLink(NostrPrefix.Note, a)),
"bookmark",
);
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}

View File

@ -3,7 +3,7 @@ import React, { ReactNode, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
import classNames from "classnames";
import { EventExt, EventKind, HexKey, Lists, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { EventExt, EventKind, HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
import { findTag, hexToBech32 } from "SnortUtils";
@ -60,7 +60,7 @@ export function NoteInner(props: NoteProps) {
if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.item.filter(e => e !== id);
const ev = await publisher.noteList(es, Lists.Pinned);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
@ -71,7 +71,7 @@ export function NoteInner(props: NoteProps) {
if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.item.filter(e => e !== id);
const ev = await publisher.noteList(es, Lists.Bookmarked);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}

View File

@ -1,5 +1,6 @@
import { useEventFeed } from "Feed/EventFeed";
import { NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import Note from "Element/Event/Note";
import PageSpinner from "Element/PageSpinner";

View File

@ -0,0 +1,32 @@
import { NostrLink, NoteCollection, ReqFilter, RequestBuilder } from "@snort/system";
import { useReactions, useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import { TimelineRenderer } from "./TimelineFragment";
export function GenericFeed({ link }: { link: NostrLink }) {
const sub = useMemo(() => {
console.debug(link);
const sub = new RequestBuilder("generic");
sub.withOptions({ leaveOpen: true });
const reqs = JSON.parse(link.id) as Array<ReqFilter>;
reqs.forEach(a => {
const f = sub.withBareFilter(a);
link.relays?.forEach(r => f.relay(r));
});
return sub;
}, [link]);
const evs = useRequestBuilder(NoteCollection, sub);
const reactions = useReactions("generic:reactions", evs.data?.map(a => NostrLink.fromEvent(a)) ?? []);
return (
<TimelineRenderer
frags={[{ events: evs.data ?? [], refTime: 0 }]}
related={reactions.data ?? []}
latest={[]}
showLatest={() => {
//nothing
}}
/>
);
}

View File

@ -1,16 +1,14 @@
import "./Timeline.css";
import { FormattedMessage } from "react-intl";
import { useCallback, useMemo } from "react";
import { useInView } from "react-intersection-observer";
import { TaggedNostrEvent, EventKind, u256 } from "@snort/system";
import { TaggedNostrEvent, EventKind } from "@snort/system";
import Icon from "Icons/Icon";
import { dedupeByPubkey, findTag } from "SnortUtils";
import ProfileImage from "Element/User/ProfileImage";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "Feed/TimelineFeed";
import Note from "Element/Event/Note";
import useModeration from "Hooks/useModeration";
import { LiveStreams } from "Element/LiveStreams";
import { TimelineRenderer } from "./TimelineFragment";
import { unixNow } from "@snort/shared";
export interface TimelineProps {
postsOnly: boolean;
@ -37,8 +35,6 @@ const Timeline = (props: TimelineProps) => {
const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions);
const { muted, isMuted } = useModeration();
const { ref, inView } = useInView();
const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => {
const a = [...nts.filter(a => a.kind !== EventKind.LiveEvent)];
@ -56,12 +52,6 @@ const Timeline = (props: TimelineProps) => {
const latestFeed = useMemo(() => {
return filterPosts(feed.latest ?? []).filter(a => !mainFeed.some(b => b.id === a.id));
}, [feed, filterPosts]);
const relatedFeed = useCallback(
(id: u256) => {
return (feed.related ?? []).filter(a => findTag(a, "e") === id);
},
[feed.related],
);
const liveStreams = useMemo(() => {
return (feed.main ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [feed]);
@ -80,42 +70,17 @@ const Timeline = (props: TimelineProps) => {
return (
<>
<LiveStreams evs={liveStreams} />
{latestFeed.length > 0 && (
<>
<div className="card latest-notes" onClick={() => onShowLatest()} ref={ref}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showProfileCard={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: latestFeed.length }}
/>
<Icon name="arrowUp" />
</div>
{!inView && (
<div className="card latest-notes latest-notes-fixed pointer fade-in" onClick={() => onShowLatest(true)}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showProfileCard={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: latestFeed.length }}
/>
<Icon name="arrowUp" />
</div>
)}
</>
)}
{mainFeed.map(e => (
<Note
key={e.id}
searchedValue={props.subject.discriminator}
data={e}
related={relatedFeed(e.id)}
ignoreModeration={props.ignoreModeration}
depth={0}
/>
))}
<TimelineRenderer
frags={[
{
events: mainFeed,
refTime: mainFeed.at(0)?.created_at ?? unixNow(),
},
]}
related={feed.related ?? []}
latest={latestAuthors}
showLatest={t => onShowLatest(t)}
/>
{(props.loadMore === undefined || props.loadMore === true) && (
<div className="flex items-center">
<button type="button" onClick={() => feed.loadMore()}>

View File

@ -1,21 +1,17 @@
import "./Timeline.css";
import { ReactNode, useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react";
import { FormattedMessage } from "react-intl";
import { TaggedNostrEvent, EventKind, u256, NostrEvent, NostrLink } from "@snort/system";
import { EventKind, NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { SnortContext } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
import { SnortContext, useReactions } from "@snort/system-react";
import { dedupeByPubkey, findTag, orderDescending } from "SnortUtils";
import Note from "Element/Event/Note";
import useModeration from "Hooks/useModeration";
import { FollowsFeed } from "Cache";
import { LiveStreams } from "Element/LiveStreams";
import { useReactions } from "Feed/Reactions";
import AsyncButton from "../AsyncButton";
import useLogin from "Hooks/useLogin";
import ProfileImage from "Element/User/ProfileImage";
import Icon from "Icons/Icon";
import { TimelineFragment, TimelineRenderer } from "./TimelineFragment";
import useHashtagsFeed from "Feed/HashtagsFeed";
import { ShowMoreInView } from "Element/Event/ShowMore";
export interface TimelineFollowsProps {
postsOnly: boolean;
@ -37,39 +33,63 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
const reactions = useReactions(
"follows-feed-reactions",
feed.map(a => NostrLink.fromEvent(a)),
undefined,
true,
);
const system = useContext(SnortContext);
const login = useLogin();
const { muted, isMuted } = useModeration();
const { ref, inView } = useInView();
const sortedFeed = useMemo(() => orderDescending(feed), [feed]);
const postsOnly = useCallback(
(a: NostrEvent) => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true),
[props.postsOnly],
);
const filterPosts = useCallback(
function <T extends NostrEvent>(nts: Array<T>) {
const a = nts.filter(a => a.kind !== EventKind.LiveEvent);
return a
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true))
?.filter(postsOnly)
.filter(a => !isMuted(a.pubkey) && login.follows.item.includes(a.pubkey) && (props.noteFilter?.(a) ?? true));
},
[props.postsOnly, muted, login.follows.timestamp],
[postsOnly, muted, login.follows.timestamp],
);
const mixin = useHashtagsFeed();
const mainFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at <= latest));
}, [sortedFeed, filterPosts, latest, login.follows.timestamp]);
}, [sortedFeed, filterPosts, latest, login.follows.timestamp, mixin]);
const hashTagsGroups = useMemo(() => {
const mainFeedIds = new Set(mainFeed.map(a => a.id));
const included = new Set<string>();
return (mixin.data.data ?? [])
.filter(a => !mainFeedIds.has(a.id) && postsOnly(a))
.reduce(
(acc, v) => {
if (included.has(v.id)) return acc;
const tags = v.tags
.filter(a => a[0] === "t")
.map(v => v[1].toLocaleLowerCase())
.filter(a => mixin.hashtags.includes(a));
for (const t of tags) {
acc[t] ??= [];
acc[t].push(v);
break;
}
included.add(v.id);
return acc;
},
{} as Record<string, Array<TaggedNostrEvent>>,
);
}, [mixin, mainFeed, postsOnly]);
const latestFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at > latest));
}, [sortedFeed, latest]);
const relatedFeed = useCallback(
(id: u256) => {
return (reactions?.data ?? []).filter(a => findTag(a, "e") === id);
},
[reactions],
);
const liveStreams = useMemo(() => {
return (sortedFeed ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [sortedFeed]);
@ -88,53 +108,58 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
return (
<>
{(props.liveStreams ?? true) && <LiveStreams evs={liveStreams} />}
{latestFeed.length > 0 && (
<>
<div className="card latest-notes" onClick={() => onShowLatest()} ref={ref}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowingMark={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: latestFeed.length }}
/>
<Icon name="arrowUp" />
</div>
{!inView && (
<div className="card latest-notes latest-notes-fixed pointer fade-in" onClick={() => onShowLatest(true)}>
{latestAuthors.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowingMark={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: latestFeed.length }}
/>
<Icon name="arrowUp" />
</div>
)}
</>
)}
{mainFeed.map(
a =>
props.noteRenderer?.(a) ?? (
<Note
data={a as TaggedNostrEvent}
related={relatedFeed(a.id)}
key={a.id}
depth={0}
onClick={props.noteOnClick}
/>
),
)}
<div className="flex items-center p">
<AsyncButton
onClick={async () => {
await FollowsFeed.loadMore(system, login, sortedFeed[sortedFeed.length - 1].created_at);
}}>
<FormattedMessage defaultMessage="Load more" />
</AsyncButton>
</div>
<TimelineRenderer
frags={weaveTimeline(mainFeed, hashTagsGroups)}
related={reactions.data ?? []}
latest={latestAuthors}
showLatest={t => onShowLatest(t)}
noteOnClick={props.noteOnClick}
noteRenderer={props.noteRenderer}
/>
<ShowMoreInView
onClick={async () => await FollowsFeed.loadMore(system, login, sortedFeed[sortedFeed.length - 1].created_at)}
/>
</>
);
};
export default TimelineFollows;
function weaveTimeline(
main: Array<TaggedNostrEvent>,
hashtags: Record<string, Array<TaggedNostrEvent>>,
): Array<TimelineFragment> {
// always skip 5 posts from start to avoid heavy handed weaving
const skip = 5;
if (main.length < skip) {
return [{ events: main, refTime: unixNow() }];
}
const frags = Object.entries(hashtags).map(([k, v]) => {
const take = v.slice(0, 5);
return {
title: (
<div className="flex bb p">
<h2>#{k}</h2>
</div>
),
events: take,
refTime: Math.min(
main[skip].created_at,
take.reduce((acc, v) => (acc > v.created_at ? acc : v.created_at), 0),
),
} as TimelineFragment;
});
return [
{
events: main.slice(0, skip),
refTime: main[0].created_at,
},
...frags,
{
events: main.slice(skip),
refTime: main[skip].created_at,
},
].sort((a, b) => (a.refTime > b.refTime ? -1 : 1));
}

View File

@ -0,0 +1,82 @@
import { TaggedNostrEvent } from "@snort/system";
import Note from "Element/Event/Note";
import ProfileImage from "Element/User/ProfileImage";
import Icon from "Icons/Icon";
import { findTag } from "SnortUtils";
import { ReactNode, useCallback } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
export interface TimelineFragment {
events: Array<TaggedNostrEvent>;
refTime: number;
title?: ReactNode;
}
export interface TimelineFragmentProps {
frags: Array<TimelineFragment>;
related: Array<TaggedNostrEvent>;
/**
* List of pubkeys who have posted recently
*/
latest: Array<string>;
showLatest: (toTop: boolean) => void;
noteRenderer?: (ev: TaggedNostrEvent) => ReactNode;
noteOnClick?: (ev: TaggedNostrEvent) => void;
}
export function TimelineRenderer(props: TimelineFragmentProps) {
const { ref, inView } = useInView();
const relatedFeed = useCallback(
(id: string) => {
return props.related.filter(a => findTag(a, "e") === id);
},
[props.related],
);
return (
<>
{props.latest.length > 0 && (
<>
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>
{props.latest.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowingMark={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: props.latest.length }}
/>
<Icon name="arrowUp" />
</div>
{!inView && (
<div
className="card latest-notes latest-notes-fixed pointer fade-in"
onClick={() => props.showLatest(true)}>
{props.latest.slice(0, 3).map(p => {
return <ProfileImage pubkey={p} showUsername={false} link={""} showFollowingMark={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
values={{ n: props.latest.length }}
/>
<Icon name="arrowUp" />
</div>
)}
</>
)}
{props.frags.map(f => {
return (
<>
{f.title}
{f.events.map(
e =>
props.noteRenderer?.(e) ?? (
<Note data={e} related={relatedFeed(e.id)} key={e.id} depth={0} onClick={props.noteOnClick} />
),
)}
</>
);
})}
</>
);
}

View File

@ -1,20 +1,22 @@
import { useEffect, useState } from "react";
import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import { useReactions } from "@snort/system-react";
import PageSpinner from "Element/PageSpinner";
import Note from "Element/Event/Note";
import NostrBandApi from "External/NostrBand";
import { useReactions } from "Feed/Reactions";
import { ErrorOrOffline } from "Element/ErrorOrOffline";
import { useLocale } from "IntlProvider";
export default function TrendingNotes() {
const [posts, setPosts] = useState<Array<NostrEvent>>();
const [error, setError] = useState<Error>();
const related = useReactions("trending", posts?.map(a => NostrLink.fromEvent(a)) ?? []);
const { lang } = useLocale();
const related = useReactions("trending", posts?.map(a => NostrLink.fromEvent(a)) ?? [], undefined, true);
async function loadTrendingNotes() {
const api = new NostrBandApi();
const trending = await api.trendingNotes();
const trending = await api.trendingNotes(lang);
setPosts(trending.notes.map(a => a.event));
}

View File

@ -44,7 +44,11 @@ export default class NostrBandApi {
return await this.#json<TrendingUserResponse>("GET", "/v0/trending/profiles");
}
async trendingNotes() {
async trendingNotes(lang?: string) {
const supportedLangs = ["en", "de", "ja", "zh", "th", "pt", "es", "fr"];
if (lang && supportedLangs.includes(lang)) {
return await this.#json<TrendingNoteResponse>("GET", `/v0/trending/notes?lang=${lang}`);
}
return await this.#json<TrendingNoteResponse>("GET", "/v0/trending/notes");
}

View File

@ -1,5 +1,5 @@
import { useMemo } from "react";
import { EventKind, HexKey, Lists, RequestBuilder, ReplaceableNoteStore, NoteCollection } from "@snort/system";
import { EventKind, HexKey, RequestBuilder, ReplaceableNoteStore, NoteCollection } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unwrap, findTag, chunks } from "SnortUtils";
@ -13,7 +13,7 @@ export default function useProfileBadges(pubkey?: HexKey) {
const sub = useMemo(() => {
if (!pubkey) return null;
const b = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
b.withFilter().kinds([EventKind.ProfileBadges]).tag("d", [Lists.Badges]).authors([pubkey]);
b.withFilter().kinds([EventKind.ProfileBadges]).tag("d", ["profile_badges"]).authors([pubkey]);
return b;
}, [pubkey]);

View File

@ -1,9 +0,0 @@
import { HexKey, Lists } from "@snort/system";
import useNotelistSubscription from "Hooks/useNotelistSubscription";
import useLogin from "Hooks/useLogin";
export default function useBookmarkFeed(pubkey?: HexKey) {
const { bookmarked } = useLogin();
return useNotelistSubscription(pubkey, Lists.Bookmarked, bookmarked.item);
}

View File

@ -0,0 +1,24 @@
import { useMemo } from "react";
import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { unixNow } from "@snort/shared";
import { useRequestBuilder } from "@snort/system-react";
import useLogin from "Hooks/useLogin";
import { Hour } from "Const";
export default function useHashtagsFeed() {
const { hashtags } = useLogin(s => ({ hashtags: s.tags.item }));
const sub = useMemo(() => {
const rb = new RequestBuilder("hashtags-feed");
rb.withFilter()
.kinds([EventKind.TextNote])
.tag("t", hashtags)
.since(unixNow() - Hour * 4);
return rb;
}, [hashtags]);
return {
data: useRequestBuilder(NoteCollection, sub),
hashtags,
};
}

View File

@ -1,11 +1,10 @@
import { useEffect, useMemo } from "react";
import { TaggedNostrEvent, Lists, EventKind, RequestBuilder, NoteCollection } from "@snort/system";
import { TaggedNostrEvent, EventKind, RequestBuilder, NoteCollection, NostrLink } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils";
import { bech32ToHex, findTag, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils";
import { makeNotification, sendNotification } from "Notifications";
import useEventPublisher from "Hooks/useEventPublisher";
import { getMutedKeys } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import useLogin from "Hooks/useLogin";
import {
@ -51,7 +50,10 @@ export default function useLoginFeed() {
b.withOptions({
leaveOpen: true,
});
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList, EventKind.Relays]);
b.withFilter()
.authors([pubKey])
.kinds([EventKind.ContactList, EventKind.Relays, EventKind.MuteList, EventKind.PinList]);
b.withFilter().authors([pubKey]).kinds([EventKind.CategorizedBookmarks]).tag("d", ["follow", "bookmark"]);
if (CONFIG.features.subscriptions && !login.readonly) {
b.withFilter().authors([pubKey]).kinds([EventKind.AppData]).tag("d", ["snort"]);
b.withFilter()
@ -61,10 +63,6 @@ export default function useLoginFeed() {
.tag("p", [pubKey])
.limit(10);
}
b.withFilter()
.authors([pubKey])
.kinds([EventKind.PubkeyLists])
.tag("d", [Lists.Muted, Lists.Followed, Lists.Pinned, Lists.Bookmarked]);
const n4Sub = Nip4Chats.subscription(login);
if (n4Sub) {
@ -151,23 +149,26 @@ export default function useLoginFeed() {
}
}, [loginFeed, readNotifications]);
function handleMutedFeed(mutedFeed: TaggedNostrEvent[]) {
const muted = getMutedKeys(mutedFeed);
setMuted(login, muted.keys, muted.createdAt * 1000);
async function handleMutedFeed(mutedFeed: TaggedNostrEvent[]) {
const latest = getNewest(mutedFeed);
if (!latest) return;
if (muted.raw && (muted.raw?.content?.length ?? 0) > 0 && pubKey) {
publisher
?.nip4Decrypt(muted.raw.content, pubKey)
.then(plaintext => {
try {
const blocked = JSON.parse(plaintext);
const keys = blocked.filter((p: string) => p && p.length === 2 && p[0] === "p").map((p: string) => p[1]);
setBlocked(login, keys, unwrap(muted.raw).created_at * 1000);
} catch (error) {
console.debug("Couldn't parse JSON");
}
})
.catch(error => console.warn(error));
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);
}
}
}
@ -194,23 +195,20 @@ export default function useLoginFeed() {
useEffect(() => {
if (loginFeed.data) {
const getList = (evs: readonly TaggedNostrEvent[], list: Lists) =>
evs
.filter(
a => a.kind === EventKind.TagLists || a.kind === EventKind.NoteLists || a.kind === EventKind.PubkeyLists,
)
.filter(a => unwrap(a.tags.find(b => b[0] === "d"))[1] === list);
const mutedFeed = getList(loginFeed.data, Lists.Muted);
const mutedFeed = loginFeed.data.filter(a => a.kind === EventKind.MuteList);
handleMutedFeed(mutedFeed);
const pinnedFeed = getList(loginFeed.data, Lists.Pinned);
const pinnedFeed = loginFeed.data.filter(a => a.kind === EventKind.PinList);
handlePinnedFeed(pinnedFeed);
const tagsFeed = getList(loginFeed.data, Lists.Followed);
const tagsFeed = loginFeed.data.filter(
a => a.kind === EventKind.CategorizedBookmarks && findTag(a, "d") === "follow",
);
handleTagFeed(tagsFeed);
const bookmarkFeed = getList(loginFeed.data, Lists.Bookmarked);
const bookmarkFeed = loginFeed.data.filter(
a => a.kind === EventKind.CategorizedBookmarks && findTag(a, "d") === "bookmark",
);
handleBookmarkFeed(bookmarkFeed);
}
}, [loginFeed]);

View File

@ -1,52 +0,0 @@
import { useMemo } from "react";
import { HexKey, TaggedNostrEvent, Lists, EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { getNewest } from "SnortUtils";
import useLogin from "Hooks/useLogin";
export default function useMutedFeed(pubkey?: HexKey) {
const { publicKey, muted } = useLogin();
const isMe = publicKey === pubkey;
const sub = useMemo(() => {
if (isMe || !pubkey) return null;
const b = new RequestBuilder(`muted:${pubkey.slice(0, 12)}`);
b.withFilter().authors([pubkey]).kinds([EventKind.PubkeyLists]).tag("d", [Lists.Muted]);
return b;
}, [pubkey]);
const mutedFeed = useRequestBuilder(NoteCollection, sub);
const mutedList = useMemo(() => {
if (pubkey && mutedFeed.data) {
return getMuted(mutedFeed.data, pubkey);
}
return [];
}, [mutedFeed, pubkey]);
return isMe ? muted.item : mutedList;
}
export function getMutedKeys(rawNotes: TaggedNostrEvent[]): {
createdAt: number;
keys: HexKey[];
raw?: TaggedNostrEvent;
} {
const newest = getNewest(rawNotes);
if (newest) {
const { created_at, tags } = newest;
const keys = tags.filter(t => t[0] === "p").map(t => t[1]);
return {
raw: newest,
keys,
createdAt: created_at,
};
}
return { createdAt: 0, keys: [] };
}
export function getMuted(feed: readonly TaggedNostrEvent[], pubkey: HexKey): HexKey[] {
const lists = feed.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey);
return getMutedKeys(lists).keys;
}

View File

@ -1,8 +0,0 @@
import { HexKey, Lists } from "@snort/system";
import useNotelistSubscription from "Hooks/useNotelistSubscription";
import useLogin from "Hooks/useLogin";
export default function usePinnedFeed(pubkey?: HexKey) {
const pinned = useLogin().pinned.item;
return useNotelistSubscription(pubkey, Lists.Pinned, pinned);
}

View File

@ -1,8 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { EventKind, NostrLink, RequestBuilder, NoteCollection, EventExt } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useReactions } from "./Reactions";
import { useReactions, useRequestBuilder } from "@snort/system-react";
export default function useThreadFeed(link: NostrLink) {
const [root, setRoot] = useState<NostrLink>();

View File

@ -1,12 +1,11 @@
import { useCallback, useMemo } from "react";
import { EventKind, NostrLink, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useReactions, useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
import useTimelineWindow from "Hooks/useTimelineWindow";
import useLogin from "Hooks/useLogin";
import { SearchRelays } from "Const";
import { useReactions } from "./Reactions";
export interface TimelineFeedOptions {
method: "TIME_RANGE" | "LIMIT_UNTIL";

View File

@ -0,0 +1,64 @@
import { removeUndefined } from "@snort/shared";
import { EventKind, NostrLink, NoteCollection, RequestBuilder } from "@snort/system";
import { useEventsFeed, useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
/**
* Use a link event containing e/a/p/t tags
*/
export function useLinkList(id: string, fn: (rb: RequestBuilder) => void) {
const sub = useMemo(() => {
const rb = new RequestBuilder(id);
fn(rb);
return rb;
}, [id, fn]);
const listStore = useRequestBuilder(NoteCollection, sub);
return useMemo(() => {
if (listStore.data && listStore.data.length > 0) {
return removeUndefined(
listStore.data
.map(e =>
e.tags.map(a => {
try {
return NostrLink.fromTag(a);
} catch {
// ignored, skipped
}
}),
)
.flat(),
);
}
return [];
}, [listStore.data]);
}
export function useLinkListEvents(id: string, fn: (rb: RequestBuilder) => void) {
const links = useLinkList(id, fn);
return useEventsFeed(`${id}:events`, links).data ?? [];
}
export function usePinList(pubkey: string | undefined) {
return useLinkListEvents(`pins:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.PinList]).authors([pubkey]);
}
});
}
export function useMuteList(pubkey: string | undefined) {
return useLinkList(`pins:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.MuteList]).authors([pubkey]);
}
});
}
export default function useCategorizedBookmarks(pubkey: string | undefined, list: string) {
return useLinkListEvents(`categorized-bookmarks:${list}:${pubkey?.slice(0, 12)}`, rb => {
if (pubkey) {
rb.withFilter().kinds([EventKind.CategorizedBookmarks]).authors([pubkey]).tag("d", [list]);
}
});
}

View File

@ -1,44 +0,0 @@
import { useMemo } from "react";
import { HexKey, Lists, EventKind, FlatNoteStore, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import useLogin from "Hooks/useLogin";
export default function useNotelistSubscription(pubkey: HexKey | undefined, l: Lists, defaultIds: HexKey[]) {
const { preferences, publicKey } = useLogin();
const isMe = publicKey === pubkey;
const sub = useMemo(() => {
if (isMe || !pubkey) return null;
const rb = new RequestBuilder(`note-list-${l}:${pubkey.slice(0, 12)}`);
rb.withFilter().kinds([EventKind.NoteLists]).authors([pubkey]).tag("d", [l]).limit(1);
return rb;
}, [pubkey]);
const listStore = useRequestBuilder(NoteCollection, sub);
const etags = useMemo(() => {
if (isMe) return defaultIds;
// there should only be a single event here because we only load 1 pubkey
if (listStore.data && listStore.data.length > 0) {
return listStore.data[0].tags.filter(a => a[0] === "e").map(a => a[1]);
}
return [];
}, [listStore.data, isMe, defaultIds]);
const esub = useMemo(() => {
if (!pubkey || etags.length === 0) return null;
const s = new RequestBuilder(`${l}-notes:${pubkey.slice(0, 12)}`);
s.withFilter().kinds([EventKind.TextNote]).ids(etags);
if (etags.length > 0 && preferences.enableReactions) {
s.withFilter()
.kinds([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt])
.tag("e", etags);
}
return s;
}, [etags, pubkey, preferences]);
const store = useRequestBuilder(FlatNoteStore, esub);
return store.data ?? [];
}

View File

@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { NostrHashtagLink } from "@snort/system";
import Timeline from "Element/Feed/Timeline";
import useEventPublisher from "Hooks/useEventPublisher";
@ -18,7 +19,10 @@ const HashTagsPage = () => {
async function followTags(ts: string[]) {
if (publisher) {
const ev = await publisher.tags(ts);
const ev = await publisher.bookmarks(
ts.map(a => new NostrHashtagLink(a)),
"follow",
);
system.BroadcastEvent(ev);
setTags(login, ts, ev.created_at * 1000);
}

View File

@ -1,11 +1,12 @@
import { dedupe, unwrap } from "@snort/shared";
import { EventKind, parseNostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
import { useParams } from "react-router-dom";
import { Hour } from "Const";
import Timeline from "Element/Feed/Timeline";
import PageSpinner from "Element/PageSpinner";
import { useEventFeed } from "Feed/EventFeed";
import { FormattedMessage } from "react-intl";
import { useParams } from "react-router-dom";
export function ListFeedPage() {
const { id } = useParams();
@ -13,7 +14,7 @@ export function ListFeedPage() {
const { data } = useEventFeed(link);
if (!data) return <PageSpinner />;
if (data.kind !== EventKind.ContactList && data.kind !== EventKind.PubkeyLists) {
if (data.kind !== EventKind.ContactList && data.kind !== EventKind.CategorizedPeople) {
return (
<b>
<FormattedMessage defaultMessage="Must be a contact list or pubkey list" />

View File

@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import { NostrLink, TLVEntryType, UserMetadata, decodeTLV } from "@snort/system";
import { useUserProfile, useUserSearch } from "@snort/system-react";
import { useEventFeed, useUserProfile, useUserSearch } from "@snort/system-react";
import UnreadCount from "Element/UnreadCount";
import ProfileImage from "Element/User/ProfileImage";
@ -21,7 +21,6 @@ import Text from "Element/Text";
import { Chat, ChatType, createChatLink, useChatSystem } from "chat";
import Modal from "Element/Modal";
import ProfilePreview from "Element/User/ProfilePreview";
import { useEventFeed } from "Feed/EventFeed";
import { LoginSession, LoginStore } from "Login";
import { Nip28ChatSystem } from "chat/nip28";
import { ChatParticipantProfile } from "Element/Chat/ChatParticipant";

View File

@ -7,6 +7,7 @@ import { fetchNip05Pubkey } from "@snort/shared";
import Spinner from "Icons/Spinner";
import ProfilePage from "Pages/Profile/ProfilePage";
import { ThreadRoute } from "Element/Event/Thread";
import { GenericFeed } from "Element/Feed/Generic";
export default function NostrLinkHandler() {
const params = useParams();
@ -24,6 +25,8 @@ export default function NostrLinkHandler() {
} else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) {
const id = nav.encode();
setRenderComponent(<ProfilePage key={id} id={id} state={state} />); // Directly render ProfilePage
} else if (nav.type === NostrPrefix.Req) {
setRenderComponent(<GenericFeed link={nav} />);
}
} else {
if (state) {

View File

@ -2,7 +2,7 @@ import "./Notifications.css";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { EventExt, EventKind, NostrEvent, NostrLink, NostrPrefix, TaggedNostrEvent, parseZap } from "@snort/system";
import { unixNow, unwrap } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import { useEventFeed, useUserProfile } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
@ -15,7 +15,6 @@ import { dedupe, findTag, orderAscending, orderDescending, getDisplayName } from
import Icon from "Icons/Icon";
import ProfileImage from "Element/User/ProfileImage";
import useModeration from "Hooks/useModeration";
import { useEventFeed } from "Feed/EventFeed";
import Text from "Element/Text";
import { formatShort } from "Number";
import { LiveEvent } from "Element/LiveEvent";

View File

@ -19,8 +19,6 @@ import { findTag, getLinkReactions, unwrap } from "SnortUtils";
import Note from "Element/Event/Note";
import { Tab, TabElement } from "Element/Tabs";
import Icon from "Icons/Icon";
import useMutedFeed from "Feed/MuteList";
import usePinnedFeed from "Feed/PinnedFeed";
import useFollowsFeed from "Feed/FollowsFeed";
import useProfileBadges from "Feed/BadgesFeed";
import useModeration from "Hooks/useModeration";
@ -47,8 +45,6 @@ import { EmailRegex } from "Const";
import useLogin from "Hooks/useLogin";
import { ZapTarget } from "Zapper";
import { useStatusFeed } from "Feed/StatusFeed";
import messages from "../messages";
import { SpotlightMediaModal } from "Element/SpotlightMedia";
import ProfileTab, {
BookMarksTab,
@ -60,6 +56,9 @@ import ProfileTab, {
} from "Pages/Profile/ProfileTab";
import DisplayName from "Element/User/DisplayName";
import { UserWebsiteLink } from "Element/User/UserWebsiteLink";
import { useMuteList, usePinList } from "Hooks/useLists";
import messages from "../messages";
interface ProfilePageProps {
id?: string;
@ -95,8 +94,8 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
// feeds
const { blocked } = useModeration();
const pinned = usePinnedFeed(id);
const muted = useMutedFeed(id);
const pinned = usePinList(id);
const muted = useMuteList(id);
const badges = useProfileBadges(showBadges ? id : undefined);
const follows = useFollowsFeed(id);
const status = useStatusFeed(showStatus ? id : undefined, true);
@ -273,7 +272,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
return <FollowersTab id={id} />;
}
case ProfileTabType.MUTED: {
return <MutedList pubkeys={muted} />;
return <MutedList pubkeys={muted.map(a => a.id)} />;
}
case ProfileTabType.BLOCKED: {
return <BlockList />;

View File

@ -8,11 +8,11 @@ import FollowsList from "Element/User/FollowListBase";
import useFollowsFeed from "Feed/FollowsFeed";
import useRelaysFeed from "Feed/RelaysFeed";
import RelaysMetadata from "Element/Relay/RelaysMetadata";
import useBookmarkFeed from "Feed/BookmarkFeed";
import Bookmarks from "Element/User/Bookmarks";
import Icon from "Icons/Icon";
import { Tab } from "Element/Tabs";
import { default as ZapElement } from "Element/Event/Zap";
import useCategorizedBookmarks from "Hooks/useLists";
import messages from "../messages";
@ -59,7 +59,7 @@ export function RelaysTab({ id }: { id: HexKey }) {
}
export function BookMarksTab({ id }: { id: HexKey }) {
const bookmarks = useBookmarkFeed(id);
const bookmarks = useCategorizedBookmarks(id, "bookmark");
return (
<Bookmarks
pubkey={id}

View File

@ -5,6 +5,7 @@ import AsyncButton from "Element/AsyncButton";
import classNames from "classnames";
import { appendDedupe } from "SnortUtils";
import useEventPublisher from "Hooks/useEventPublisher";
import { NostrHashtagLink } from "@snort/system";
export const FixedTopics = {
life: {
@ -69,9 +70,11 @@ export function Topics() {
const tags = Object.entries(FixedTopics)
.filter(([k]) => topics.includes(k))
.map(([, v]) => v.tags)
.flat();
.flat()
.map(a => new NostrHashtagLink(a));
if (tags.length > 0) {
const ev = await publisher?.tags(tags);
const ev = await publisher?.bookmarks(tags, "follow");
if (ev) {
await system.BroadcastEvent(ev);
}

View File

@ -4,7 +4,7 @@ import { ExternalStore, decodeInvoice } from "@snort/shared";
import { unwrap } from "SnortUtils";
import LNDHubWallet from "./LNDHub";
import { NostrConnectWallet } from "./NostrWalletConnect";
import { setupWebLNWalletConfig, WebLNWallet } from "./WebLN";
import { WebLNWallet } from "./WebLN";
export enum WalletKind {
LNDHub = 1,
@ -134,7 +134,6 @@ export class WalletStore extends ExternalStore<WalletStoreSnapshot> {
this.#configs = [];
this.#instance = new Map();
this.load(false);
setupWebLNWalletConfig(this);
this.notifyChange();
}

View File

@ -18,6 +18,7 @@ import {
NostrEvent,
mapEventToProfile,
PowWorker,
encodeTLVEntries,
} from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { removeUndefined, throwIfOffline } from "@snort/shared";
@ -50,6 +51,8 @@ import { ListFeedPage } from "Pages/ListFeedPage";
import { updateRelayConnections } from "Hooks/useLoginRelays";
import { AboutPage } from "Pages/About";
import { OnboardingRoutes } from "Pages/onboarding";
import { setupWebLNWalletConfig } from "Wallet/WebLN";
import { Wallets } from "Wallet";
declare global {
interface Window {
@ -164,6 +167,8 @@ async function initSite() {
sc.setAttribute("data-domain", CONFIG.hostname);
document.head.appendChild(sc);
}
setupWebLNWalletConfig(Wallets);
return null;
}
@ -282,3 +287,7 @@ root.render(
</IntlProvider>
</StrictMode>,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.encodeTLV = encodeTLVEntries;

View File

@ -9,7 +9,7 @@
"+vVZ/G": "连接",
"+vj0U3": "编辑",
"+xliwN": "{name} 转发了",
"/B8zwF": "你想要的空间 😌",
"/B8zwF": "如你所愿的空间 😌",
"/GCoTA": "清空",
"/JE/X+": "帐户支持",
"/PCavi": "公开",
@ -72,12 +72,12 @@
"62nsdy": "重试",
"65BmHb": "从 {host} 代理图像失败,点击此处直接加载",
"6OSOXl": "原因:<i>{reason}</i>",
"6TfgXX": "{site} 是一个开放源码项目,由充满热情的人们在空闲时间创建",
"6TfgXX": "{site} 是由热心人士利用空闲时间开发的开源项目",
"6bgpn+": "并非所有客户端都支持,就算已配置了打闪拆分,你仍然可能会收到一些打闪",
"6ewQqw": "赞 ({n})",
"6uMqL1": "未付款",
"7+Domh": "笔记",
"712i26": "代理使用 HODL 发票转发付款,从而隐藏了节点的公开密钥",
"712i26": "代理使用 HODL 发票转发付款,从而隐藏了你的节点的公钥",
"7BX/yC": "帐户切换",
"7UOvbT": "离线",
"7hp70g": "NIP-05",
@ -145,7 +145,7 @@
"FDguSC": "{n} 次打闪",
"FMfjrl": "在个人档案页面上显示状态消息",
"FSYL8G": "热门用户",
"FcNSft": "重定向发布 HTTP 重定向到提供的闪电地址",
"FcNSft": "重定向将 HTTP 重定向到所提供的闪电地址",
"FdhSU2": "立即领取",
"FfYsOb": "发生错误!",
"FmXUJg": "正在关注你",
@ -176,13 +176,13 @@
"IoQq+a": "仍然要加载点击这里",
"Ix8l+B": "热门笔记",
"J+dIsA": "订阅",
"J2HeQ+": "使用逗号分隔单词,如 word1、word2、word3",
"J2HeQ+": "使用逗号分隔单词,如 word1, word2, word3",
"JCIgkj": "用户名",
"JGrt9q": "将聪发送到 {name}",
"JHEHCk": "打闪 ({n})",
"JIVWWA": "体育",
"JPFYIM": "没有闪电地址",
"JSx7y9": "订阅 {site_name} {plan} for {price} 并获得以下奖励",
"JSx7y9": "以 {price} 订阅 {site_name} {plan} 并获得以下奖励",
"JeoS4y": "转发",
"JjGgXI": "搜索用户",
"JkLHGw": "网站",
@ -276,14 +276,14 @@
"VN0+Fz": "余额: {amount} 聪",
"VOjC1i": "选择你要将附件上传到哪个上传服务",
"VR5eHw": "公钥 (npub/nprofile)",
"VcwrfF": "请说",
"VcwrfF": "好的",
"VlJkSk": "{n} 已静音",
"VnXp8Z": "头像",
"VvaJst": "查看钱包",
"W1yoZY": "看起来你没有任何订阅,你可以获取一个{link}",
"W2PiAr": "{n} 已屏蔽",
"W9355R": "解除静音",
"WmZhfL": "自动将笔记翻译成地语言",
"WmZhfL": "自动将笔记翻译成你的本地语言",
"WvGmZT": "npub / nprofile / nostr 地址",
"X6tipZ": "使用密钥登录",
"X7xU8J": "nsec、npub、NIP-05、十六进制、助记词句",
@ -298,7 +298,7 @@
"ZKORll": "立即激活",
"ZLmyG9": "贡献者",
"ZS+jRE": "将打闪拆分发送到",
"Zff6lu": "用户名 iris.to/<b>{name}</b> 为您保留!",
"Zff6lu": "用户名 iris.to/<b>{name}</b> 已为你保留!",
"a+6cHB": "贬义",
"a5UPxh": "资助提供 NIP-05 验证服务的开发人员和平台",
"a7TDNm": "笔记将实时流式传输到全球和帖子选项卡",
@ -324,12 +324,12 @@
"cuP16y": "多帐户支持",
"cuV2gK": "名称已被注册",
"cyR7Kh": "返回",
"d+6YsV": "列表静音",
"d+6YsV": "应静音的列表",
"d6CyG5": "历史",
"d7d0/x": "闪电地址",
"dOQCL8": "显示名称",
"deEeEI": "注册",
"dmsiLv": "已为 {site} 开发人员配置了 {n} 的默认 Zap Pool 分割,您可以随时在 {link}中禁用它。",
"dmsiLv": "已为 {site} 开发人员配置了 {n} 的默认打闪池分割,你随时可以在 {link}中禁用它。",
"e61Jf3": "即将上线",
"e7VmYP": "输入 PIN 码解锁你的私钥",
"e7qqly": "全标已读",
@ -375,8 +375,8 @@
"itPgxd": "个人档案",
"izWS4J": "取消关注",
"jA3OE/": "{n,plural,=1{{n}聪} other{{n}聪}}",
"jAmfGl": "的 {site_name} 订阅已过期",
"jHa/ko": "清理您的饲料",
"jAmfGl": "的 {site_name} 订阅已过期",
"jHa/ko": "清理你的订阅",
"jMzO1S": "内部错误: {msg}",
"jfV8Wr": "返回",
"jvo0vs": "保存",
@ -452,7 +452,7 @@
"u/vOPu": "已付款",
"u4bHcR": "在此处查看代码:{link}",
"uCk8r+": "已有账户?",
"uKqSN+": "关注反馈",
"uKqSN+": "关注",
"uSV4Ti": "转发需要人工确认",
"uc0din": "将聪拆分发送到",
"ugyJnE": "正在发送笔记和其他东西",

View File

@ -7,14 +7,14 @@
"+vA//S": "登錄",
"+vIQlC": "請確保將以下密碼妥善保存以便將來管理你的代號",
"+vVZ/G": "連接",
"+vj0U3": "edit",
"+vj0U3": "編輯",
"+xliwN": "{name} 轉發了",
"/B8zwF": "Your space the way you want it 😌",
"/GCoTA": "Clear",
"/B8zwF": "如你所願的空間 😌",
"/GCoTA": "清除",
"/JE/X+": "帳戶支持",
"/PCavi": "公開",
"/Xf4UW": "傳送匿名使用資料",
"/clOBU": "Weekly",
"/clOBU": "每週",
"/d6vEc": "使你的帳號可更方便地被找到及分享",
"/n5KSF": "{n} 毫秒",
"00LcfG": "加載更多",
@ -23,27 +23,27 @@
"0BUTMv": "搜索...",
"0jOEtS": "LNURL 無效",
"0mch2Y": "名稱中有禁用字符",
"0siT4z": "Politics",
"0siT4z": "政治",
"0uoY11": "顯示狀態",
"0yO7wF": "{n} 秒",
"1Mo59U": "是否確定要從收藏中移除此條筆記?",
"1R43+L": "輸入 Nostr Wallet Connect 配置",
"1c4YST": "已連接到:{node} 🎉",
"1nYUGC": "{n} 個關注",
"1o2BgB": "Check Signatures",
"1ozeyg": "Nature",
"1o2BgB": "檢查簽名",
"1ozeyg": "自然",
"1udzha": "對話",
"2/2yg+": "添加",
"25V4l1": "橫幅",
"25WwxF": "Don't have an account?",
"25WwxF": "沒有帳戶?",
"2IFGap": "捐贈",
"2LbrkB": "輸入密碼",
"2O2sfp": "Finish",
"2O2sfp": "完成",
"2a2YiP": "{n} 個收藏",
"2k0Cv+": "踩 {n}",
"2ukA4d": "{n}小時",
"2zJXeA": "Profiles",
"39AHJm": "Sign Up",
"2zJXeA": "個人檔案",
"39AHJm": "註冊",
"3KNMbJ": "文章",
"3cc4Ct": "淺色",
"3gOsZq": "翻譯人員",
@ -55,8 +55,8 @@
"47FYwb": "取消",
"4IPzdn": "主要開發人員",
"4L2vUY": "你的新 NIP-05 代號是:",
"4MBtMa": "Name must be between 1 and 32 characters",
"4MjsHk": "Life",
"4MBtMa": "名稱長度必須介於 1 到 32 個字符之間",
"4MjsHk": "生活",
"4OB335": "踩",
"4Vmpt4": "Nostr Plebs 是該領域首批 NIP-05 供應商之一,以合理的價格提供大量域名",
"4Z3t5i": "使用 imgproxy 壓縮圖片",
@ -69,21 +69,21 @@
"5ykRmX": "發送打閃",
"6/SF6e": "<h1>{n}</h1> Cashu 聰",
"6/hB3S": "觀看重播",
"62nsdy": "Retry",
"62nsdy": "重試",
"65BmHb": "從 {host} 代理圖像失敗,點擊此處直接加載",
"6OSOXl": "原因:<i>{reason}</i>",
"6TfgXX": "{site} is an open source project built by passionate people in their free time",
"6TfgXX": "{site} 是由熱心人士利用空閒時間開發的開源項目",
"6bgpn+": "並非所有客戶端都支持,就算已配置了打閃拆分,你仍然可能會收到一些打閃",
"6ewQqw": "贊({n}",
"6uMqL1": "未付款",
"7+Domh": "筆記",
"712i26": "Proxy uses HODL invoices to forward the payment, which hides the pubkey of your node",
"712i26": "代理使用 HODL 發票轉發付款,從而隱藏了你的節點的公鑰",
"7BX/yC": "帳戶切換",
"7UOvbT": "Offline",
"7UOvbT": "離線",
"7hp70g": "NIP-05",
"8/vBbP": "轉發({n}",
"89q5wc": "確認轉發",
"8ED/4u": "Reply To",
"8ED/4u": "回覆",
"8QDesP": "打閃 {n} 聰",
"8Rkoyb": "接收方",
"8Y6bZQ": "無效打閃拆分:{input}",
@ -93,27 +93,27 @@
"9HU8vw": "回覆",
"9SvQep": "關注 {n}",
"9WRlF4": "發送",
"9kSari": "Retry publishing",
"9kSari": "重試發布",
"9pMqYs": "Nostr 地址",
"9wO4wJ": "閃電發票",
"ABAQyo": "Chats",
"ABAQyo": "聊天",
"ADmfQT": "上一層",
"AN0Z7Q": "已被靜音的關鍵詞",
"ASRK0S": "该作者已被静音",
"Ai8VHU": "Snort 中繼器上無限制筆記保留",
"AkCxS/": "原因",
"Am8glJ": "Game",
"Am8glJ": "遊戲",
"AnLrRC": "非打閃",
"AxDOiG": "Months",
"AxDOiG": "",
"AyGauy": "登錄",
"B4C47Y": "名稱過短",
"B6+XJy": "已打閃",
"B6H7eJ": "nsec、npub、nip-05、十六進制",
"BGCM48": "寫入 Snort 中繼器到權限1年的事件保留",
"BWpuKl": "更新",
"BjNwZW": "Nostr address (nip05)",
"BjNwZW": "Nostr 地址NIP-05",
"C1LjMx": "閃電捐款",
"C7642/": "Quote Repost",
"C7642/": "引用轉帖",
"C81/uG": "登出",
"C8HhVE": "推薦關注",
"CHTbO3": "加載發票失敗",
@ -123,21 +123,21 @@
"Cu/K85": "翻譯自 {lang}",
"D+KzKd": "加載時自動打閃每條筆記",
"D3idYv": "設置",
"DBiVK1": "Cache",
"DBiVK1": "緩存",
"DKnriN": "發送聰",
"DZzCem": "顯示最新的 {n} 條筆記",
"DcL8P+": "支持者",
"Dh3hbq": "自動打閃",
"Dn82AL": "直播",
"DtYelJ": "轉移",
"Dx4ey3": "Toggle all",
"EJbFi7": "Search notes",
"Dx4ey3": "切換全部",
"EJbFi7": "搜索筆記",
"ELbg9p": "數據提供方",
"EQKRE4": "在個人檔案頁面上顯示徽章",
"EWyQH5": "全球",
"Ebl/B2": "翻譯成 {lang}",
"EcZF24": "自定義中繼器",
"EcfIwB": "Username is available",
"EcfIwB": "用戶名可用。",
"EcglP9": "密鑰",
"EjFyoR": "鏈上捐款地址",
"EnCOBJ": "購買",
@ -145,7 +145,7 @@
"FDguSC": "{n} 打閃",
"FMfjrl": "在個人檔案頁面上顯示狀態消息",
"FSYL8G": "熱門用戶",
"FcNSft": "Redirect issues HTTP redirect to the supplied lightning address",
"FcNSft": "重定向將 HTTP 重定向到所提供的閃電地址",
"FdhSU2": "立即領取",
"FfYsOb": "發生錯誤!",
"FmXUJg": "正在關注你",
@ -170,19 +170,19 @@
"HhcAVH": "你不關注此用戶,點擊此處從<i>{link}</i>加載多媒體,或更新<a><i>你的選項</i></a>來自動加載來自任何人的多媒體。",
"IEwZvs": "是否確定要取消置頂此條筆記?",
"IKKHqV": "關注",
"IVbtTS": "Zap all {n} sats",
"IWz1ta": "Auto Translate",
"IVbtTS": "打閃所有 {n} 聰",
"IWz1ta": "自動翻譯",
"Ig9/a1": "向 {name} 發送了 {n} 聰",
"IoQq+a": "仍然要加載點擊這裡",
"Ix8l+B": "熱門筆記",
"J+dIsA": "訂閱",
"J2HeQ+": "Use commas to separate words e.g. word1, word2, word3",
"J2HeQ+": "使用逗号分隔单词,如 word1, word2, word3",
"JCIgkj": "用戶名",
"JGrt9q": "將聰發送到 {name}",
"JHEHCk": "打閃({n}",
"JIVWWA": "Sport",
"JIVWWA": "體育",
"JPFYIM": "沒有閃電地址",
"JSx7y9": "Subscribe to {site_name} {plan} for {price} and receive the following rewards",
"JSx7y9": "以 {price} 訂閱 {site_name} {plan} 並獲得以下獎勵",
"JeoS4y": "轉發",
"JjGgXI": "搜索用戶",
"JkLHGw": "網站",
@ -190,7 +190,7 @@
"K3r6DQ": "刪除",
"K7AkdL": "顯示",
"KAhAcM": "輸入 LNDHub 配置",
"KHK8B9": "Relay",
"KHK8B9": "中繼器",
"KQvWvD": "已刪除",
"KahimY": "未知事件類型:{kind}",
"KoFlZg": "輸入鑄幣廠 URL",
@ -207,29 +207,29 @@
"MI2jkA": "無法使用:",
"MP54GY": "錢包密碼",
"MWTx65": "默認頁面",
"MiMipu": "Set as primary Nostr address (nip05)",
"MiMipu": "設置為主要 Nostr 地址NIP-05",
"Mrpkot": "支付訂閱",
"MuVeKe": "購買 nostr 地址",
"MzRYWH": "購買 {item}",
"Mzizei": "Iris.to account",
"Mzizei": "Iris.to 帳戶",
"N2IrpM": "確認",
"NAidKb": "通知",
"NAuFNH": "你已經有此類型的訂閱,請續訂或支付",
"NdOYJJ": "嗯在這裡什麼都沒有.. 去看看 {newUsersPage} 以關注一些推薦的 nostrich",
"NepkXH": "無法用{amount}聰投票,請設置一個不同的默認打閃金額",
"NndBJE": "新用戶頁面",
"O8Z8t9": "Show More",
"O8Z8t9": "顯示更多",
"OEW7yJ": "打閃",
"OKhRC6": "分享",
"OLEm6z": "未知登錄錯誤",
"OQSOJF": "Get a free nostr address",
"OQSOJF": "獲取一個免費的 Nostr 地址",
"OQXnew": "你的訂閱仍然活躍,你還不能續訂",
"ORGv1Q": "已創建",
"P61BTu": "複製事件 JSON",
"P7FD0F": "系統(默認)",
"P7nJT9": "今天總計UTC{amount} 聰",
"PCSt5T": "選項",
"PJeJFc": "Summary",
"PJeJFc": "概要",
"PamNxw": "未知文件標頭:{name}",
"Pe0ogR": "主題",
"PrsIg7": "回應將在每個頁面上顯示,如果禁用則不會顯示任何回應",
@ -238,54 +238,54 @@
"Qxv0B2": "目前你的打閃池中有 {number} 聰。",
"R/6nsx": "訂閱",
"R81upa": "你關注的用戶",
"RSr2uB": "Username must only contain lowercase letters and numbers",
"RSr2uB": "用戶名只能含有小寫字母和數字",
"RahCRH": "已過期",
"RfhLwC": "作者:{author}",
"RhDAoS": "是否確定要刪除 {id}",
"RjpoYG": "最新",
"RkW5we": "Bitcoin",
"RkW5we": "比特幣",
"RoOyAh": "中繼器",
"Rs4kCE": "收藏",
"RwFaYs": "排序",
"SLZGPn": "Enter a pin to encrypt your private key, you must enter this pin every time you open {site}.",
"SLZGPn": "輸入一個 PIN 碼來加密你的私鑰,每次開啟 {site} 時都必須輸入此 PIN 碼。",
"SMO+on": "將打閃發送到 {name}",
"SOqbe9": "更新閃電地址",
"SP0+yi": "購買訂閱",
"SYQtZ7": "閃電地址代理",
"ShdEie": "全標已讀",
"Sjo1P4": "自定義",
"SmuYUd": "What should we call you?",
"SmuYUd": "我們應該怎麼樣稱呼你?",
"Ss0sWu": "立即支付",
"StKzTE": "作者已將此筆記標記為<i>敏感主題</i>",
"TDR5ge": "帖子中的媒體將自動顯示給選定的人,否則只會顯示鏈接",
"TJo5E6": "Preview",
"TJo5E6": "預覽",
"TP/cMX": "已結束",
"TaeBqw": "Sign in with Nostr Extension",
"TdtZQ5": "Crypto",
"TaeBqw": "通過 Nostr 擴展程式登錄",
"TdtZQ5": "加密貨幣",
"TpgeGw": "十六進制鹽..",
"Tpy00S": "用戶",
"U1aPPi": "Stop listening",
"U1aPPi": "停止收聽",
"UDYlxu": "待定訂閱",
"UJTWqI": "Remove from my relays",
"UNjfWJ": "Check all event signatures received from relays",
"UJTWqI": "從我的中繼器中移除",
"UNjfWJ": "检查从中继收到的所有事件签名",
"UT7Nkj": "新聊天",
"UUPFlt": "用戶必須接受內容警告才能顯示你的筆記的內容。",
"Ub+AGc": "Sign In",
"Ub+AGc": "登錄",
"Up5U7K": "屏蔽",
"UrKTqQ": "You have an active iris.to account",
"UrKTqQ": "你有一個活躍的 iris.to 帳戶",
"VN0+Fz": "餘額:{amount} 聰",
"VOjC1i": "選擇你要將附件上傳到哪個上傳服務",
"VR5eHw": "公鑰npub/nprofile",
"VcwrfF": "Yes please",
"VcwrfF": "好的",
"VlJkSk": "{n} 已静音",
"VnXp8Z": "頭像",
"VvaJst": "查看錢包",
"W1yoZY": "看起來你沒有任何訂閱,你可以獲取一個{link}",
"W2PiAr": "{n} 已屏蔽",
"W9355R": "解除静音",
"WmZhfL": "Automatically translate notes to your local language",
"WmZhfL": "自動將筆記翻譯成你的本地語言",
"WvGmZT": "npub / nprofile / nostr 地址",
"X6tipZ": "Sign in with key",
"X6tipZ": "使用密鑰登錄",
"X7xU8J": "nsec、nsec、NIP-05、十六進制、助記詞句",
"XECMfW": "傳送使用資料",
"XICsE8": "文件主機",
@ -298,12 +298,12 @@
"ZKORll": "立即激活",
"ZLmyG9": "貢獻者",
"ZS+jRE": "將打閃拆分發送到",
"Zff6lu": "Username iris.to/<b>{name}</b> is reserved for you!",
"a+6cHB": "Derogatory",
"Zff6lu": "用戶名 iris.to/<b>{name}</b> 已為你保留!",
"a+6cHB": "貶義",
"a5UPxh": "資助提供 NIP-05 驗證服務的開發人員和平台",
"a7TDNm": "筆記將實時流式傳輸到全球和帖子選項卡",
"aHje0o": "Name or nym",
"aMaLBK": "Supported Extensions",
"aHje0o": "名稱",
"aMaLBK": "支持的擴展",
"aWpBzj": "顯示更多",
"b12Goz": "助記詞",
"b5vAk0": "你的代號將像閃電地址一樣重定向至你所選的 LNURL 或閃電地址",
@ -312,11 +312,11 @@
"bep9C3": "公鑰",
"bfvyfs": "匿名",
"bxv59V": "剛剛",
"c+JYNI": "No thanks",
"c+JYNI": "不,謝謝",
"c35bj2": "如果你對 NIP-05 訂單有任何疑問,請私信 {link}",
"c3g2hL": "再次廣播",
"cFbU1B": "使用 Alby前往 {link} 來配置你的 NWC",
"cHCwbF": "Photography",
"cHCwbF": "攝影",
"cPIKU2": "關注",
"cQfLWb": "URL..",
"cWx9t8": "全部靜音",
@ -324,27 +324,27 @@
"cuP16y": "多帳戶支持",
"cuV2gK": "名稱已被註冊",
"cyR7Kh": "返回",
"d+6YsV": "Lists to mute:",
"d+6YsV": "應靜音的列表:",
"d6CyG5": "歷史",
"d7d0/x": "閃電地址",
"dOQCL8": "顯示名稱",
"deEeEI": "Register",
"dmsiLv": "A default Zap Pool split of {n} has been configured for {site} developers, you can disable it at any time in {link}",
"deEeEI": "註冊",
"dmsiLv": "已為 {site} 開發人員配置了 {n} 的默認打閃池分割,你隨時可以在 {link} 中禁用它。",
"e61Jf3": "即將上線",
"e7VmYP": "輸入 PIN 碼解鎖你的私鑰",
"e7qqly": "全標已讀",
"eF0Re7": "Use a nostr signer extension to sign in",
"eF0Re7": "使用 Nostr 簽名擴展登錄",
"eHAneD": "回應表情符號",
"eJj8HD": "獲取驗證",
"eSzf2G": "一個 {nIn} 聰的打閃將分配 {nOut} 聰給打閃池。",
"eXT2QQ": "羣聊",
"fBI91o": "打閃",
"fBlba3": "Thanks for using {site}, please consider donating if you can.",
"fBlba3": "感謝你使用 {site},請考慮捐贈。",
"fOksnD": "無法投票,因為 LNURL 服務不支持打閃",
"fWZYP5": "置頂",
"fX5RYm": "Pick a few topics of interest",
"fX5RYm": "挑選幾個感興趣的主題",
"filwqD": "讀",
"fjAcWo": "Gift Wraps",
"fjAcWo": "禮品包裝",
"flnGvv": "你在想些什麼?",
"fqwcJ1": "鏈上捐款",
"fsB/4p": "已保存",
@ -356,7 +356,7 @@
"geppt8": "{count} ({count2} in memory)",
"gjBiyj": "加載中...",
"grQ+mI": "工作量證明",
"h7jvCs": "{site} is more fun together!",
"h7jvCs": "{site} 一起使用更好玩!",
"h8XMJL": "徽章",
"hMzcSq": "消息",
"hRTfTR": "PRO",
@ -370,21 +370,21 @@
"iGT1eE": "防止虛假帳戶冒充你",
"iNWbVV": "代號",
"iXPL0Z": "無法在不安全的連接上使用私鑰登錄,請使用 nostr 密鑰管理器擴展程序",
"iYc3Ld": "Payments",
"iYc3Ld": "付款",
"ieGrWo": "關注",
"itPgxd": "個人檔案",
"izWS4J": "取消關注",
"jA3OE/": "{n,plural,=1{{n} 聰} other{{n} 聰}}",
"jAmfGl": "Your {site_name} subscription is expired",
"jHa/ko": "Clean up your feed",
"jAmfGl": "你的 {site_name} 訂閱已過期了",
"jHa/ko": "清理你的訂閱",
"jMzO1S": "內部錯誤:{msg}",
"jfV8Wr": "返回",
"jvo0vs": "保存",
"jzgQ2z": "{n} 個回應",
"k2veDA": "寫",
"k7+5Ny": "Hate Speech",
"k7+5Ny": "仇恨言論",
"k7sKNy": "我們自己的 NIP-05 驗證服務,幫助支持本站的發展,並在我們的網站上獲得閃亮的特殊徽章。",
"kEZUR8": "Register an Iris username",
"kEZUR8": "註冊一個 Iris 用戶名",
"kJYo0u": "{n,plural,=0{{name}已轉發} other{{name}和{n}個其他用戶已轉發}}",
"kaaf1E": "現在",
"kuPHYE": "{n,plural,=0{{name}已點贊} other{{name}和{n}個其他用戶已點贊}}",
@ -408,7 +408,7 @@
"n1Whvj": "切換",
"nDejmx": "解除屏蔽",
"nGBrvw": "收藏",
"nihgfo": "Listen to this article",
"nihgfo": "聆聽本文",
"nn1qb3": "非常感謝你的捐贈",
"nwZXeh": "{n} 已屏蔽",
"o7e+nJ": "{n} 個粉絲",
@ -424,50 +424,50 @@
"qDwvZ4": "未知錯誤",
"qMx1sA": "默認打閃金額",
"qUJTsT": "已屏蔽",
"qZsKBR": "Renew {tier}",
"qZsKBR": "更新 {tier}",
"qdGuQo": "你的私鑰是(不要與任何人分享)",
"qfmMQh": "该筆記已被静音",
"qkvYUb": "添加至個人檔案",
"qmJ8kD": "翻譯失敗",
"qtWLmt": "點贊",
"qydxOd": "Science",
"qydxOd": "科學",
"qz9fty": "PIN 碼不正確",
"r3C4x/": "軟件",
"r5srDR": "輸入錢包密碼",
"rT14Ow": "添加中繼器",
"rbrahO": "Close",
"rbrahO": "關閉",
"rfuMjE": "(默認)",
"rmdsT4": "{n}天",
"rx1i0i": "Short link",
"rx1i0i": "短鏈接",
"sKDn4e": "顯示徽章",
"sUNhQE": "用戶",
"sZQzjQ": "解析打閃拆分失敗了:{input}",
"tGXF0Q": "Relay Lists",
"tGXF0Q": "中繼器列表",
"tOdNiY": "深色",
"th5lxp": "將筆記發送到你的寫入中繼器的子集",
"thnRpU": "驗證 NIP-05 可以幫助:",
"tjpYlr": "Relay Metrics",
"tjpYlr": "中繼器指標",
"ttxS0b": "支持者徽章",
"u+LyXc": "Interactions",
"u+LyXc": "互動",
"u/vOPu": "已支付",
"u4bHcR": "在此處查看代碼:{link}",
"uCk8r+": "Already have an account?",
"uKqSN+": "Follows Feed",
"uCk8r+": "已經有帳戶?",
"uKqSN+": "關注源",
"uSV4Ti": "轉發需要人工確認",
"uc0din": "將聰拆分發送到",
"ugyJnE": "Sending notes and other stuff",
"ugyJnE": "發送筆記和其他東西",
"usAvMr": "編輯個人檔案",
"v8lolG": "開始聊天",
"vB3oQ/": "Must be a contact list or pubkey list",
"vN5UH8": "Profile Image",
"vB3oQ/": "必須是一個聯繫人列表或公鑰列表",
"vN5UH8": "頭像",
"vOKedj": "{n,plural,=1{和{n}個其他} other{和{n}個其他}}",
"vZ4quW": "NIP-05 是一種基於 DNS 的驗證規範,可幫助驗證你是真實用戶。",
"vhlWFg": "投票選項",
"vlbWtt": "獲取免費的",
"vrTOHJ": "{amount} 聰",
"vxwnbh": "適用於所有發佈事件的工作量",
"w1Fanr": "Business",
"w6qrwX": "NSFW",
"w1Fanr": "商業",
"w6qrwX": "敏感內容",
"wEQDC6": "編輯",
"wSZR47": "提交",
"wWLwvh": "匿名",
@ -483,19 +483,19 @@
"xaj9Ba": "供應方",
"xbVgIm": "自動加載媒體",
"xhQMeQ": "有效期",
"xl4s/X": "Additional Terms:",
"xl4s/X": "附加條款:",
"xmcVZ0": "搜索",
"y1Z3or": "語言",
"yCLnBC": "LNURL 或閃電地址",
"yNBPJp": "Help fund the development of {site}",
"yNBPJp": "幫助資助 {site} 的開發",
"zCb8fX": "權重",
"zFegDD": "聯絡",
"zINlao": "所有者",
"zQvVDJ": "全部",
"zcaOTs": "以聰為單位的打閃金額",
"zm6qS1": "{n} mins to read",
"zm6qS1": "{n} 分鐘閱讀時間",
"zonsdq": "加載 LNURL 服務失敗",
"zvCDao": "自動顯示最新筆記",
"zwb6LR": "<b>鑄幣廠:</b> {url}",
"zxvhnE": "Daily"
"zxvhnE": "每天"
}

View File

@ -5,3 +5,5 @@ export * from "./useSystemState";
export * from "./useUserProfile";
export * from "./useUserSearch";
export * from "./useEventReactions";
export * from "./useReactions";
export * from "./useEventFeed";

View File

@ -1,6 +1,6 @@
import { useMemo } from "react";
import { RequestBuilder, ReplaceableNoteStore, NostrLink, NoteCollection } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useRequestBuilder } from "./useRequestBuilder";
export function useEventFeed(link: NostrLink) {
const sub = useMemo(() => {

View File

@ -1,13 +1,16 @@
import { RequestBuilder, EventKind, NoteCollection, NostrLink } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import useLogin from "Hooks/useLogin";
import { useMemo } from "react";
import { RequestBuilder, EventKind, NoteCollection, NostrLink } from "@snort/system";
import { useRequestBuilder } from "./useRequestBuilder";
export function useReactions(subId: string, ids: Array<NostrLink>, others?: (rb: RequestBuilder) => void) {
const { preferences: pref } = useLogin();
export function useReactions(
subId: string,
ids: Array<NostrLink>,
others?: (rb: RequestBuilder) => void,
leaveOpen?: boolean,
) {
const sub = useMemo(() => {
const rb = new RequestBuilder(subId);
rb.withOptions({ leaveOpen });
if (ids.length > 0) {
const grouped = ids.reduce(
@ -20,13 +23,7 @@ export function useReactions(subId: string, ids: Array<NostrLink>, others?: (rb:
);
for (const [, v] of Object.entries(grouped)) {
rb.withFilter()
.kinds(
pref.enableReactions
? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]
: [EventKind.ZapReceipt, EventKind.Repost],
)
.replyToLink(v);
rb.withFilter().kinds([EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]).replyToLink(v);
}
}
others?.(rb);

View File

@ -24,11 +24,17 @@ enum EventKind {
Relays = 10002, // NIP-65
Ephemeral = 20_000,
Auth = 22242, // NIP-42
PubkeyLists = 30000, // NIP-51a
NoteLists = 30001, // NIP-51b
MuteList = 10_000, // NIP-51
PinList = 10_001, // NIP-51
CategorizedPeople = 30000, // NIP-51a
CategorizedBookmarks = 30001, // NIP-51b
TagLists = 30002, // NIP-51c
Badge = 30009, // NIP-58
ProfileBadges = 30008, // NIP-58
LongFormTextNote = 30023, // NIP-23
AppData = 30_078, // NIP-78
LiveEvent = 30311, // NIP-102
@ -37,7 +43,7 @@ enum EventKind {
SimpleChatMetadata = 39_000, // NIP-29
ZapRequest = 9734, // NIP 57
ZapReceipt = 9735, // NIP 57
HttpAuthentication = 27235, // NIP XX - HTTP Authentication
HttpAuthentication = 27235, // NIP 98 - HTTP Authentication
}
export default EventKind;

View File

@ -1,6 +1,6 @@
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { unwrap, getPublicKey, unixNow } from "@snort/shared";
import { unwrap } from "@snort/shared";
import {
decodeEncryptionPayload,
@ -8,7 +8,6 @@ import {
EventSigner,
FullRelaySettings,
HexKey,
Lists,
MessageEncryptorVersion,
NostrEvent,
NostrLink,
@ -18,6 +17,7 @@ import {
RelaySettings,
SignerSupports,
TaggedNostrEvent,
ToNostrEventTag,
u256,
UserMetadata,
} from ".";
@ -104,11 +104,14 @@ export class EventPublisher {
return await this.#sign(eb);
}
async muted(keys: HexKey[], priv: HexKey[]) {
const eb = this.#eb(EventKind.PubkeyLists);
eb.tag(["d", Lists.Muted]);
keys.forEach(p => {
/**
* Build a mute list event using lists of pubkeys
* @param pub Public mute list
* @param priv Private mute list
*/
async muted(pub: Array<string>, priv: Array<string>) {
const eb = this.#eb(EventKind.MuteList);
pub.forEach(p => {
eb.tag(["p", p]);
});
if (priv.length > 0) {
@ -119,20 +122,26 @@ export class EventPublisher {
return await this.#sign(eb);
}
async noteList(notes: u256[], list: Lists) {
const eb = this.#eb(EventKind.NoteLists);
eb.tag(["d", list]);
/**
* Build a pin list event using lists of event links
*/
async pinned(notes: Array<ToNostrEventTag>) {
const eb = this.#eb(EventKind.PinList);
notes.forEach(n => {
eb.tag(["e", n]);
eb.tag(unwrap(n.toEventTag()));
});
return await this.#sign(eb);
}
async tags(tags: string[]) {
const eb = this.#eb(EventKind.TagLists);
eb.tag(["d", Lists.Followed]);
tags.forEach(t => {
eb.tag(["t", t]);
/**
* Build a categorized bookmarks event with a given label
* @param notes List of bookmarked links
*/
async bookmarks(notes: Array<ToNostrEventTag>, list: "bookmark" | "follow") {
const eb = this.#eb(EventKind.CategorizedBookmarks);
eb.tag(["d", list]);
notes.forEach(n => {
eb.tag(unwrap(n.toEventTag()));
});
return await this.#sign(eb);
}
@ -263,6 +272,7 @@ export class EventPublisher {
eb.tag(["e", id]);
return await this.#sign(eb);
}
/**
* Repost a note (NIP-18)
*/

View File

@ -12,6 +12,7 @@ export const enum NostrPrefix {
Event = "nevent",
Relay = "nrelay",
Address = "naddr",
Req = "nreq",
}
export enum TLVEntryType {
@ -54,7 +55,9 @@ export function encodeTLVEntries(prefix: NostrPrefix, ...entries: Array<TLVEntry
switch (v.type) {
case TLVEntryType.Special: {
const buf =
prefix === NostrPrefix.Address ? enc.encode(v.value as string) : utils.hexToBytes(v.value as string);
prefix === NostrPrefix.Address || prefix === NostrPrefix.Req
? enc.encode(v.value as string)
: utils.hexToBytes(v.value as string);
buffers.push(0, buf.length, ...buf);
break;
}
@ -101,8 +104,8 @@ export function decodeTLV(str: string) {
function decodeTLVEntry(type: TLVEntryType, prefix: string, data: Uint8Array) {
switch (type) {
case TLVEntryType.Special: {
if (prefix === NostrPrefix.Address) {
return new TextDecoder("ASCII").decode(data);
if (prefix === NostrPrefix.Address || prefix === NostrPrefix.Req) {
return new TextDecoder().decode(data);
} else {
return utils.bytesToHex(data);
}
@ -114,7 +117,7 @@ function decodeTLVEntry(type: TLVEntryType, prefix: string, data: Uint8Array) {
return new Uint32Array(new Uint8Array(data.reverse()).buffer)[0];
}
case TLVEntryType.Relay: {
return new TextDecoder("ASCII").decode(data);
return new TextDecoder().decode(data);
}
}
}

View File

@ -1,4 +1,4 @@
import { bech32ToHex, hexToBech32, isHex, unwrap } from "@snort/shared";
import { bech32ToHex, hexToBech32, isHex, removeUndefined, unwrap } from "@snort/shared";
import {
decodeTLV,
encodeTLV,
@ -12,7 +12,19 @@ import {
} from ".";
import { findTag } from "./utils";
export class NostrLink {
export interface ToNostrEventTag {
toEventTag(): Array<string> | undefined;
}
export class NostrHashtagLink implements ToNostrEventTag {
constructor(readonly tag: string) {}
toEventTag(): string[] | undefined {
return ["t", this.tag];
}
}
export class NostrLink implements ToNostrEventTag {
constructor(
readonly type: NostrPrefix,
readonly id: string,
@ -42,8 +54,8 @@ export class NostrLink {
toEventTag() {
const relayEntry = this.relays ? [this.relays[0]] : [];
if (this.type === NostrPrefix.PublicKey) {
return ["p", this.id];
if (this.type === NostrPrefix.PublicKey || this.type === NostrPrefix.Profile) {
return ["p", this.id, ...relayEntry];
} else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) {
return ["e", this.id, ...relayEntry];
} else if (this.type === NostrPrefix.Address) {
@ -174,6 +186,18 @@ export class NostrLink {
throw new Error(`Unknown tag kind ${tag[0]}`);
}
static fromTags(tags: Array<Array<string>>) {
return removeUndefined(
tags.map(a => {
try {
return NostrLink.fromTag(a);
} catch {
// ignored, cant be mapped
}
}),
);
}
static fromEvent(ev: TaggedNostrEvent | NostrEvent) {
const relays = "relays" in ev ? ev.relays : undefined;
@ -213,7 +237,7 @@ export function parseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLin
let entity = link.startsWith("web+nostr:") || link.startsWith("nostr:") ? link.split(":")[1] : link;
// trim any non-bech32 chars
entity = entity.match(/(n(?:pub|profile|event|ote|addr)1[acdefghjklmnpqrstuvwxyz023456789]+)/)?.[0] ?? entity;
entity = entity.match(/(n(?:pub|profile|event|ote|addr|req)1[acdefghjklmnpqrstuvwxyz023456789]+)/)?.[0] ?? entity;
const isPrefix = (prefix: NostrPrefix) => {
return entity.startsWith(prefix);
@ -227,7 +251,12 @@ export function parseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLin
const id = bech32ToHex(entity);
if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id");
return new NostrLink(NostrPrefix.Note, id);
} else if (isPrefix(NostrPrefix.Profile) || isPrefix(NostrPrefix.Event) || isPrefix(NostrPrefix.Address)) {
} else if (
isPrefix(NostrPrefix.Profile) ||
isPrefix(NostrPrefix.Event) ||
isPrefix(NostrPrefix.Address) ||
isPrefix(NostrPrefix.Req)
) {
const decoded = decodeTLV(entity);
const id = decoded.find(a => a.type === TLVEntryType.Special)?.value as string;
@ -243,6 +272,8 @@ export function parseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLin
return new NostrLink(NostrPrefix.Event, id, kind, author, relays);
} else if (isPrefix(NostrPrefix.Address)) {
return new NostrLink(NostrPrefix.Address, id, kind, author, relays);
} else if (isPrefix(NostrPrefix.Req)) {
return new NostrLink(NostrPrefix.Req, id);
}
} else if (prefixHint) {
return new NostrLink(prefixHint, link);

View File

@ -70,17 +70,6 @@ export type UserMetadata = {
lud16?: string;
};
/**
* NIP-51 list types
*/
export enum Lists {
Muted = "mute",
Pinned = "pin",
Bookmarked = "bookmark",
Followed = "follow",
Badges = "profile_badges",
}
export interface FullRelaySettings {
url: string;
settings: RelaySettings;

View File

@ -47,6 +47,7 @@ export class QueryTrace extends EventEmitter<QueryTraceEvents> {
}
forceEose() {
this.sent ??= unixNowMs();
this.eose = unixNowMs();
this.#wasForceClosed = true;
this.sendClose();