forked from Kieran/snort
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
b5df7dbb6e
@ -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 (
|
||||
<>
|
||||
|
@ -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";
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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";
|
||||
|
||||
|
32
packages/app/src/Element/Feed/Generic.tsx
Normal file
32
packages/app/src/Element/Feed/Generic.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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()}>
|
||||
|
@ -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));
|
||||
}
|
||||
|
82
packages/app/src/Element/Feed/TimelineFragment.tsx
Normal file
82
packages/app/src/Element/Feed/TimelineFragment.tsx
Normal 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} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
||||
|
6
packages/app/src/External/NostrBand.ts
vendored
6
packages/app/src/External/NostrBand.ts
vendored
@ -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");
|
||||
}
|
||||
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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);
|
||||
}
|
24
packages/app/src/Feed/HashtagsFeed.ts
Normal file
24
packages/app/src/Feed/HashtagsFeed.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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]);
|
||||
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -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>();
|
||||
|
@ -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";
|
||||
|
64
packages/app/src/Hooks/useLists.tsx
Normal file
64
packages/app/src/Hooks/useLists.tsx
Normal 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]);
|
||||
}
|
||||
});
|
||||
}
|
@ -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 ?? [];
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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" />
|
||||
|
@ -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";
|
||||
|
@ -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) {
|
||||
|
@ -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";
|
||||
|
@ -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 />;
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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": "正在发送笔记和其他东西",
|
||||
|
@ -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": "每天"
|
||||
}
|
||||
|
@ -5,3 +5,5 @@ export * from "./useSystemState";
|
||||
export * from "./useUserProfile";
|
||||
export * from "./useUserSearch";
|
||||
export * from "./useEventReactions";
|
||||
export * from "./useReactions";
|
||||
export * from "./useEventFeed";
|
||||
|
@ -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(() => {
|
@ -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);
|
@ -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;
|
||||
|
@ -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)
|
||||
*/
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -47,6 +47,7 @@ export class QueryTrace extends EventEmitter<QueryTraceEvents> {
|
||||
}
|
||||
|
||||
forceEose() {
|
||||
this.sent ??= unixNowMs();
|
||||
this.eose = unixNowMs();
|
||||
this.#wasForceClosed = true;
|
||||
this.sendClose();
|
||||
|
Loading…
Reference in New Issue
Block a user