feat: follow sets page

This commit is contained in:
2025-05-06 15:09:43 +01:00
parent 91c912a886
commit d22ce56ebc
13 changed files with 195 additions and 80 deletions

View File

@ -12,6 +12,8 @@ import usePreferences from "@/Hooks/usePreferences";
import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils";
import { useWallet } from "@/Wallet";
import { ProxyImg } from "../ProxyImg";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
const wallet = useWallet();
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
@ -62,29 +64,33 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
}
}
const picture = findTag(ev, "image");
return (
<FollowListBase
pubkeys={ids}
className={className}
title={findTag(ev, "title") ?? findTag(ev, "d")}
actions={
<>
<AsyncButton className="mr5 secondary" onClick={() => zapAll()}>
<FormattedMessage
defaultMessage="Zap all {n} sats"
id="IVbtTS"
values={{
n: <FormattedNumber value={defaultZapAmount * ids.length} />,
}}
/>
</AsyncButton>
</>
}
profilePreviewProps={{
options: {
about: true,
},
}}
/>
<>
{picture && <ProxyImg src={picture} className="br max-h-44 w-full object-cover mb-4" />}
<FollowListBase
pubkeys={ids}
className={className}
title={findTag(ev, "title") ?? findTag(ev, "d")}
actions={
<>
<AsyncButton className="mr5 secondary" onClick={() => zapAll()}>
<FormattedMessage
defaultMessage="Zap all {n} sats"
id="IVbtTS"
values={{
n: <FormattedNumber value={defaultZapAmount * ids.length} />,
}}
/>
</AsyncButton>
</>
}
profilePreviewProps={{
options: {
about: true,
},
}}
/>
</>
);
}

View File

@ -62,6 +62,7 @@ export default memo(function EventComponent(props: NoteProps) {
case EventKind.ZapstrTrack:
content = <ZapstrEmbed ev={ev} />;
break;
case EventKind.StarterPackSet:
case EventKind.FollowSet:
case EventKind.ContactList:
content = <PubkeyList ev={ev} className={className} />;

View File

@ -61,17 +61,6 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
</>
),
},
{
tab: "suggested",
path: `${base}/suggested`,
show: Boolean(pubKey),
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Suggested Follows" />
</>
),
},
{
tab: "trending/hashtags",
path: `${base}/trending/hashtags`,
@ -105,6 +94,17 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
</>
),
},
{
tab: "follow-sets",
path: `${base}/follow-sets`,
show: true,
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Follow Sets" />
</>
),
},
] as Array<{
tab: RootTabRoutePath;
path: string;

View File

@ -1,4 +1,4 @@
import { HexKey, NostrPrefix } from "@snort/system";
import { NostrPrefix } from "@snort/system";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
@ -61,7 +61,7 @@ export default function SuggestedProfiles() {
</select>
</div>
<FollowListBase
pubkeys={userList as HexKey[]}
pubkeys={userList}
profilePreviewProps={{
options: {
about: true,

View File

@ -1,16 +1,16 @@
import { HexKey } from "@snort/system";
import classNames from "classnames";
import { ReactNode } from "react";
import { FormattedMessage } from "react-intl";
import ProfilePreview, { ProfilePreviewProps } from "@/Components/User/ProfilePreview";
import useFollowsControls from "@/Hooks/useFollowControls";
import useLogin from "@/Hooks/useLogin";
import useWoT from "@/Hooks/useWoT";
import AsyncButton from "../Button/AsyncButton";
import messages from "../messages";
export interface FollowListBaseProps {
pubkeys: HexKey[];
pubkeys: string[];
title?: ReactNode;
showFollowAll?: boolean;
className?: string;
@ -27,7 +27,8 @@ export default function FollowListBase({
profilePreviewProps,
}: FollowListBaseProps) {
const control = useFollowsControls();
const login = useLogin();
const readonly = useLogin(s => s.readonly);
const wot = useWoT();
async function followAll() {
await control.addFollow(pubkeys);
@ -37,15 +38,17 @@ export default function FollowListBase({
<div className="flex flex-col gap-2">
{(showFollowAll ?? true) && (
<div className="flex items-center">
<div className="grow font-bold">{title}</div>
<div className="grow font-bold text-xl">{title}</div>
{actions}
<AsyncButton className="transparent" type="button" onClick={() => followAll()} disabled={login.readonly}>
<FormattedMessage {...messages.FollowAll} />
<AsyncButton className="transparent" type="button" onClick={() => followAll()} disabled={readonly}>
<FormattedMessage defaultMessage="Follow All" />
</AsyncButton>
</div>
)}
<div className={className}>
{pubkeys?.slice(0, 20).map(a => <ProfilePreview pubkey={a} key={a} {...profilePreviewProps} />)}
<div className={classNames("flex flex-col gap-2", className)}>
{wot.sortPubkeys(pubkeys).map(a => (
<ProfilePreview pubkey={a} key={a} {...profilePreviewProps} />
))}
</div>
</div>
);

View File

@ -277,6 +277,8 @@ export default function KindName({ kind }: { kind: number }) {
return <FormattedMessage defaultMessage="Community Definition" />;
case 38383:
return <FormattedMessage defaultMessage="Peer-to-peer Order events" />;
case 39089:
return <FormattedMessage defaultMessage="Starter Pack" />;
case 39701:
return <FormattedMessage defaultMessage="Web bookmarks" />;
default:

View File

@ -1,5 +1,5 @@
import { unwrap } from "@snort/shared";
import { EventKind, NostrLink, NostrPrefix, parseNostrLink } from "@snort/system";
import { Bech32Regex, unwrap } from "@snort/shared";
import { EventKind, NostrLink, NostrPrefix, tryParseNostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import classNames from "classnames";
import React, { useCallback, useMemo } from "react";
@ -9,24 +9,25 @@ import { useLocation, useNavigate } from "react-router-dom";
import { rootTabItems } from "@/Components/Feed/RootTabItems";
import { RootTabs } from "@/Components/Feed/RootTabs";
import Icon from "@/Components/Icons/Icon";
import KindName from "@/Components/kind-name";
import DisplayName from "@/Components/User/DisplayName";
import useLogin from "@/Hooks/useLogin";
import { LogoHeader } from "@/Pages/Layout/LogoHeader";
import NotificationsHeader from "@/Pages/Layout/NotificationsHeader";
import { bech32ToHex } from "@/Utils";
import { bech32ToHex, findTag } from "@/Utils";
export function Header() {
const navigate = useNavigate();
const location = useLocation();
const pageName = decodeURIComponent(location.pathname.split("/")[1]);
const pathSplit = location.pathname.split("/");
const pageName = decodeURIComponent(pathSplit[1]);
const nostrLink = useMemo(() => {
try {
return parseNostrLink(pageName);
} catch (e) {
return undefined;
const nostrEntity = pathSplit.find(a => a.match(Bech32Regex));
if (nostrEntity) {
return tryParseNostrLink(nostrEntity);
}
}, [pageName]);
}, [pathSplit]);
const { publicKey, tags } = useLogin(s => ({
publicKey: s.publicKey,
@ -115,17 +116,20 @@ export function Header() {
function NoteTitle({ link }: { link: NostrLink }) {
const ev = useEventFeed(link);
const values = useMemo(() => {
return { name: <DisplayName pubkey={ev?.pubkey ?? ""} /> };
}, [ev?.pubkey]);
if (!ev?.pubkey) {
return <FormattedMessage defaultMessage="Note" />;
}
const title = findTag(ev, "title");
return (
<>
<FormattedMessage defaultMessage="Note by {name}" values={values} />
<FormattedMessage
defaultMessage="{note_type} by {name}{title}"
values={{
note_type: <KindName kind={ev.kind} />,
name: <DisplayName pubkey={ev.pubkey} />,
title: title ? ` - ${title}` : "",
}}
/>
</>
);
}

View File

@ -1,5 +1,5 @@
import { dedupe, unwrap } from "@snort/shared";
import { EventKind, parseNostrLink } from "@snort/system";
import { parseNostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
@ -27,7 +27,8 @@ export function ListFeedPage() {
);
if (!data) return <PageSpinner />;
if (data.kind !== EventKind.ContactList && data.kind !== EventKind.FollowSet) {
const hasPTags = data.tags.some(a => a[0] === "p");
if (!hasPTags) {
return (
<b>
<FormattedMessage defaultMessage="Must be a contact list or pubkey list" />

View File

@ -0,0 +1,80 @@
import { dedupe } from "@snort/shared";
import { EventKind, NostrLink, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import AsyncButton from "@/Components/Button/AsyncButton";
import { AvatarGroup } from "@/Components/User/AvatarGroup";
import DisplayName from "@/Components/User/DisplayName";
import { ProfileLink } from "@/Components/User/ProfileLink";
import useFollowsControls from "@/Hooks/useFollowControls";
import useWoT from "@/Hooks/useWoT";
import { findTag } from "@/Utils";
export default function FollowSetsPage() {
const sub = new RequestBuilder("follow-sets");
sub.withFilter().kinds([EventKind.StarterPackSet, EventKind.FollowSet]);
const data = useRequestBuilder(sub);
const wot = useWoT();
const control = useFollowsControls();
const dataSorted = wot.sortEvents(data);
return (
<div className="p flex flex-col gap-4">
{dataSorted.map(a => {
const title = findTag(a, "title") ?? findTag(a, "d") ?? a.content;
const pTags = wot.sortPubkeys(dedupe(a.tags.filter(a => a[0] === "p").map(a => a[1])));
const isFollowingAll = pTags.every(a => control.isFollowing(a));
if (pTags.length === 0) return;
const link = NostrLink.fromEvent(a);
return (
<div key={a.id} className="p br bg-gray-ultradark flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="text-xl">{title}</div>
<div className="text-gray-medium font-medium flex items-center gap-2">
<Link to={`/${link.encode()}`} state={a}>
<FormattedMessage defaultMessage="{n} people" values={{ n: pTags.length }} />
</Link>
-
<Link to={`/list-feed/${link.encode()}`}>
<FormattedMessage defaultMessage="View Feed" />
</Link>
</div>
</div>
{!isFollowingAll && (
<div className="flex gap-4">
<AsyncButton
className="secondary"
onClick={async () => {
await control.addFollow(pTags);
}}>
<FormattedMessage defaultMessage="Follow All" />
</AsyncButton>
</div>
)}
</div>
<div className="flex gap-2">
<AvatarGroup ids={pTags.slice(0, 10)} size={40} />
</div>
<div>
<FormattedMessage
defaultMessage="<dark>Created by</dark> {name}"
values={{
dark: c => <span className="text-gray-medium">{c}</span>,
name: (
<ProfileLink pubkey={a.pubkey}>
<DisplayName pubkey={a.pubkey} />
</ProfileLink>
),
}}
/>
</div>
</div>
);
})}
</div>
);
}

View File

@ -1,4 +1,3 @@
import SuggestedProfiles from "@/Components/SuggestedProfiles";
import TrendingHashtags from "@/Components/Trending/TrendingHashtags";
import TrendingNotes from "@/Components/Trending/TrendingPosts";
import Discover from "@/Pages/Discover";
@ -6,6 +5,7 @@ import HashTagsPage from "@/Pages/HashTagsPage";
import { ConversationsTab } from "@/Pages/Root/ConversationsTab";
import { DefaultTab } from "@/Pages/Root/DefaultTab";
import { FollowedByFriendsTab } from "@/Pages/Root/FollowedByFriendsTab";
import FollowSetsPage from "@/Pages/Root/FollowSets";
import { ForYouTab } from "@/Pages/Root/ForYouTab";
import MediaPosts from "@/Pages/Root/Media";
import { NotesTab } from "@/Pages/Root/NotesTab";
@ -25,7 +25,8 @@ export type RootTabRoutePath =
| "suggested"
| "t/:tag"
| "topics"
| "media";
| "media"
| "follow-sets";
export type RootTabRoute = {
path: RootTabRoutePath;
@ -69,14 +70,6 @@ export const RootTabRoutes: RootTabRoute[] = [
path: "trending/hashtags",
element: <TrendingHashtags />,
},
{
path: "suggested",
element: (
<div className="p">
<SuggestedProfiles />
</div>
),
},
{
path: "t/:tag",
element: <HashTagsPage />,
@ -89,4 +82,8 @@ export const RootTabRoutes: RootTabRoute[] = [
path: "media",
element: <MediaPosts />,
},
{
path: "follow-sets",
element: <FollowSetsPage />,
},
];

View File

@ -465,6 +465,9 @@
"9HU8vw": {
"defaultMessage": "Reply"
},
"9RNiUn": {
"defaultMessage": "View Feed"
},
"9SvQep": {
"defaultMessage": "Follows {n}"
},
@ -493,9 +496,6 @@
"defaultMessage": "Parent",
"description": "Link to parent note in thread"
},
"ALdW69": {
"defaultMessage": "Note by {name}"
},
"AN0Z7Q": {
"defaultMessage": "Muted Words"
},
@ -587,9 +587,6 @@
"C8FsOr": {
"defaultMessage": "Popular Servers"
},
"C8HhVE": {
"defaultMessage": "Suggested Follows"
},
"CA1efg": {
"defaultMessage": "Video sets"
},
@ -608,6 +605,9 @@
"CM0k0d": {
"defaultMessage": "Prune follow list"
},
"CSOaM+": {
"defaultMessage": "{note_type} by {name}{title}"
},
"CVWeJ6": {
"defaultMessage": "Trending People"
},
@ -1398,6 +1398,9 @@
"V20Og0": {
"defaultMessage": "Labeling"
},
"V93INS": {
"defaultMessage": "<dark>Created by</dark> {name}"
},
"VOjC1i": {
"defaultMessage": "Pick which upload service you want to upload attachments to"
},
@ -1608,6 +1611,12 @@
"c3g2hL": {
"defaultMessage": "Broadcast Again"
},
"c6BMLV": {
"defaultMessage": "Starter Pack"
},
"cF3ruj": {
"defaultMessage": "Follow All"
},
"cFbU1B": {
"defaultMessage": "Using Alby? Go to {link} to get your NWC config!"
},
@ -1786,6 +1795,9 @@
"gDzDRs": {
"defaultMessage": "Emoji to send when reactiong to a note"
},
"gPxSgn": {
"defaultMessage": "Follow Sets"
},
"gXgY3+": {
"defaultMessage": "Not all clients support this yet"
},
@ -1810,6 +1822,9 @@
"grQ+mI": {
"defaultMessage": "Proof of Work"
},
"grRQTM": {
"defaultMessage": "{n} people"
},
"gtNjNP": {
"defaultMessage": "Basic protocol flow description"
},

View File

@ -154,6 +154,7 @@
"9+Ddtu": "Next",
"92gdbw": "Relay Discovery",
"9HU8vw": "Reply",
"9RNiUn": "View Feed",
"9SvQep": "Follows {n}",
"9V0wg3": "Calendar Event RSVP",
"9WRlF4": "Send",
@ -163,7 +164,6 @@
"9wO4wJ": "Lightning Invoice",
"A86fJ+": "Generic Repost",
"ADmfQT": "Parent",
"ALdW69": "Note by {name}",
"AN0Z7Q": "Muted Words",
"ASRK0S": "This author has been muted",
"AedFVZ": "Create or update a product",
@ -194,13 +194,13 @@
"C7642/": "Quote Repost",
"C81/uG": "Logout",
"C8FsOr": "Popular Servers",
"C8HhVE": "Suggested Follows",
"CA1efg": "Video sets",
"CHTbO3": "Failed to load invoice",
"CJ0biq": "Poll Response",
"CJx5Nd": "Profile Zaps",
"CM+Cfj": "Follow List",
"CM0k0d": "Prune follow list",
"CSOaM+": "{note_type} by {name}{title}",
"CVWeJ6": "Trending People",
"CYkOCI": "and {count} others you follow",
"Cdxwi0": "Repository announcements",
@ -463,6 +463,7 @@
"UsCzPc": "Share a personalized invitation with friends!",
"UxgyeY": "Your referral code is {code}",
"V20Og0": "Labeling",
"V93INS": "<dark>Created by</dark> {name}",
"VOjC1i": "Pick which upload service you want to upload attachments to",
"VR5eHw": "Public key (npub/nprofile)",
"VcwrfF": "Yes please",
@ -533,6 +534,8 @@
"c35bj2": "If you have an enquiry about your NIP-05 order please DM {link}",
"c3LlRO": "{n}KiB",
"c3g2hL": "Broadcast Again",
"c6BMLV": "Starter Pack",
"cF3ruj": "Follow All",
"cFbU1B": "Using Alby? Go to {link} to get your NWC config!",
"cG/bKQ": "Native nostr wallet connection",
"cHCwbF": "Photography",
@ -592,6 +595,7 @@
"g5pX+a": "About",
"g985Wp": "Failed to send vote",
"gDzDRs": "Emoji to send when reactiong to a note",
"gPxSgn": "Follow Sets",
"gXgY3+": "Not all clients support this yet",
"gczcC5": "Subscribe",
"geppt8": "{count} ({count2} in memory)",
@ -600,6 +604,7 @@
"gl1NeW": "Lists",
"go2/QF": "User server list",
"grQ+mI": "Proof of Work",
"grRQTM": "{n} people",
"gtNjNP": "Basic protocol flow description",
"h1gtUi": "Poll",
"h7jvCs": "{site} is more fun together!",

View File

@ -46,6 +46,7 @@ const enum EventKind {
CurationSet = 30_004, // NIP-51
InterestSet = 30_015, // NIP-15
EmojiSet = 30_030, // NIP-51
StarterPackSet = 39_089, // NIP-51
Badge = 30009, // NIP-58
ProfileBadges = 30008, // NIP-58