4e9c6b37d3
Touches user-facing labels only: - TopNav: Support -> Campaigns, Organize -> Groups - Sidebar: Organize -> Groups - MobileBottomNav: Organize -> Groups - /communities hero kicker: Organize -> Groups Routes, hooks, and the country-organizers admin feature (`OrganizersPage` / `useOrganizers` — a separate concept covering appointed pinners for country feeds) are left alone. Code comments referring to the "Organize hero" are kept as-is so future readers can still find their way around by structural name.
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import {
|
|
Activity,
|
|
Award,
|
|
BadgeCheck,
|
|
Bell,
|
|
Bird,
|
|
Blocks,
|
|
BookMarked,
|
|
BookOpen,
|
|
Bookmark,
|
|
Bot,
|
|
CalendarDays,
|
|
Camera,
|
|
Clapperboard,
|
|
Code,
|
|
Earth,
|
|
Film,
|
|
HandHeart,
|
|
HelpCircle,
|
|
Highlighter,
|
|
LifeBuoy,
|
|
List,
|
|
MessageSquare,
|
|
MessageSquareMore,
|
|
Megaphone,
|
|
Mic,
|
|
Music,
|
|
Newspaper,
|
|
Palette,
|
|
PartyPopper,
|
|
Podcast,
|
|
Repeat2,
|
|
Search,
|
|
ScrollText,
|
|
Settings,
|
|
Smile,
|
|
SmilePlus,
|
|
Stars,
|
|
User,
|
|
Users,
|
|
Vote,
|
|
WalletMinimal,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { VERIFIED_PAGE_PATH } from "@/lib/agoraDefaults";
|
|
import { CardsIcon } from "@/components/icons/CardsIcon";
|
|
import { ChestIcon } from "@/components/icons/ChestIcon";
|
|
import { LogoIcon } from "@/components/icons/LogoIcon";
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
type IconComponent = React.ComponentType<{ className?: string }>;
|
|
|
|
/** Sentinel ID used to represent a visual divider in the sidebar order. */
|
|
export const SIDEBAR_DIVIDER_ID = "divider";
|
|
|
|
/** Returns true if the given sidebar order ID is a divider sentinel. */
|
|
export function isSidebarDivider(id: string): boolean {
|
|
return id === SIDEBAR_DIVIDER_ID;
|
|
}
|
|
|
|
/** Returns true if the given sidebar order ID is a `nostr:` URI. */
|
|
export function isNostrUri(id: string): boolean {
|
|
return id.startsWith("nostr:");
|
|
}
|
|
|
|
/** Returns true if the given sidebar order ID is an `nsite://` URI. */
|
|
export function isNsiteUri(id: string): boolean {
|
|
return id.startsWith("nsite://");
|
|
}
|
|
|
|
/** Extracts the nsite subdomain from an `nsite://` URI. */
|
|
export function nsiteUriToSubdomain(uri: string): string {
|
|
return uri.slice("nsite://".length);
|
|
}
|
|
|
|
/** Extracts the NIP-19 bech32 identifier from a `nostr:` URI. Returns the raw string if not a nostr: URI. */
|
|
export function nostrUriToNip19(uri: string): string {
|
|
return uri.startsWith("nostr:") ? uri.slice(6) : uri;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given sidebar order ID is an external content identifier
|
|
* (i-tag value): an https:// URL or a prefixed identifier like `iso3166:US`.
|
|
*/
|
|
export function isExternalUri(id: string): boolean {
|
|
return (
|
|
id.startsWith("https://") ||
|
|
id.startsWith("http://") ||
|
|
id.startsWith("iso3166:") ||
|
|
id.startsWith("isbn:")
|
|
);
|
|
}
|
|
|
|
/** A sidebar-capable item with everything needed for display and navigation. */
|
|
export interface SidebarItemDef {
|
|
/** Unique identifier stored in sidebarOrder. */
|
|
id: string;
|
|
/** Display label. */
|
|
label: string;
|
|
/** Navigation path (e.g. '/feed', '/notifications', '/vines'). */
|
|
path: string;
|
|
/** Icon component. */
|
|
icon: IconComponent;
|
|
/** If true, only shown when a user is logged in. */
|
|
requiresAuth?: boolean;
|
|
/** If true, only shown to platform admins (implies requiresAuth). */
|
|
requiresAdmin?: boolean;
|
|
}
|
|
|
|
// ── Registry ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Single source of truth for all sidebar items.
|
|
*
|
|
* Every item that can appear in the sidebar — whether it's a system page like
|
|
* "Feed" or a Nostr content type like "Vines" — lives here with a consistent
|
|
* shape. The order here is the default display order for fresh installs.
|
|
*/
|
|
export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
|
// System pages
|
|
{
|
|
id: "wallet",
|
|
label: "Wallet",
|
|
path: "/wallet",
|
|
icon: WalletMinimal,
|
|
requiresAuth: true,
|
|
},
|
|
{ id: "feed", label: "Feed", path: "/feed", icon: LogoIcon },
|
|
{ id: "campaigns", label: "Fundraisers", path: "/campaigns", icon: HandHeart },
|
|
{
|
|
id: "notifications",
|
|
label: "Notifications",
|
|
path: "/notifications",
|
|
icon: Bell,
|
|
requiresAuth: true,
|
|
},
|
|
{
|
|
id: "messages",
|
|
label: "Messages",
|
|
path: "/messages",
|
|
icon: MessageSquareMore,
|
|
},
|
|
{ id: "search", label: "Search", path: "/search", icon: Search },
|
|
{
|
|
id: "verified",
|
|
label: "Verified",
|
|
path: VERIFIED_PAGE_PATH,
|
|
icon: BadgeCheck,
|
|
},
|
|
{
|
|
id: "bookmarks",
|
|
label: "Bookmarks",
|
|
path: "/bookmarks",
|
|
icon: Bookmark,
|
|
requiresAuth: true,
|
|
},
|
|
{
|
|
id: "profile",
|
|
label: "Profile",
|
|
path: "/profile",
|
|
icon: User,
|
|
requiresAuth: true,
|
|
},
|
|
{
|
|
id: "lists",
|
|
label: "Lists",
|
|
path: "/lists",
|
|
icon: List,
|
|
requiresAuth: true,
|
|
},
|
|
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
|
|
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
|
|
{ id: "help", label: "Help", path: "/help", icon: LifeBuoy },
|
|
{ id: "agent", label: "Agent", path: "/agent", icon: Bot },
|
|
// Content types
|
|
{ id: "actions", label: "Pledges", path: "/pledges", icon: Megaphone },
|
|
{ id: "events", label: "Events", path: "/events", icon: CalendarDays },
|
|
{ id: "photos", label: "Photos", path: "/photos", icon: Camera },
|
|
{ id: "videos", label: "Videos", path: "/videos", icon: Film },
|
|
{ id: "articles", label: "Articles", path: "/articles", icon: Newspaper },
|
|
{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter },
|
|
{ id: "books", label: "Books", path: "/books", icon: BookMarked },
|
|
{ id: "vines", label: "Divines", path: "/vines", icon: Clapperboard },
|
|
{ id: "music", label: "Music", path: "/music", icon: Music },
|
|
{ id: "podcasts", label: "Podcasts", path: "/podcasts", icon: Podcast },
|
|
{ id: "webxdc", label: "Webxdc", path: "/webxdc", icon: Blocks },
|
|
{ id: "polls", label: "Polls", path: "/polls", icon: Vote },
|
|
{ id: "packs", label: "Follow Packs", path: "/packs", icon: PartyPopper },
|
|
{ id: "colors", label: "Color Moments", path: "/colors", icon: Palette },
|
|
{ id: "decks", label: "Magic Decks", path: "/decks", icon: CardsIcon },
|
|
{ id: "treasures", label: "Treasures", path: "/treasures", icon: ChestIcon },
|
|
{ id: "emojis", label: "Emojis", path: "/emojis", icon: SmilePlus },
|
|
{ id: "development", label: "Development", path: "/development", icon: Code },
|
|
{ id: "badges", label: "Badges", path: "/badges", icon: Award },
|
|
{ id: "communities", label: "Groups", path: "/communities", icon: Users },
|
|
{ id: "world", label: "World", path: "/world", icon: Earth },
|
|
];
|
|
|
|
/**
|
|
* Sidebar items that are addable from their own page but omitted from the
|
|
* default/sidebar customization option list.
|
|
*/
|
|
const OPTIONAL_SIDEBAR_ITEMS: SidebarItemDef[] = [
|
|
{ id: "dashboard", label: "Dashboard", path: "/dashboard", icon: Activity },
|
|
];
|
|
|
|
const ALL_SIDEBAR_ITEMS = [...SIDEBAR_ITEMS, ...OPTIONAL_SIDEBAR_ITEMS];
|
|
|
|
/** Set of all known sidebar item IDs for quick lookup. */
|
|
export const SIDEBAR_ITEM_IDS = new Set(ALL_SIDEBAR_ITEMS.map((s) => s.id));
|
|
|
|
/** Map from ID to definition for O(1) lookup. */
|
|
const SIDEBAR_ITEM_MAP = new Map(ALL_SIDEBAR_ITEMS.map((s) => [s.id, s]));
|
|
|
|
/**
|
|
* Icons for content types used outside the sidebar (e.g. ContentSettings).
|
|
* Feed-only kinds that don't have sidebar pages are included here too.
|
|
*/
|
|
export const CONTENT_KIND_ICONS: Record<string, IconComponent> = {
|
|
posts: MessageSquare,
|
|
comments: MessageSquareMore,
|
|
reposts: Repeat2,
|
|
"generic-reposts": Repeat2,
|
|
reactions: SmilePlus,
|
|
zaps: Zap,
|
|
voice: Mic,
|
|
"custom-emojis": Smile,
|
|
statuses: SmilePlus,
|
|
"bird-detections": Bird,
|
|
constellations: Stars,
|
|
...Object.fromEntries(
|
|
SIDEBAR_ITEMS.filter((s) => s.icon).map((s) => [s.id, s.icon]),
|
|
),
|
|
videos: Film,
|
|
books: BookOpen,
|
|
vines: Clapperboard,
|
|
music: Music,
|
|
podcasts: Podcast,
|
|
webxdc: Palette,
|
|
packs: PartyPopper,
|
|
colors: Palette,
|
|
decks: CardsIcon,
|
|
treasures: ChestIcon,
|
|
emojis: SmilePlus,
|
|
development: Code,
|
|
badges: HelpCircle,
|
|
communities: Users,
|
|
world: Earth,
|
|
archive: HelpCircle,
|
|
bluesky: HelpCircle,
|
|
vanish: MessageSquareMore,
|
|
};
|
|
|
|
// ── Lookups ───────────────────────────────────────────────────────────────────
|
|
|
|
/** Get the sidebar item definition by ID, or undefined if unknown. */
|
|
export function getSidebarItem(id: string): SidebarItemDef | undefined {
|
|
return SIDEBAR_ITEM_MAP.get(id);
|
|
}
|
|
|
|
/** Returns the icon element for a sidebar item ID at the given size. */
|
|
export function sidebarItemIcon(
|
|
id: string,
|
|
size = "size-6",
|
|
): React.ReactElement {
|
|
const Icon = SIDEBAR_ITEM_MAP.get(id)?.icon ?? Palette;
|
|
return <Icon className={size} />;
|
|
}
|
|
|
|
/** Lookup display label for a sidebar item ID. */
|
|
export function itemLabel(id: string): string {
|
|
return SIDEBAR_ITEM_MAP.get(id)?.label ?? id;
|
|
}
|
|
|
|
/** Lookup navigation path for a sidebar item ID. */
|
|
export function itemPath(
|
|
id: string,
|
|
profilePath?: string,
|
|
homePage?: string,
|
|
): string {
|
|
if (id === "profile" && profilePath) return profilePath;
|
|
if (homePage && id === homePage) return "/";
|
|
return SIDEBAR_ITEM_MAP.get(id)?.path ?? `/${id}`;
|
|
}
|
|
|
|
/**
|
|
* Search sidebar items by label. Matches when the query is a prefix of the
|
|
* full label or of any word within the label (e.g. "arch" matches "Archive"
|
|
* and "Internet Archive" but not "Search"). Whole-label prefix matches are
|
|
* sorted before word-boundary matches. Auth-requiring items are excluded
|
|
* when the user is not logged in.
|
|
*/
|
|
export function searchSidebarItems(
|
|
query: string,
|
|
): SidebarItemDef[] {
|
|
const q = query.trim().toLowerCase();
|
|
if (q.length === 0) return [];
|
|
|
|
const prefixMatches: SidebarItemDef[] = [];
|
|
const wordMatches: SidebarItemDef[] = [];
|
|
|
|
for (const item of SIDEBAR_ITEMS) {
|
|
const label = item.label.toLowerCase();
|
|
if (label.startsWith(q)) {
|
|
prefixMatches.push(item);
|
|
} else {
|
|
// Check if query matches the start of any word in the label
|
|
const words = label.split(/\s+/);
|
|
if (words.some((word) => word.startsWith(q))) {
|
|
wordMatches.push(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...prefixMatches, ...wordMatches];
|
|
}
|
|
|
|
/** Check if a sidebar item is active given the current location. */
|
|
export function isItemActive(
|
|
id: string,
|
|
pathname: string,
|
|
_search: string,
|
|
profilePath?: string,
|
|
homePage?: string,
|
|
): boolean {
|
|
// Nostr URI items: active when pathname matches /<nip19>
|
|
if (isNostrUri(id)) {
|
|
const nip19Id = nostrUriToNip19(id);
|
|
return pathname === `/${nip19Id}`;
|
|
}
|
|
|
|
// Nsite URI items: active when the nsite preview is open for this subdomain.
|
|
// The pathname will be the naddr of the nsite event, which we can't cheaply
|
|
// derive here without async resolution. For now, nsite items are never
|
|
// highlighted as "active" via pathname — the visual indication comes from
|
|
// the nsite preview panel being open.
|
|
if (isNsiteUri(id)) {
|
|
return false;
|
|
}
|
|
|
|
// External content items: active when pathname matches /i/<encoded-value>
|
|
if (isExternalUri(id)) {
|
|
return pathname === `/i/${encodeURIComponent(id)}` || pathname === `/i/${id}`;
|
|
}
|
|
|
|
if (id === "profile") return !!profilePath && pathname === profilePath;
|
|
if (id === "settings") return pathname.startsWith("/settings");
|
|
|
|
const itemDef = SIDEBAR_ITEM_MAP.get(id);
|
|
const itemPathname = itemDef?.path ?? `/${id}`;
|
|
|
|
// Homepage item is active at both "/" and its own path
|
|
if (homePage && id === homePage)
|
|
return pathname === "/" || pathname === itemPathname;
|
|
|
|
return pathname === itemPathname;
|
|
}
|