refactor: use UserState

This commit is contained in:
2024-05-28 14:51:35 +01:00
parent 154fa551b4
commit c32c230227
22 changed files with 189 additions and 328 deletions

View File

@ -8,8 +8,8 @@
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.6", "@scure/base": "^1.1.6",
"@snort/shared": "^1.0.15", "@snort/shared": "^1.0.15",
"@snort/system": "^1.3.3", "@snort/system": "^1.3.5",
"@snort/system-react": "^1.3.3", "@snort/system-react": "^1.3.5",
"@snort/system-wasm": "^1.0.4", "@snort/system-wasm": "^1.0.4",
"@snort/wallet": "^0.1.3", "@snort/wallet": "^0.1.3",
"@snort/worker-relay": "^1.1.0", "@snort/worker-relay": "^1.1.0",

View File

@ -4,12 +4,9 @@ export const LIVE_STREAM = 30_311 as EventKind;
export const LIVE_STREAM_CHAT = 1_311 as EventKind; export const LIVE_STREAM_CHAT = 1_311 as EventKind;
export const LIVE_STREAM_RAID = 1_312 as EventKind; export const LIVE_STREAM_RAID = 1_312 as EventKind;
export const LIVE_STREAM_CLIP = 1_313 as EventKind; export const LIVE_STREAM_CLIP = 1_313 as EventKind;
export const EMOJI_PACK = 30_030 as EventKind;
export const USER_EMOJIS = 10_030 as EventKind;
export const GOAL = 9041 as EventKind; export const GOAL = 9041 as EventKind;
export const USER_CARDS = 17_777 as EventKind; export const USER_CARDS = 17_777 as EventKind;
export const CARD = 37_777 as EventKind; export const CARD = 37_777 as EventKind;
export const MUTED = 10_000 as EventKind;
export const VIDEO_KIND = 34_235 as EventKind; export const VIDEO_KIND = 34_235 as EventKind;
export const SHORTS_KIND = 34_236 as EventKind; export const SHORTS_KIND = 34_236 as EventKind;

View File

@ -20,6 +20,9 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: Asyn
if (props.onClick) { if (props.onClick) {
await props.onClick(e); await props.onClick(e);
} }
} catch (e) {
console.error(e);
throw e;
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -2,7 +2,7 @@ import "./live-chat.css";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { EventKind, NostrEvent, NostrLink, NostrPrefix, ParsedZap, TaggedNostrEvent } from "@snort/system"; import { EventKind, NostrEvent, NostrLink, NostrPrefix, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useEventFeed, useEventReactions, useReactions, useUserProfile } from "@snort/system-react"; import { useEventFeed, useEventReactions, useReactions, useUserProfile } from "@snort/system-react";
import { unixNow, unwrap } from "@snort/shared"; import { removeUndefined, unixNow, unwrap } from "@snort/shared";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Icon } from "../icon"; import { Icon } from "../icon";
@ -87,11 +87,9 @@ export function LiveChat({
return starts ? Number(starts) : unixNow() - WEEK; return starts ? Number(starts) : unixNow() - WEEK;
}, [ev]); }, [ev]);
const { badges, awards } = useBadges(host, started); const { badges, awards } = useBadges(host, started);
const mutedPubkeys = useMemo(() => {
return new Set(getTagValues(login?.muted.tags ?? [], "p"));
}, [login]);
const hostMutedPubkeys = useMutedPubkeys(host, true); const hostMutedPubkeys = useMutedPubkeys(host, true);
const userEmojiPacks = login?.emojis ?? []; const userEmojiPacks = useEmoji(login?.pubkey);
const channelEmojiPacks = useEmoji(host); const channelEmojiPacks = useEmoji(host);
const allEmojiPacks = useMemo(() => { const allEmojiPacks = useMemo(() => {
return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId); return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
@ -110,7 +108,7 @@ export function LiveChat({
if (ends) { if (ends) {
extra.push({ kind: -2, created_at: Number(ends) } as TaggedNostrEvent); extra.push({ kind: -2, created_at: Number(ends) } as TaggedNostrEvent);
} }
return [...feed, ...awards, ...extra] return removeUndefined([...feed, ...awards, ...extra])
.filter(a => a.created_at >= started) .filter(a => a.created_at >= started)
.sort((a, b) => b.created_at - a.created_at); .sort((a, b) => b.created_at - a.created_at);
}, [feed, awards]); }, [feed, awards]);
@ -145,8 +143,14 @@ export function LiveChat({
}, [adjustLayout]); }, [adjustLayout]);
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
return events.filter(e => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey)); return events.filter(e => {
}, [events, mutedPubkeys, hostMutedPubkeys]); if (!e.pubkey) return true; // injected content
const author = NostrLink.publicKey(e.pubkey);
return (
!(login?.state?.muted.some(a => a.equals(author)) ?? true) && !hostMutedPubkeys.some(a => a.equals(author))
);
});
}, [events, login?.state?.version, hostMutedPubkeys]);
return ( return (
<div className={classNames("flex flex-col gap-1", className)} style={height ? { height: `${height}px` } : {}}> <div className={classNames("flex flex-col gap-1", className)} style={height ? { height: `${height}px` } : {}}>

View File

@ -1,43 +1,24 @@
import "./emoji-pack.css"; import "./emoji-pack.css";
import { type NostrEvent } from "@snort/system"; import { EventKind, NostrLink, type NostrEvent } from "@snort/system";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useContext } from "react";
import { SnortContext } from "@snort/system-react";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { toEmojiPack } from "@/hooks/emoji"; import useEmoji from "@/hooks/emoji";
import { findTag } from "@/utils";
import { USER_EMOJIS } from "@/const";
import { Login } from "@/login";
import type { EmojiPack as EmojiPackType } from "@/types";
import { DefaultButton, WarningButton } from "./buttons"; import { DefaultButton, WarningButton } from "./buttons";
export function EmojiPack({ ev }: { ev: NostrEvent }) { export function EmojiPack({ ev }: { ev: NostrEvent }) {
const system = useContext(SnortContext);
const login = useLogin(); const login = useLogin();
const name = findTag(ev, "d"); const link = NostrLink.fromEvent(ev);
const isUsed = login?.emojis.find(e => e.author === ev.pubkey && e.name === name); const name = link.id;
const emojis = useEmoji(login?.pubkey);
const isUsed = emojis.find(e => e.author === link.author && e.name === link.id);
const emoji = ev.tags.filter(e => e.at(0) === "emoji"); const emoji = ev.tags.filter(e => e.at(0) === "emoji");
async function toggleEmojiPack() { async function toggleEmojiPack() {
let newPacks = [] as EmojiPackType[];
if (isUsed) { if (isUsed) {
newPacks = login?.emojis.filter(e => e.author !== ev.pubkey && e.name !== name) ?? []; await login?.state?.removeFromList(EventKind.EmojisList, link, true);
} else { } else {
newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)]; await login?.state?.addToList(EventKind.EmojisList, link, true);
}
const pub = login?.publisher();
if (pub) {
const ev = await pub.generic(eb => {
eb.kind(USER_EMOJIS).content("");
for (const e of newPacks) {
eb.tag(["a", e.address]);
}
return eb;
});
console.debug(ev);
await system.BroadcastEvent(ev);
Login.setEmojis(newPacks);
} }
} }

View File

@ -5,7 +5,7 @@ import { Goal } from "./goal";
import { Note } from "./note"; import { Note } from "./note";
import { EmojiPack } from "./emoji-pack"; import { EmojiPack } from "./emoji-pack";
import { Badge } from "./badge"; import { Badge } from "./badge";
import { EMOJI_PACK, GOAL, LIVE_STREAM_CLIP, StreamState } from "@/const"; import { GOAL, LIVE_STREAM_CLIP, StreamState } from "@/const";
import { useEventFeed } from "@snort/system-react"; import { useEventFeed } from "@snort/system-react";
import LiveStreamClip from "./stream/clip"; import LiveStreamClip from "./stream/clip";
import { ExternalLink } from "./external-link"; import { ExternalLink } from "./external-link";
@ -21,7 +21,7 @@ export function EventIcon({ kind }: { kind?: EventKind }) {
switch (kind) { switch (kind) {
case GOAL: case GOAL:
return <Icon name="piggybank" />; return <Icon name="piggybank" />;
case EMOJI_PACK: case EventKind.EmojiSet:
return <Icon name="face-content" />; return <Icon name="face-content" />;
case EventKind.Badge: case EventKind.Badge:
return <Icon name="badge" />; return <Icon name="badge" />;
@ -35,7 +35,7 @@ export function NostrEvent({ ev }: { ev: TaggedNostrEvent }) {
case GOAL: { case GOAL: {
return <Goal ev={ev} />; return <Goal ev={ev} />;
} }
case EMOJI_PACK: { case EventKind.EmojiSet: {
return <EmojiPack ev={ev} />; return <EmojiPack ev={ev} />;
} }
case EventKind.Badge: { case EventKind.Badge: {

View File

@ -1,67 +1,28 @@
import { EventKind } from "@snort/system"; import { NostrHashtagLink, NostrLink, NostrPrefix } from "@snort/system";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useContext } from "react";
import { SnortContext } from "@snort/system-react";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { Login } from "@/login";
import { DefaultButton } from "./buttons"; import { DefaultButton } from "./buttons";
import { Icon } from "./icon"; import { Icon } from "./icon";
export function LoggedInFollowButton({ export function LoggedInFollowButton({ link, hideWhenFollowing }: { link: NostrLink; hideWhenFollowing?: boolean }) {
tag,
value,
hideWhenFollowing,
}: {
tag: "p" | "t";
value: string;
hideWhenFollowing?: boolean;
}) {
const system = useContext(SnortContext);
const login = useLogin(); const login = useLogin();
if (!login) return; if (!login?.state) return;
const { tags, content, timestamp } = login.follows; const follows = login.state.follows ?? [];
const follows = tags.filter(t => t.at(0) === tag); const isFollowing = follows.includes(link.id);
const isFollowing = follows.find(t => t.at(1) === value);
async function unfollow() { async function unfollow() {
const pub = login?.publisher(); await login?.state?.unfollow(link, true);
if (pub) {
const newFollows = tags.filter(t => t.at(1) !== value);
const ev = await pub.generic(eb => {
eb.kind(EventKind.ContactList).content(content ?? "");
for (const t of newFollows) {
eb.tag(t);
}
return eb;
});
console.debug(ev);
await system.BroadcastEvent(ev);
Login.setFollows(newFollows, content ?? "", ev.created_at);
}
} }
async function follow() { async function follow() {
const pub = login?.publisher(); await login?.state?.follow(link, true);
if (pub) {
const newFollows = [...tags, [tag, value]];
const ev = await pub.generic(eb => {
eb.kind(EventKind.ContactList).content(content ?? "");
for (const tag of newFollows) {
eb.tag(tag);
}
return eb;
});
console.debug(ev);
await system.BroadcastEvent(ev);
Login.setFollows(newFollows, content ?? "", ev.created_at);
}
} }
if (isFollowing && hideWhenFollowing) return; if (isFollowing && hideWhenFollowing) return;
return ( return (
<DefaultButton disabled={timestamp ? timestamp === 0 : true} onClick={isFollowing ? unfollow : follow}> <DefaultButton onClick={isFollowing ? unfollow : follow}>
{isFollowing ? ( {isFollowing ? (
<FormattedMessage defaultMessage="Unfollow" /> <FormattedMessage defaultMessage="Unfollow" />
) : ( ) : (
@ -75,11 +36,14 @@ export function LoggedInFollowButton({
} }
export function FollowTagButton({ tag, hideWhenFollowing }: { tag: string; hideWhenFollowing?: boolean }) { export function FollowTagButton({ tag, hideWhenFollowing }: { tag: string; hideWhenFollowing?: boolean }) {
const login = useLogin(); //const login = useLogin();
return login?.pubkey ? <LoggedInFollowButton tag={"t"} value={tag} hideWhenFollowing={hideWhenFollowing} /> : null; //const link = new NostrHashtagLink(tag);
return;
//return login?.pubkey ? <LoggedInFollowButton link={link} hideWhenFollowing={hideWhenFollowing} /> : null;
} }
export function FollowButton({ pubkey, hideWhenFollowing }: { pubkey: string; hideWhenFollowing?: boolean }) { export function FollowButton({ pubkey, hideWhenFollowing }: { pubkey: string; hideWhenFollowing?: boolean }) {
const login = useLogin(); const login = useLogin();
return login?.pubkey ? <LoggedInFollowButton tag={"p"} value={pubkey} hideWhenFollowing={hideWhenFollowing} /> : null; const link = new NostrLink(NostrPrefix.PublicKey, pubkey);
return login?.pubkey ? <LoggedInFollowButton link={link} hideWhenFollowing={hideWhenFollowing} /> : null;
} }

View File

@ -1,54 +1,30 @@
import { useContext, useMemo } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { Login } from "@/login";
import { MUTED } from "@/const";
import { DefaultButton } from "./buttons"; import { DefaultButton } from "./buttons";
import { NostrLink } from "@snort/system";
export function useMute(pubkey: string) { export function useMute(pubkey: string) {
const system = useContext(SnortContext);
const login = useLogin(); const login = useLogin();
const { tags, content } = login?.muted ?? { tags: [] }; const link = NostrLink.publicKey(pubkey);
const muted = useMemo(() => tags.filter(t => t.at(0) === "p"), [tags]);
const isMuted = useMemo(() => muted.find(t => t.at(1) === pubkey), [pubkey, muted]);
async function unmute() { async function unmute() {
const pub = login?.publisher(); await login?.state?.unmute(link, true);
if (pub) {
const newMuted = tags.filter(t => t.at(1) !== pubkey);
const ev = await pub.generic(eb => {
eb.kind(MUTED).content(content ?? "");
for (const t of newMuted) {
eb.tag(t);
}
return eb;
});
console.debug(ev);
await system.BroadcastEvent(ev);
Login.setMuted(newMuted, content ?? "", ev.created_at);
}
} }
async function mute() { async function mute() {
const pub = login?.publisher(); try {
if (pub) { await login?.state?.mute(link, true);
const newMuted = [...tags, ["p", pubkey]]; } catch (e) {
const ev = await pub.generic(eb => { console.error(e);
eb.kind(MUTED).content(content ?? "");
for (const tag of newMuted) {
eb.tag(tag);
}
return eb;
});
console.debug(ev);
await system.BroadcastEvent(ev);
Login.setMuted(newMuted, content ?? "", ev.created_at);
} }
} }
return { isMuted, mute, unmute }; return {
isMuted: login?.state?.muted.some(a => a.equals(link)) ?? false,
mute,
unmute,
};
} }
export function LoggedInMuteButton({ pubkey }: { pubkey: string }) { export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {

View File

@ -6,6 +6,7 @@ import { useCards } from "@/hooks/cards";
import { StreamCardEditor } from "./stream-card-editor"; import { StreamCardEditor } from "./stream-card-editor";
import { Card } from "./card-item"; import { Card } from "./card-item";
import classNames from "classnames"; import classNames from "classnames";
import { USER_CARDS } from "@/const";
export interface CardType { export interface CardType {
identifier: string; identifier: string;
@ -40,13 +41,10 @@ export function ReadOnlyStreamCards({ host, className }: StreamCardsProps) {
export function StreamCards({ host }: StreamCardsProps) { export function StreamCards({ host }: StreamCardsProps) {
const login = useLogin(); const login = useLogin();
const canEdit = login?.pubkey === host; const canEdit = login?.pubkey === host;
const cards = login?.state?.getList(USER_CARDS);
return ( return (
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
{canEdit ? ( {canEdit ? <StreamCardEditor tags={cards ?? []} pubkey={login.pubkey} /> : <ReadOnlyStreamCards host={host} />}
<StreamCardEditor tags={login.cards.tags} pubkey={login.pubkey} />
) : (
<ReadOnlyStreamCards host={host} />
)}
</DndProvider> </DndProvider>
); );
} }

View File

@ -3,16 +3,21 @@ import { FormattedMessage } from "react-intl";
import { Toggle } from "../toggle"; import { Toggle } from "../toggle";
import { useUserCards } from "@/hooks/cards"; import { useUserCards } from "@/hooks/cards";
import { AddCard } from "./add-card"; import { AddCard } from "./add-card";
import { Tags } from "@/types";
import { Card } from "./card-item"; import { Card } from "./card-item";
import { ToNostrEventTag } from "@snort/system";
import { Tag } from "@/types";
interface StreamCardEditorProps { interface StreamCardEditorProps {
pubkey: string; pubkey: string;
tags: Tags; tags: Array<ToNostrEventTag>;
} }
export function StreamCardEditor({ pubkey, tags }: StreamCardEditorProps) { export function StreamCardEditor({ pubkey, tags }: StreamCardEditorProps) {
const cards = useUserCards(pubkey, tags, true); const cards = useUserCards(
pubkey,
tags.map(a => a.toEventTag() as Tag),
true,
);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
return ( return (
<> <>

View File

@ -1,8 +1,8 @@
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { useSortedStreams } from "@/hooks/useLiveStreams"; import { useSortedStreams } from "@/hooks/useLiveStreams";
import { getTagValues, getHost, extractStreamInfo } from "@/utils"; import { getTagValues, getHost, extractStreamInfo } from "@/utils";
import { NostrEvent, TaggedNostrEvent } from "@snort/system"; import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import { ReactNode, useCallback, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import VideoGrid from "./video-grid"; import VideoGrid from "./video-grid";
import { StreamTile } from "./stream/stream-tile"; import { StreamTile } from "./stream/stream-tile";
@ -34,33 +34,27 @@ export default function VideoGridSorted({
showVideos, showVideos,
}: VideoGridSortedProps) { }: VideoGridSortedProps) {
const login = useLogin(); const login = useLogin();
const mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p")); const mutedHosts = login?.state?.muted ?? [];
const tags = login?.follows.tags ?? []; const follows = login?.state?.follows ?? [];
const followsHost = useCallback( const followsHost = (ev: NostrEvent) => follows?.includes(getHost(ev));
(ev: NostrEvent) => {
return tags.find(t => t.at(1) === getHost(ev)); const filteredStreams = evs.filter(a => !mutedHosts.includes(NostrLink.publicKey(getHost(a))));
}, const { live, planned, ended } = useSortedStreams(filteredStreams, showAll ? 0 : undefined);
[tags], const hashtags: Array<string> = [];
);
const { live, planned, ended } = useSortedStreams(evs, showAll ? 0 : undefined);
const hashtags = getTagValues(tags, "t");
const following = live.filter(followsHost); const following = live.filter(followsHost);
const liveNow = live.filter(e => !following.includes(e)); const liveNow = live.filter(e => !following.includes(e));
const hasFollowingLive = following.length > 0; const hasFollowingLive = following.length > 0;
const plannedEvents = planned.filter(e => !mutedHosts.has(getHost(e))).filter(followsHost); const plannedEvents = planned.filter(followsHost);
const endedEvents = ended.filter(e => !mutedHosts.has(getHost(e)));
const liveByHashtag = useMemo(() => { const liveByHashtag = useMemo(() => {
return hashtags return hashtags
.map(t => ({ .map(t => ({
tag: t, tag: t,
live: live live: live.filter(e => {
.filter(e => !mutedHosts.has(getHost(e))) const evTags = getTagValues(e.tags, "t");
.filter(e => { return evTags.includes(t);
const evTags = getTagValues(e.tags, "t"); }),
return evTags.includes(t);
}),
})) }))
.filter(t => t.live.length > 0); .filter(t => t.live.length > 0);
}, [live, hashtags]); }, [live, hashtags]);
@ -72,11 +66,9 @@ export default function VideoGridSorted({
)} )}
{!hasFollowingLive && ( {!hasFollowingLive && (
<VideoGrid> <VideoGrid>
{live {live.map(e => (
.filter(e => !mutedHosts.has(getHost(e))) <StreamTile ev={e} key={e.id} style="grid" />
.map(e => ( ))}
<StreamTile ev={e} key={e.id} style="grid" />
))}
</VideoGrid> </VideoGrid>
)} )}
{liveByHashtag.map(t => ( {liveByHashtag.map(t => (
@ -96,8 +88,8 @@ export default function VideoGridSorted({
{plannedEvents.length > 0 && (showPlanned ?? true) && ( {plannedEvents.length > 0 && (showPlanned ?? true) && (
<GridSection header={<FormattedMessage defaultMessage="Planned" id="kp0NPF" />} items={plannedEvents} /> <GridSection header={<FormattedMessage defaultMessage="Planned" id="kp0NPF" />} items={plannedEvents} />
)} )}
{endedEvents.length > 0 && (showEnded ?? true) && ( {ended.length > 0 && (showEnded ?? true) && (
<GridSection header={<FormattedMessage defaultMessage="Ended" id="TP/cMX" />} items={endedEvents} /> <GridSection header={<FormattedMessage defaultMessage="Ended" id="TP/cMX" />} items={ended} />
)} )}
</div> </div>
); );

View File

@ -1,9 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { NostrEvent, RequestBuilder } from "@snort/system"; import { EventKind, NostrEvent, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { findTag, uniqBy } from "@/utils"; import { findTag, uniqBy } from "@/utils";
import { EMOJI_PACK, USER_EMOJIS } from "@/const";
import type { EmojiPack, EmojiTag, Tags } from "@/types"; import type { EmojiPack, EmojiTag, Tags } from "@/types";
function cleanShortcode(shortcode?: string) { function cleanShortcode(shortcode?: string) {
@ -29,7 +28,7 @@ export function packId(pack: EmojiPack): string {
export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) { export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
const related = useMemo(() => { const related = useMemo(() => {
if (userEmoji) { if (userEmoji) {
return userEmoji?.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`)); return userEmoji?.filter(t => t.at(0) === "a" && t.at(1)?.startsWith(`${EventKind.EmojiSet}:`));
} }
return []; return [];
}, [userEmoji]); }, [userEmoji]);
@ -48,9 +47,9 @@ export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
const rb = new RequestBuilder(`emoji-related:${pubkey}`); const rb = new RequestBuilder(`emoji-related:${pubkey}`);
rb.withFilter().kinds([EMOJI_PACK]).authors(authors).tag("d", identifiers); rb.withFilter().kinds([EventKind.EmojiSet]).authors(authors).tag("d", identifiers);
rb.withFilter().kinds([EMOJI_PACK]).authors([pubkey]); rb.withFilter().kinds([EventKind.EmojiSet]).authors([pubkey]);
return rb; return rb;
}, [pubkey, related]); }, [pubkey, related]);
@ -73,8 +72,7 @@ export default function useEmoji(pubkey?: string) {
const sub = useMemo(() => { const sub = useMemo(() => {
if (!pubkey) return null; if (!pubkey) return null;
const rb = new RequestBuilder(`emoji:${pubkey}`); const rb = new RequestBuilder(`emoji:${pubkey}`);
rb.withFilter().authors([pubkey]).kinds([EventKind.EmojisList]);
rb.withFilter().authors([pubkey]).kinds([USER_EMOJIS]);
return rb; return rb;
}, [pubkey]); }, [pubkey]);

View File

@ -1,23 +1,20 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { RequestBuilder } from "@snort/system"; import { EventKind, NostrLink, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { MUTED } from "@/const";
import { getTagValues } from "@/utils";
export function useMutedPubkeys(host?: string, leaveOpen = false) { export function useMutedPubkeys(host?: string, leaveOpen = false) {
const mutedSub = useMemo(() => { const mutedSub = useMemo(() => {
if (!host) return null; if (!host) return null;
const rb = new RequestBuilder(`muted:${host}`); const rb = new RequestBuilder(`muted:${host}`);
rb.withOptions({ leaveOpen }); rb.withOptions({ leaveOpen });
rb.withFilter().kinds([MUTED]).authors([host]); rb.withFilter().kinds([EventKind.MuteList]).authors([host]);
return rb; return rb;
}, [host]); }, [host]);
const muted = useRequestBuilder(mutedSub); const muted = useRequestBuilder(mutedSub);
const mutedPubkeys = useMemo(() => { const mutedPubkeys = useMemo(() => {
return new Set(getTagValues(muted?.at(0)?.tags ?? [], "p")); return muted.flatMap(a => NostrLink.fromAllTags(a.tags));
}, [muted]); }, [muted]);
return mutedPubkeys; return mutedPubkeys;

View File

@ -1,11 +1,5 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useUserEmojiPacks } from "@/hooks/emoji";
import { MUTED, USER_CARDS, USER_EMOJIS } from "@/const";
import type { Tags } from "@/types";
import { getPublisher, getSigner, Login, LoginSession } from "@/login"; import { getPublisher, getSigner, Login, LoginSession } from "@/login";
export function useLogin() { export function useLogin() {
@ -27,52 +21,3 @@ export function useLogin() {
}, },
}; };
} }
export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const [userEmojis, setUserEmojis] = useState<Tags>([]);
const session = useSyncExternalStore(
c => Login.hook(c),
() => Login.snapshot(),
);
const sub = useMemo(() => {
if (!pubkey) return null;
const b = new RequestBuilder(`login:${pubkey.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.authors([pubkey])
.kinds([EventKind.ContactList, MUTED, USER_EMOJIS, USER_CARDS]);
return b;
}, [pubkey, leaveOpen]);
const data = useRequestBuilder(sub);
useEffect(() => {
if (!data) {
return;
}
for (const ev of data) {
if (ev?.kind === USER_EMOJIS) {
setUserEmojis(ev.tags);
}
if (ev?.kind === USER_CARDS) {
Login.setCards(ev.tags, ev.created_at);
}
if (ev?.kind === MUTED) {
Login.setMuted(ev.tags, ev.content, ev.created_at);
}
if (ev?.kind === EventKind.ContactList) {
Login.setFollows(ev.tags, ev.content, ev.created_at);
}
}
}, [data]);
const emojis = useUserEmojiPacks(pubkey, userEmojis);
useEffect(() => {
if (session) {
Login.setEmojis(emojis);
}
}, [emojis]);
}

View File

@ -4,8 +4,9 @@ import { useRequestBuilder } from "@snort/system-react";
import { LIVE_STREAM } from "@/const"; import { LIVE_STREAM } from "@/const";
import { useZaps } from "./zaps"; import { useZaps } from "./zaps";
export function useProfile(link: NostrLink, leaveOpen = false) { export function useProfile(link?: NostrLink, leaveOpen = false) {
const sub = useMemo(() => { const sub = useMemo(() => {
if (!link) return;
const b = new RequestBuilder(`profile:${link.id.slice(0, 12)}`); const b = new RequestBuilder(`profile:${link.id.slice(0, 12)}`);
b.withOptions({ b.withOptions({
leaveOpen, leaveOpen,

View File

@ -119,7 +119,7 @@ const router = createBrowserRouter([
element: <TagPage />, element: <TagPage />,
}, },
{ {
path: "/p/:npub", path: "/p/:id",
element: <ProfilePage />, element: <ProfilePage />,
}, },
{ {

View File

@ -1,28 +1,18 @@
import { bytesToHex } from "@noble/curves/abstract/utils"; import { bytesToHex } from "@noble/curves/abstract/utils";
import { schnorr } from "@noble/curves/secp256k1"; import { schnorr } from "@noble/curves/secp256k1";
import { ExternalStore, unwrap } from "@snort/shared"; import { ExternalStore, unwrap } from "@snort/shared";
import { EventPublisher, Nip7Signer, PrivateKeySigner } from "@snort/system"; import { EventPublisher, Nip7Signer, PrivateKeySigner, UserState, UserStateObject } from "@snort/system";
import type { EmojiPack, Tags } from "@/types";
export enum LoginType { export enum LoginType {
Nip7 = "nip7", Nip7 = "nip7",
PrivateKey = "private-key", PrivateKey = "private-key",
} }
interface ReplaceableTags {
tags: Tags;
content?: string;
timestamp: number;
}
export interface LoginSession { export interface LoginSession {
type: LoginType; type: LoginType;
pubkey: string; pubkey: string;
privateKey?: string; privateKey?: string;
follows: ReplaceableTags; state?: UserState<never>;
muted: ReplaceableTags;
cards: ReplaceableTags;
emojis: Array<EmojiPack>;
color?: string; color?: string;
wallet?: { wallet?: {
type: number; type: number;
@ -30,13 +20,6 @@ export interface LoginSession {
}; };
} }
const initialState = {
follows: { tags: [], timestamp: 0 },
muted: { tags: [], timestamp: 0 },
cards: { tags: [], timestamp: 0 },
emojis: [],
};
const SESSION_KEY = "session"; const SESSION_KEY = "session";
export class LoginStore extends ExternalStore<LoginSession | undefined> { export class LoginStore extends ExternalStore<LoginSession | undefined> {
@ -46,9 +29,40 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
super(); super();
const json = window.localStorage.getItem(SESSION_KEY); const json = window.localStorage.getItem(SESSION_KEY);
if (json) { if (json) {
this.#session = { ...initialState, ...JSON.parse(json) }; this.#session = JSON.parse(json);
if (this.#session) { if (this.#session) {
let save = false;
this.#session.state = new UserState(
this.#session?.pubkey,
undefined,
this.#session.state as UserStateObject<never> | undefined,
);
this.#session.state.on("change", () => {
this.#save();
});
//reset
this.#session.type ??= LoginType.Nip7; this.#session.type ??= LoginType.Nip7;
if ("cards" in this.#session) {
delete this.#session.cards;
save = true;
}
if ("emojis" in this.#session) {
delete this.#session.emojis;
save = true;
}
if ("follows" in this.#session) {
delete this.#session.follows;
save = true;
}
if ("muted" in this.#session) {
delete this.#session.muted;
save = true;
}
if (save) {
this.#save();
}
} }
} }
} }
@ -57,7 +71,6 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
this.#session = { this.#session = {
type, type,
pubkey: pk, pubkey: pk,
...initialState,
}; };
this.#save(); this.#save();
} }
@ -67,7 +80,6 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
type: LoginType.PrivateKey, type: LoginType.PrivateKey,
pubkey: bytesToHex(schnorr.getPublicKey(key)), pubkey: bytesToHex(schnorr.getPublicKey(key)),
privateKey: key, privateKey: key,
...initialState,
}; };
this.#save(); this.#save();
} }
@ -81,44 +93,6 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
return this.#session ? { ...this.#session } : undefined; return this.#session ? { ...this.#session } : undefined;
} }
setFollows(follows: Tags, content: string, ts: number) {
if (!this.#session) return;
if (this.#session.follows.timestamp >= ts) {
return;
}
this.#session.follows.tags = follows;
this.#session.follows.content = content;
this.#session.follows.timestamp = ts;
this.#save();
}
setEmojis(emojis: Array<EmojiPack>) {
if (!this.#session) return;
this.#session.emojis = emojis;
this.#save();
}
setMuted(muted: Tags, content: string, ts: number) {
if (!this.#session) return;
if (this.#session.muted.timestamp >= ts) {
return;
}
this.#session.muted.tags = muted;
this.#session.muted.content = content;
this.#session.muted.timestamp = ts;
this.#save();
}
setCards(cards: Tags, ts: number) {
if (!this.#session) return;
if (this.#session.cards.timestamp >= ts) {
return;
}
this.#session.cards.tags = cards;
this.#session.cards.timestamp = ts;
this.#save();
}
setColor(color: string) { setColor(color: string) {
if (!this.#session) return; if (!this.#session) return;
this.#session.color = color; this.#session.color = color;
@ -133,7 +107,11 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
#save() { #save() {
if (this.#session) { if (this.#session) {
window.localStorage.setItem(SESSION_KEY, JSON.stringify(this.#session)); const ses = { ...this.#session } as Record<string, unknown>;
if (this.#session.state instanceof UserState) {
ses.state = this.#session.state.serialize();
}
window.localStorage.setItem(SESSION_KEY, JSON.stringify(ses));
} else { } else {
window.localStorage.removeItem(SESSION_KEY); window.localStorage.removeItem(SESSION_KEY);
} }

View File

@ -1,5 +1,5 @@
import { useStreamsFeed } from "@/hooks/live-streams"; import { useStreamsFeed } from "@/hooks/live-streams";
import { getHost, getTagValues } from "@/utils"; import { getHost } from "@/utils";
import { dedupe, unwrap } from "@snort/shared"; import { dedupe, unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Profile } from "../../element/profile"; import { Profile } from "../../element/profile";
@ -19,12 +19,14 @@ export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose:
const [raiding, setRaiding] = useState(""); const [raiding, setRaiding] = useState("");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p")); const livePubkeys = dedupe(live.map(a => getHost(a))).filter(
const livePubkeys = dedupe(live.map(a => getHost(a))).filter(a => !mutedHosts.has(a)); a => !login?.state?.muted.some(b => b.equals(NostrLink.publicKey(a))),
);
async function raid() { async function raid() {
if (login) { const pub = login?.publisher();
const ev = await login.publisher().generic(eb => { if (pub) {
const ev = await pub.generic(eb => {
return eb return eb
.kind(LIVE_STREAM_RAID) .kind(LIVE_STREAM_RAID)
.tag(unwrap(link.toEventTag("root"))) .tag(unwrap(link.toEventTag("root")))

View File

@ -1,19 +1,29 @@
import "./layout.css"; import "./layout.css";
import { CSSProperties, useEffect } from "react"; import { CSSProperties, useContext, useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom"; import { Outlet, useLocation } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useLogin, useLoginEvents } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { trackEvent } from "@/utils"; import { trackEvent } from "@/utils";
import { HeaderNav } from "./header"; import { HeaderNav } from "./header";
import { LeftNav } from "./left-nav"; import { LeftNav } from "./left-nav";
import { SnortContext } from "@snort/system-react";
import { EventKind } from "@snort/system";
import { USER_CARDS } from "@/const";
export function LayoutPage() { export function LayoutPage() {
const location = useLocation(); const location = useLocation();
const login = useLogin(); const login = useLogin();
const system = useContext(SnortContext);
useLoginEvents(login?.pubkey, true); useEffect(() => {
if (login?.state) {
login.state.checkIsStandardList(EventKind.EmojisList);
login.state.checkIsStandardList(USER_CARDS);
login.state.init(login.signer(), system);
}
}, []);
useEffect(() => { useEffect(() => {
trackEvent("pageview"); trackEvent("pageview");

View File

@ -1,9 +1,8 @@
import "./profile-page.css"; import "./profile-page.css";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CachedMetadata, NostrEvent, NostrLink, TaggedNostrEvent, parseNostrLink } from "@snort/system"; import { CachedMetadata, NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Icon } from "@/element/icon"; import { Icon } from "@/element/icon";
@ -25,20 +24,21 @@ import { useProfileClips } from "@/hooks/clips";
import VideoGrid from "@/element/video-grid"; import VideoGrid from "@/element/video-grid";
import { ClipTile } from "@/element/stream/clip-tile"; import { ClipTile } from "@/element/stream/clip-tile";
import useImgProxy from "@/hooks/img-proxy"; import useImgProxy from "@/hooks/img-proxy";
import { useStreamLink } from "@/hooks/stream-link";
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp"; const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";
export function ProfilePage() { export function ProfilePage() {
const params = useParams(); const link = useStreamLink();
const link = parseNostrLink(unwrap(params.npub));
const { streams, zaps } = useProfile(link, true); const { streams, zaps } = useProfile(link, true);
const profile = useUserProfile(link.id); const profile = useUserProfile(link?.id);
const { proxy } = useImgProxy(); const { proxy } = useImgProxy();
const pastStreams = useMemo(() => { const pastStreams = useMemo(() => {
return streams.filter(ev => findTag(ev, "status") === StreamState.Ended); return streams.filter(ev => findTag(ev, "status") === StreamState.Ended);
}, [streams]); }, [streams]);
if (!link) return;
return ( return (
<div className="flex flex-col gap-3 xl:px-4 w-full"> <div className="flex flex-col gap-3 xl:px-4 w-full">
<img <img

View File

@ -1,22 +1,32 @@
import { VIDEO_KIND } from "@/const"; import { VIDEO_KIND } from "@/const";
import VideoGrid from "@/element/video-grid"; import VideoGrid from "@/element/video-grid";
import { findTag } from "@/utils"; import { findTag, getHost } from "@/utils";
import { RequestBuilder } from "@snort/system"; import { NostrLink, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { VideoTile } from "@/element/video/video-tile"; import { VideoTile } from "@/element/video/video-tile";
import { useLogin } from "@/hooks/login";
export function VideosPage() { export function VideosPage() {
const login = useLogin();
const rb = new RequestBuilder("videos"); const rb = new RequestBuilder("videos");
rb.withFilter().kinds([VIDEO_KIND]); rb.withFilter().kinds([VIDEO_KIND]);
const videos = useRequestBuilder(rb); const videos = useRequestBuilder(rb);
const sorted = videos.sort((a, b) => { console.debug(login?.state?.muted);
const pubA = findTag(a, "published_at"); const sorted = videos
const pubB = findTag(b, "published_at"); .filter(a => {
return Number(pubA) > Number(pubB) ? -1 : 1; const host = getHost(a);
}); const link = NostrLink.publicKey(host);
return (login?.state?.muted.length ?? 0) === 0 || !login?.state?.muted.some(a => a.equals(link));
})
.sort((a, b) => {
const pubA = findTag(a, "published_at");
const pubB = findTag(b, "published_at");
return Number(pubA) > Number(pubB) ? -1 : 1;
});
return ( return (
<div className="p-4"> <div className="p-4">

View File

@ -2687,14 +2687,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@snort/system-react@npm:^1.3.3": "@snort/system-react@npm:^1.3.5":
version: 1.3.3 version: 1.3.5
resolution: "@snort/system-react@npm:1.3.3" resolution: "@snort/system-react@npm:1.3.5"
dependencies: dependencies:
"@snort/shared": "npm:^1.0.15" "@snort/shared": "npm:^1.0.15"
"@snort/system": "npm:^1.3.3" "@snort/system": "npm:^1.3.5"
react: "npm:^18.2.0" react: "npm:^18.2.0"
checksum: 10c0/c99d27e3367a31e278c4b293b11b00b8aefd4600dc081d2c422606572ba170d2f0256bff6e55523f8884906e54a3699149154a7feb7119410a484975c6c815c4 checksum: 10c0/5e03db4a0034bbf672238f2091e86974ba21ecdb6e8562d869a7141b8b8f447802a2c8c3ef10824b81ad6288ae69d39505fb4572965bebba856990edec37dfb2
languageName: node languageName: node
linkType: hard linkType: hard
@ -2726,9 +2726,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@snort/system@npm:^1.3.3": "@snort/system@npm:^1.3.5":
version: 1.3.3 version: 1.3.5
resolution: "@snort/system@npm:1.3.3" resolution: "@snort/system@npm:1.3.5"
dependencies: dependencies:
"@noble/curves": "npm:^1.4.0" "@noble/curves": "npm:^1.4.0"
"@noble/hashes": "npm:^1.4.0" "@noble/hashes": "npm:^1.4.0"
@ -2743,7 +2743,7 @@ __metadata:
lru-cache: "npm:^10.2.0" lru-cache: "npm:^10.2.0"
uuid: "npm:^9.0.0" uuid: "npm:^9.0.0"
ws: "npm:^8.14.0" ws: "npm:^8.14.0"
checksum: 10c0/a153ec74f0c12a6f739d1c1fd4cc78e3ca8d5a8a8c61f876a87e875ed3e330339d6e155fa52f36830aab2da0e8bfc210fe55da1f77e960b14a70e3aebd01ab7f checksum: 10c0/cc48ad0f9e9d857061fb9f04b1d753bc9d284b9f89ec8a766c3afe3a0af33b4f7219dc9d068365ecdc38fde4cc91b9476dbfa3883eb1c9f317b6fbf5b3e681ad
languageName: node languageName: node
linkType: hard linkType: hard
@ -7678,8 +7678,8 @@ __metadata:
"@noble/hashes": "npm:^1.4.0" "@noble/hashes": "npm:^1.4.0"
"@scure/base": "npm:^1.1.6" "@scure/base": "npm:^1.1.6"
"@snort/shared": "npm:^1.0.15" "@snort/shared": "npm:^1.0.15"
"@snort/system": "npm:^1.3.3" "@snort/system": "npm:^1.3.5"
"@snort/system-react": "npm:^1.3.3" "@snort/system-react": "npm:^1.3.5"
"@snort/system-wasm": "npm:^1.0.4" "@snort/system-wasm": "npm:^1.0.4"
"@snort/wallet": "npm:^0.1.3" "@snort/wallet": "npm:^0.1.3"
"@snort/worker-relay": "npm:^1.1.0" "@snort/worker-relay": "npm:^1.1.0"