Compare commits

...

7 Commits

Author SHA1 Message Date
Chad Curtis 67de9c84be Blend community banner into tabs and tighten its layout
Wrap the hero banner and tab strip in a shared image+gradient backdrop
so the banner image continues underneath the tabs and fades into the
page background, removing the hard seam between them. The gradient
holds heavy darkness through the tab strip (kept legible with light
tab text + drop-shadowed underline) and drops to the page bg only at
the very bottom edge.

Reduce banner cognitive load: move the description behind an Info
button next to the title (drop the inline line-clamp and its
ResizeObserver-based clipping detection), promote the avatar stack
above the title row, and shorten the banner aspect ratio (2:1 mobile,
21:9 desktop).
2026-05-14 02:01:11 -05:00
Chad Curtis 738f33a594 Add icon and hover-to-unfollow options to FollowToggleButton 2026-05-14 01:42:33 -05:00
Chad Curtis ae17b06eda Restructure community tabs: fuse discussion into Activity, add Pulse, expandable description 2026-05-14 01:42:30 -05:00
Chad Curtis 312b5de8b3 Replace the world page bottom drawer with a floating discovery launcher
The persistent vaul bottom sheet ate half the map on phones and tablets
and made the docked discovery panel crush the map between 900px and
1280px. Swap it for a centered Dialog modal opened by a single button
anchored top-right next to Leaflet's zoom controls.

The docked WorldDiscoveryPanel now hides below xl (1280px) instead of
the sidebar breakpoint (900px) so the map stays usable when the panel
would otherwise crowd it. CommunityStatsPanel drops its rounded-2xl
card border in compact mode so it doesn't render box-in-a-box inside
the modal or docked panel.

Initial map zoom bumps from 2 to 3 below xl so phones and tablets
don't see ocean bands above and below the world tiles. Default
viewport center moves to Venezuela. Leaflet zoom controls picked up
themed background / foreground / border / primary-on-hover styling to
match the rest of the UI, and the country search header is opaque so
it no longer renders blurry inside the modal.
2026-05-14 01:26:06 -05:00
Chad Curtis 26475cf08f Anchor the community FAB menu to the FAB and merge Goals + Events
The community detail page previously fanned out the FAB into a stack of
chips positioned in the page's bottom-right corner, which drifted away
from the actual FAB on desktop (where the FAB is sticky inside the
center column). Move the menu into FloatingComposeButton itself: when a
page declares a `fabMenu` via useLayoutOptions, the FAB renders as a
Radix Popover trigger and the menu opens anchored to it on both mobile
and desktop. Hover state inverts to primary surface + foreground so
icons stop sitting on a same-color background (`--accent` mirrors
`--primary` in this theme system).

Initiatives now renders goals + events as one chronological list. The
sub-toggle is gone; active events sort ascending by start date, then
active goals by newest, then a single Past section in descending order
by closing/end timestamp.
2026-05-14 01:03:51 -05:00
Chad Curtis 719b76a362 Move community members into the banner with an inline add-member dialog
The community detail page now mirrors the adventure-detail / follow-pack
banner pattern: the hero image fills the top area with a gradient
overlay, and the title, description, member avatar stack, follow toggle,
members-only filter, edit, and share controls all sit inside it. The
former Members tab is gone; tapping the avatar stack opens a dedicated
members dialog that hosts the badge panel, leadership and rank-and-file
sections, ban controls, and (for founders/mods) an inline AddMemberPanel
so search-and-add happens in the same surface instead of a second
dialog hop.

To support that embed, AddMemberDialog's form body is extracted into a
reusable AddMemberPanel export; the thin Dialog wrapper is kept for any
existing callers and now delegates submit/reset/close to an onComplete
callback.
2026-05-14 00:47:10 -05:00
Chad Curtis b900c53016 Restyle navigation with V-angled bars and Agora bolt feed button
Replace the smooth arc shapes shared by the mobile top bar, sub-header
tabs, and bottom nav with angled V polylines centralized in
ArcBackground. The top bar and sub-header now use flat rectangles, and
the bottom nav has a sharp V apex that cradles a centered Agora-bolt
Feed button. The bottom nav row layout changes from
[Home, Search, Notifications, Profile] to
[Search, Communities, _apex_, Notifications, World] with smaller outer
items, and the apex links to the configured home/feed page with
scroll-to-top + invalidation on re-tap.

Also drop the redundant 'Feed' page header on the home feed and the
border under the compact ComposeBox so it blends with the tabs strip
below it.
2026-05-14 00:38:43 -05:00
21 changed files with 1374 additions and 803 deletions
+157 -118
View File
@@ -76,6 +76,19 @@ interface AddMemberDialogProps {
existingMemberPubkeys: string[];
}
interface AddMemberPanelProps {
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
/** Called after a successful publish so the host (dialog/page) can close or refresh. */
onComplete?: () => void;
}
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
if (!aTag) return undefined;
const [kind, pubkey, ...identifierParts] = aTag.split(':');
@@ -119,6 +132,57 @@ export function AddMemberDialog({
isFounder,
existingMemberPubkeys,
}: AddMemberDialogProps) {
const { user } = useCurrentUser();
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>Add to community</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5">
<AddMemberPanel
communityEvent={communityEvent}
community={community}
isFounder={isFounder}
existingMemberPubkeys={existingMemberPubkeys}
onComplete={() => onOpenChange(false)}
/>
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
/**
* Inline form that searches for people and adds them as community members or
* moderators. Pulled out of `AddMemberDialog` so the same flow can be
* embedded inside other surfaces — e.g. the members dialog on
* `CommunityDetailPage` — without nesting a second `Dialog`.
*/
export function AddMemberPanel({
communityEvent,
community,
isFounder,
existingMemberPubkeys,
onComplete,
}: AddMemberPanelProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
@@ -129,11 +193,6 @@ export function AddMemberDialog({
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -158,11 +217,6 @@ export function AddMemberDialog({
setIsPublishing(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// ── People management ─────────────────────────────────────────────────────
const addPerson = useCallback((profile: SearchProfile) => {
@@ -407,7 +461,8 @@ export function AddMemberDialog({
const addedCount = pendingMembers.length;
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
handleOpenChange(false);
resetForm();
onComplete?.();
} catch (err) {
toast({
title: 'Failed to add members',
@@ -419,121 +474,105 @@ export function AddMemberDialog({
}
}, [
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, isBadgeImageUploading,
badgeImageUrl, nostr, publishEvent, queryClient, toast, resetForm, onComplete, applyOptimisticMembership, isBadgeImageUploading,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>Add to community</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search</Label>
<PersonSearch
onAdd={addPerson}
onAddMany={addPeople}
excludePubkeys={[
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</div>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5 space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search</Label>
<PersonSearch
onAdd={addPerson}
onAddMany={addPeople}
excludePubkeys={[
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]}
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onRoleChange={isFounder ? setRole : undefined}
/>
</div>
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onRoleChange={isFounder ? setRole : undefined}
/>
))}
</div>
</div>
)}
{hasPendingMembers && (
<div className="space-y-2">
<Label>Member badge</Label>
{hasBadge ? (
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
{isBadgeError ? (
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
) : isBadgeLoading ? (
<div className="flex items-center gap-3">
<div className="size-12 animate-pulse rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
</div>
</div>
) : existingBadge ? (
<div className="flex items-center gap-3">
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{existingBadge.description || 'Selected members will receive this badge.'}
</p>
</div>
</div>
) : (
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
)}
</div>
) : (
<ImageUploadField
id="member-badge-image"
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
</div>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
))}
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
</div>
)}
{hasPendingMembers && (
<div className="space-y-2">
<Label>Member badge</Label>
{hasBadge ? (
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
{isBadgeError ? (
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
) : isBadgeLoading ? (
<div className="flex items-center gap-3">
<div className="size-12 animate-pulse rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
</div>
</div>
) : existingBadge ? (
<div className="flex items-center gap-3">
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{existingBadge.description || 'Selected members will receive this badge.'}
</p>
</div>
</div>
) : (
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
)}
</div>
) : (
<ImageUploadField
id="member-badge-image"
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
</div>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
</div>
);
}
+10 -6
View File
@@ -6,11 +6,15 @@ export const ARC_OVERHANG_PX = 20;
/** Larger overhang for the upward arc (bottom nav) so the harsher curve isn't clipped. */
export const ARC_UP_OVERHANG_PX = 28;
/** SVG path for a downward arc (used by top bar and sub-header bar). */
const ARC_DOWN_PATH = 'M0,0 L100,0 L100,44 Q50,64 0,44 Z';
/** SVG path for a downward angled bar (used by top bar and sub-header bar).
* Bottom edge slopes from each corner down to a center apex, forming an
* inverted-V that points toward the content below. */
const ARC_DOWN_PATH = 'M0,0 L100,0 L100,34 L50,46 L0,34 Z';
/** SVG path for an upward arc (used by bottom nav). */
const ARC_UP_PATH = 'M0,30 Q50,0 100,30 L100,64 L0,64 Z';
/** SVG path for an upward angled bar (used by bottom nav).
* Top edge slopes from each corner up to a center apex, forming a V that
* points away from the content. */
const ARC_UP_PATH = 'M0,40 L50,16 L100,40 L100,64 L0,64 Z';
/** SVG path for a plain rectangle with no arc. */
const RECT_PATH = 'M0,0 L100,0 L100,64 L0,64 Z';
@@ -54,8 +58,8 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
>
<path d={path} className="fill-background/85" />
{variant === 'down' && <path d="M0,44 Q50,64 100,44" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
{variant === 'up' && <path d="M0,30 Q50,0 100,30" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
{variant === 'down' && <path d="M0,34 L50,46 L100,34" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
{variant === 'up' && <path d="M0,40 L50,16 L100,40" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
</svg>
);
}
+433 -370
View File
@@ -1,42 +1,47 @@
import { useMemo, useCallback, useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
ArrowLeft,
Activity as ActivityIcon,
CalendarDays,
Crown,
Loader2,
Info,
MessageCircle,
MessageSquare,
MoreVertical,
Pencil,
Rss,
Radio,
Shield,
ShieldBan,
Share2,
Target,
UserCheck,
UserMinus,
UserPlus,
Users,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { AddMemberDialog } from '@/components/AddMemberDialog';
import { AddMemberPanel } from '@/components/AddMemberDialog';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { CommunityChatPanel } from '@/components/CommunityChatPanel';
import { CommunityPulsePanel } from '@/components/CommunityPulsePanel';
import { NoteContent } from '@/components/NoteContent';
import { CommunityBadgePanel } from '@/components/CommunityBadgePanel';
import { ComposeBox } from '@/components/ComposeBox';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { FollowToggleButton } from '@/components/FollowButton';
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useAuthor } from '@/hooks/useAuthor';
@@ -47,18 +52,13 @@ import { useCommunityEvents } from '@/hooks/useCommunityEvents';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCommunityGoals } from '@/hooks/useCommunityGoals';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeed } from '@/hooks/useFeed';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { useMuteList } from '@/hooks/useMuteList';
import { useNow } from '@/hooks/useNow';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { applyCommunityModerationToEvents, canBanTarget, getViewerAuthority, parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { shouldHideFeedEvent, type FeedItem } from '@/lib/feedUtils';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -138,94 +138,6 @@ function ReplyCardSkeleton() {
);
}
function CommunityMemberFeed({ authorPubkeys, isMembershipLoading }: { authorPubkeys: string[]; isMembershipLoading: boolean }) {
const { muteItems } = useMuteList();
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
const queryKey = useMemo(() => ['feed', 'follows'], []);
const handleRefresh = usePageRefresh(queryKey);
const uniqueAuthors = useMemo(() => Array.from(new Set(authorPubkeys)), [authorPubkeys]);
const {
data: rawData,
isPending,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useFeed('follows', { authors: uniqueAuthors });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const feedItems = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
return (rawData.pages as { items: FeedItem[] }[])
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!key || seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
}, [rawData?.pages, muteItems]);
if (!isMembershipLoading && uniqueAuthors.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No active members found.
</div>
);
}
if (isMembershipLoading || isPending || (isLoading && !rawData)) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message="No posts from active community members yet." />
</PullToRefresh>
);
}
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
</PullToRefresh>
);
}
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
@@ -271,12 +183,13 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const [banTargetPubkey, setBanTargetPubkey] = useState<string | null>(null);
// ── Tab + FAB state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState('members');
const [activeTab, setActiveTab] = useState('chat');
const [composeOpen, setComposeOpen] = useState(false);
const [goalDialogOpen, setGoalDialogOpen] = useState(false);
const [eventDialogOpen, setEventDialogOpen] = useState(false);
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [editCommunityOpen, setEditCommunityOpen] = useState(false);
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
const [descriptionDialogOpen, setDescriptionDialogOpen] = useState(false);
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
@@ -296,6 +209,27 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
// Whether to render the description info button next to the title — true
// whenever there's any description text or a stripped trailing URL.
const descriptionExpandable = !!descriptionText || !!descriptionUrl;
/**
* Synthesize a kind-1 pseudo-event so we can hand the description off to
* `NoteContent` for the same link / hashtag / nostr-URI / embed rendering
* used in NoteCard. Reuses the community event's `pubkey` and `id` to
* satisfy hooks inside `NoteContent` (author lookup, memo keys, etc.); the
* synthesized event is never published.
*/
const descriptionPseudoEvent = useMemo<NostrEvent>(() => ({
id: `community-description-${event.id}`,
pubkey: event.pubkey,
kind: 1,
created_at: event.created_at,
tags: [],
content: description,
sig: '',
}), [description, event.id, event.pubkey, event.created_at]);
// ── Members ─────────────────────────────────────────────────────────────────
const { data: membership, moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
const viewerMember = user ? getViewerAuthority(user.pubkey, rankMap, moderation) : undefined;
@@ -404,6 +338,37 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
[eventItems, now],
);
// ── Initiatives (Goals + Events merged into one chronological list) ───────
// Active items go first, sorted: future events ascending by start date,
// then active goals by creation date (newest first). Past items follow.
const activeInitiatives = useMemo(() => {
const items = [...activeGoals, ...activeEventItems];
return items.sort((a, b) => {
const aIsEvent = a.kind === 31922 || a.kind === 31923;
const bIsEvent = b.kind === 31922 || b.kind === 31923;
if (aIsEvent && bIsEvent) {
return getCalendarEventStart(a) - getCalendarEventStart(b);
}
if (aIsEvent) return -1;
if (bIsEvent) return 1;
return b.created_at - a.created_at;
});
}, [activeGoals, activeEventItems]);
const pastInitiatives = useMemo(() => {
const items = [...pastGoals, ...pastEventItems];
return items.sort((a, b) => {
// Newest-first by the relevant "end" timestamp.
const aEnd = a.kind === 31922 || a.kind === 31923
? getCalendarEventEnd(a)
: parseInt(a.tags.find(([n]) => n === 'closed_at')?.[1] ?? String(a.created_at), 10);
const bEnd = b.kind === 31922 || b.kind === 31923
? getCalendarEventEnd(b)
: parseInt(b.tags.find(([n]) => n === 'closed_at')?.[1] ?? String(b.created_at), 10);
return bEnd - aEnd;
});
}, [pastGoals, pastEventItems]);
const replyTree = useMemo((): ReplyNode[] => {
if (!commentsData) return [];
const topLevel = commentsData.topLevelComments ?? [];
@@ -439,6 +404,30 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
.map((r) => buildNode(r));
}, [commentsData, moderation, membersOnly, rankMap]);
// ── Activity feed — merge active initiatives with top-level discussion
// posts into one chronological stream (newest first). Each entry is tagged
// so the renderer can dispatch to NoteCard (initiative) or
// ThreadedReplyList (discussion subtree).
type ActivityItem =
| { kind: 'initiative'; event: NostrEvent; ts: number }
| { kind: 'discussion'; node: ReplyNode; ts: number };
const activityItems = useMemo((): ActivityItem[] => {
const items: ActivityItem[] = [
...activeInitiatives.map((ev) => ({
kind: 'initiative' as const,
event: ev,
ts: ev.created_at,
})),
...replyTree.map((node) => ({
kind: 'discussion' as const,
node,
ts: node.event.created_at,
})),
];
return items.sort((a, b) => b.ts - a.ts);
}, [activeInitiatives, replyTree]);
// ── Share handler ───────────────────────────────────────────────────────────
const handleShare = useCallback(async () => {
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
@@ -456,35 +445,46 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}
}, [event, toast]);
// ── FAB — visible on comments, goals, and members tabs ─────────────────────
const handleFabClick = useCallback(() => {
if (activeTab === 'comments') {
setComposeOpen(true);
} else if (activeTab === 'goals') {
setGoalDialogOpen(true);
} else if (activeTab === 'events') {
setEventDialogOpen(true);
} else if (activeTab === 'members') {
setAddMemberOpen(true);
}
}, [activeTab]);
// ── FAB — opens a floating action menu anchored to the FAB itself ─────────
// Visible on tabs whose surfaces accept user-authored content.
const fabAvailable = activeTab === 'activity';
const fabIcon = activeTab === 'goals'
? <Target strokeWidth={3} size={18} />
: activeTab === 'members'
? <UserPlus className="size-5" />
: activeTab === 'events'
? <CalendarDays className="size-5" />
: undefined; // default Plus icon for comments
const fabMenu = useMemo(() => {
if (!fabAvailable) return undefined;
return [
{
id: 'new-post',
label: 'New post',
icon: <MessageCircle className="size-4" />,
onSelect: () => {
setActiveTab('activity');
setComposeOpen(true);
},
},
{
id: 'new-goal',
label: 'New goal',
icon: <Target className="size-4" />,
onSelect: () => {
setActiveTab('activity');
setGoalDialogOpen(true);
},
},
{
id: 'new-event',
label: 'New event',
icon: <CalendarDays className="size-4" />,
onSelect: () => {
setActiveTab('activity');
setEventDialogOpen(true);
},
},
];
}, [fabAvailable]);
useLayoutOptions({
showFAB:
activeTab === 'comments'
|| activeTab === 'goals'
|| activeTab === 'events'
|| (activeTab === 'members' && canAddMembers),
onFabClick: handleFabClick,
fabIcon,
showFAB: fabAvailable,
fabMenu,
});
const moderationCtx = useMemo(
@@ -494,184 +494,189 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// ── Render ──────────────────────────────────────────────────────────────────
const heroIconClassName = 'size-5 text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]';
const actionButtonClassName = 'p-2 rounded-full text-muted-foreground hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none transition-colors';
const bannerActionClassName = 'p-2 rounded-full text-white/90 hover:text-white hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50 disabled:pointer-events-none transition-colors';
return (
<div className="max-w-2xl mx-auto pb-16">
{/* ── Hero image ── */}
<div className="relative h-32 w-full overflow-hidden sm:h-40">
{image ? (
<img src={image} alt={name} className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/50 via-primary/25 to-primary/5" />
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/10 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{!image && (
<Users className="size-12 text-primary/20 sm:size-16" />
)}
<CommunityModerationContext.Provider value={moderationCtx}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
{/* ── Hero banner + tabs share a single image/gradient backdrop so the
banner image continues underneath the tab strip and fades into the
page background — eliminating the seam between the two. ── */}
<div className="relative isolate overflow-hidden">
{/* Shared backdrop — image (or fallback gradient) + darkening overlay
that spans the full height of (banner + tabs) and fades to the
page background at its bottom edge. */}
<div aria-hidden className="absolute inset-0 -z-10">
{image ? (
<img src={image} alt="" className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/50 via-primary/25 to-primary/5" />
)}
{/* Darkening overlay that fades to the page background at the
bottom of the tab strip — makes tab text legible and erases the
hard seam between banner and tabs. Stops push the heavy darkness
down so it sits behind the tabs, not over the banner. */}
<div className="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_0%,transparent_15%,rgba(0,0,0,0.9)_75%,rgba(0,0,0,0.9)_97%,hsl(var(--background))_100%)]" />
</div>
{/* ── Top bar ── */}
<div className="absolute left-0 top-0 z-10 px-4 pt-4">
{/* Banner — fixed aspect ratio, title/description/buttons overlaid */}
<div className="relative w-full aspect-[2/1] sm:aspect-[21/9]">
{!image && (
<div className="absolute inset-0 flex items-center justify-center">
<Users className="size-16 text-primary/20 sm:size-20" />
</div>
)}
{/* Extra top/bottom darkening on the hero specifically (above the
shared overlay) so overlaid title/description stay legible. */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-transparent via-transparent to-black/40" />
{/* Top bar — back button (left) + follow toggle (right) */}
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between px-4 pt-4">
<button
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
className="p-2 -ml-2 rounded-full hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
className="p-2 -ml-2 rounded-full hover:bg-black/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
aria-label="Go back"
>
<ArrowLeft className={heroIconClassName} />
</button>
</div>
<div className="absolute bottom-0 left-0 right-0 px-5 pb-3">
<h2 className="text-xl font-bold text-white leading-tight drop-shadow-lg sm:text-2xl">{name}</h2>
</div>
</div>
{/* ── Community info ── */}
<div className="px-5 mt-3 space-y-3">
{/* Description */}
{descriptionText && (
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
)}
<div className="flex items-center justify-between gap-3">
{user && communityATag && (
<FollowToggleButton
size="sm"
isFollowing={communityFollowed}
isPending={toggleCommunityFollow.isPending}
onClick={handleToggleFollow}
icon={<UserPlus className="size-4" />}
followingIcon={
<>
<UserCheck className="size-4 group-hover:hidden group-focus-visible:hidden" />
<UserMinus className="size-4 hidden group-hover:inline group-focus-visible:inline" />
</>
}
hoverToUnfollow
className={cn(
'shadow-md',
!communityFollowed && 'bg-white text-black hover:bg-white/90',
communityFollowed && 'bg-black/30 backdrop-blur-sm border-white/40 text-white hover:bg-destructive/30 hover:text-white hover:border-destructive/60',
)}
/>
)}
<div className="ml-auto flex items-center gap-1">
<MembersOnlyToggle />
{isFounder && community && (
<button
type="button"
className={actionButtonClassName}
onClick={() => setEditCommunityOpen(true)}
aria-label="Edit community"
>
<Pencil className="size-5" />
</button>
)}
<button
type="button"
className={actionButtonClassName}
onClick={handleShare}
aria-label="Share"
>
<Share2 className="size-5" />
</button>
</div>
</div>
{/* ── Tabs ── */}
<CommunityModerationContext.Provider value={moderationCtx}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="-mx-5">
<TabsList className="w-full justify-start overflow-x-auto scrollbar-none rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsTrigger
value="members"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Users className="size-4 mr-1.5" />
Members
</TabsTrigger>
<TabsTrigger
value="chat"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<MessageSquare className="size-4 mr-1.5" />
Chat
</TabsTrigger>
<TabsTrigger
value="feed"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Rss className="size-4 mr-1.5" />
Feed
</TabsTrigger>
<TabsTrigger
value="comments"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<MessageCircle className="size-4 mr-1.5" />
Posts
</TabsTrigger>
<TabsTrigger
value="goals"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Target className="size-4 mr-1.5" />
Goals
</TabsTrigger>
<TabsTrigger
value="events"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<CalendarDays className="size-4 mr-1.5" />
Events
</TabsTrigger>
</TabsList>
{/* ── Members tab ── */}
<TabsContent value="members" className="mt-0">
{community && (
<section className="border-b border-border px-5 py-4">
<CommunityBadgePanel
communityEvent={event}
community={community}
isFounder={isFounder}
/>
</section>
{/* Member stack sits ABOVE the title; the title row carries the Info
button (left of name) and action buttons (right). Description has
moved behind an Info button to reduce banner clutter. */}
<div className="absolute bottom-0 left-0 right-0 px-5 pb-3 pt-8 [text-shadow:0_1px_4px_rgba(0,0,0,0.7),0_2px_8px_rgba(0,0,0,0.4)]">
<div className="flex [text-shadow:none]">
{/* Avatar stack — clickable to open full members dialog */}
<button
type="button"
onClick={() => setMembersDialogOpen(true)}
className="flex items-center gap-2 -ml-1 px-1 py-1 rounded-md hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors min-w-0"
aria-label="Show all members"
>
<PeopleAvatarStack
pubkeys={allMemberPubkeys}
maxVisible={6}
size="sm"
className="[&_.ring-2]:ring-black/40 pointer-events-none"
/>
{allMemberPubkeys.length > 0 && (
<span className="text-xs font-medium text-white/90 [text-shadow:0_1px_3px_rgba(0,0,0,0.7)] truncate">
{allMemberPubkeys.length} member{allMemberPubkeys.length !== 1 ? 's' : ''}
</span>
)}
{membersLoading ? (
<MembersSkeleton />
) : memberSections.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No members found.
</div>
) : (
<div className="divide-y divide-border">
{memberSections.map(({ key, label, members }) => (
<section key={key} className="px-5 py-4">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
{key === 'leadership' ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
{label}
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
</h3>
<div className="space-y-0.5">
{members.map((m) => {
let roleLabel: string | undefined;
if (m.rank === 0) {
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
}
// Determine if the current user can ban this member
const canBanMember = viewerMember
&& m.pubkey !== user?.pubkey
&& canBanTarget(viewerMember, m);
return (
<PersonRow
key={m.pubkey}
pubkey={m.pubkey}
label={roleLabel}
size="sm"
onBan={canBanMember ? () => {
setBanTargetPubkey(m.pubkey);
setBanDialogOpen(true);
} : undefined}
/>
);
})}
</div>
</section>
))}
</div>
</button>
</div>
<div className="mt-1.5 flex items-end justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<h2 className="text-xl font-bold text-white leading-tight sm:text-2xl truncate">{name}</h2>
{descriptionExpandable && (
<button
type="button"
onClick={() => setDescriptionDialogOpen(true)}
className="-my-1 -mr-1 p-1 rounded-full text-white/75 hover:text-white hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
aria-label="About this community"
>
<Info className="size-4 [text-shadow:none] drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" />
</button>
)}
</TabsContent>
</div>
{/* ── Chat tab ── */}
{/* Banner action row — MembersOnly + Share + overflow menu (Unfollow / Edit) */}
<div className="flex items-center gap-0.5 shrink-0 [text-shadow:none]">
<MembersOnlyToggle
className="text-white/90 hover:text-white hover:bg-white/15 data-[state=on]:text-white"
/>
<button
type="button"
className={bannerActionClassName}
onClick={handleShare}
aria-label="Share"
>
<Share2 className="size-5" />
</button>
{isFounder && community && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={bannerActionClassName}
aria-label="More actions"
>
<MoreVertical className="size-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" sideOffset={6} className="min-w-[180px]">
<DropdownMenuItem onSelect={() => setEditCommunityOpen(true)}>
<Pencil className="size-4 mr-2" />
Edit community
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
</div>
{/* ── Tabs ── */}
<TabsList className="w-full justify-stretch rounded-none border-b border-white/15 bg-transparent p-0 h-auto">
<TabsTrigger
value="chat"
className="flex-1 min-w-0 rounded-none border-b-2 border-transparent text-white/75 hover:text-white data-[state=active]:text-white data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2 [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]"
>
<MessageCircle className="size-4 mr-1.5" />
Chat
</TabsTrigger>
<TabsTrigger
value="activity"
className="flex-1 min-w-0 rounded-none border-b-2 border-transparent text-white/75 hover:text-white data-[state=active]:text-white data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2 [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]"
>
<ActivityIcon className="size-4 mr-1.5" />
Activity
</TabsTrigger>
<TabsTrigger
value="pulse"
className="flex-1 min-w-0 rounded-none border-b-2 border-transparent text-white/75 hover:text-white data-[state=active]:text-white data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2 [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]"
>
<Radio className="size-4 mr-1.5" />
Pulse
</TabsTrigger>
</TabsList>
</div>
{/* ── /shared banner+tabs backdrop wrapper ── */}
{/* Sublabel for the currently-active tab. Only rendered when the
tab has a descriptor to show — keeps the rest of the tab strip
clean. */}
{activeTab === 'pulse' && (
<div className="px-5 py-2 text-xs text-muted-foreground text-center">
What members are posting elsewhere across Nostr.
</div>
)}
{/* ── Chat tab — community kind-9 messages ── */}
<TabsContent value="chat" className="mt-0">
{communityATag ? (
<CommunityChatPanel
@@ -687,109 +692,179 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
)}
</TabsContent>
{/* ── Feed tab ── */}
<TabsContent value="feed" className="mt-0">
<CommunityMemberFeed
authorPubkeys={allMemberPubkeys}
isMembershipLoading={membersLoading}
/>
</TabsContent>
{/* ── Comments tab ── */}
<TabsContent value="comments" className="mt-0">
{/* ── Activity tab — chronological stream of initiatives
(goals + events) interleaved with threaded NIP-22 discussion,
followed by past initiatives. ── */}
<TabsContent value="activity" className="mt-0">
<ComposeBox compact replyTo={event} />
{commentsLoading ? (
{(commentsLoading || goalsLoading || eventsLoading) ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : replyTree.length > 0 ? (
<ThreadedReplyList roots={replyTree} />
) : membersOnly && commentsData && (commentsData.topLevelComments?.length ?? 0) > 0 ? (
) : activityItems.length === 0 && pastInitiatives.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No comments from community members yet. Toggle the shield icon to see all comments.
</div>
) : (
<div className="py-12 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</TabsContent>
{/* ── Goals tab ── */}
<TabsContent value="goals" className="mt-0">
{goalsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 2 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : activeGoals.length === 0 && pastGoals.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
{membersOnly && (goals ?? []).length > 0
? 'No goals from community members yet. Toggle the shield icon to see all goals.'
: <>No goals yet.{user ? ' Create one to get started!' : ''}</>}
{membersOnly && (
(commentsData && (commentsData.topLevelComments?.length ?? 0) > 0) ||
(goals ?? []).length > 0 ||
(communityEvents ?? []).length > 0
)
? 'No activity from community members yet. Toggle the shield icon to see everything.'
: <>No activity yet.{user ? ' Start a discussion, set a goal, or schedule an event!' : ''}</>}
</div>
) : (
<div className="divide-y divide-border">
{/* Active goals first */}
{activeGoals.map((e) => (
<NoteCard key={e.id} event={e} />
))}
{activityItems.map((item) =>
item.kind === 'initiative' ? (
<NoteCard key={item.event.id} event={item.event} />
) : (
<ThreadedReplyList key={item.node.event.id} roots={[item.node]} />
)
)}
{/* Past/expired goals */}
{pastGoals.length > 0 && activeGoals.length > 0 && (
{pastInitiatives.length > 0 && (
<div className="px-5 pt-4 pb-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Past Goals
Past
</h4>
</div>
)}
{pastGoals.map((e) => (
{pastInitiatives.map((e) => (
<NoteCard key={e.id} event={e} />
))}
</div>
)}
</TabsContent>
{/* ── Events tab ── */}
<TabsContent value="events" className="mt-0">
{eventsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : activeEventItems.length === 0 && pastEventItems.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
{membersOnly && (communityEvents ?? []).length > 0
? 'No events from community members yet. Toggle the shield icon to see all events.'
: <>No events yet.{user ? ' Create one to get started!' : ''}</>}
</div>
{/* ── Pulse tab — what community members are posting elsewhere
across Nostr. Excludes events tagged with this community's
`a` reference. ── */}
<TabsContent value="pulse" className="mt-0">
{communityATag ? (
<CommunityPulsePanel
communityATag={communityATag}
memberPubkeys={allMemberPubkeys}
isMembershipLoading={membersLoading}
/>
) : (
<div className="divide-y divide-border">
{activeEventItems.map((e) => (
<NoteCard key={e.id} event={e} />
))}
{pastEventItems.length > 0 && activeEventItems.length > 0 && (
<div className="px-5 pt-4 pb-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Past Events
</h4>
</div>
)}
{pastEventItems.map((e) => (
<NoteCard key={e.id} event={e} />
))}
<div className="py-12 text-center text-muted-foreground text-sm px-5">
Pulse is unavailable for this community.
</div>
)}
</TabsContent>
</Tabs>
</CommunityModerationContext.Provider>
</div>
{/* Description dialog — opened by clicking the truncated description in
the banner. Renders the full raw description plus a clickable
website link when the description ends with a URL. */}
<Dialog open={descriptionDialogOpen} onOpenChange={setDescriptionDialogOpen}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col overflow-hidden p-0 gap-0">
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border shrink-0">
<DialogTitle>About {name}</DialogTitle>
</DialogHeader>
<div className="px-5 py-4 overflow-y-auto">
{description ? (
<NoteContent
event={descriptionPseudoEvent}
className="text-sm leading-relaxed break-words"
/>
) : (
<p className="text-sm text-muted-foreground">No description provided.</p>
)}
</div>
</DialogContent>
</Dialog>
{/* Members dialog — opened from the avatar stack in the banner. Replaces
the former Members tab; contains badge panel, leadership +
rank-and-file sections, and (for founders/mods) the inline
search-and-add panel at the bottom. */}
<Dialog open={membersDialogOpen} onOpenChange={setMembersDialogOpen}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col overflow-hidden p-0 gap-0">
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border shrink-0">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5" />
Members
{allMemberPubkeys.length > 0 && (
<span className="text-muted-foreground font-normal text-sm">({allMemberPubkeys.length})</span>
)}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
{community && (
<section className="border-b border-border px-5 py-4">
<CommunityBadgePanel
communityEvent={event}
community={community}
isFounder={isFounder}
/>
</section>
)}
{membersLoading ? (
<MembersSkeleton />
) : memberSections.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No members found.
</div>
) : (
<div className="divide-y divide-border">
{memberSections.map(({ key, label, members }) => (
<section key={key} className="px-5 py-4">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
{key === 'leadership' ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
{label}
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
</h3>
<div className="space-y-0.5">
{members.map((m) => {
let roleLabel: string | undefined;
if (m.rank === 0) {
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
}
const canBanMember = viewerMember
&& m.pubkey !== user?.pubkey
&& canBanTarget(viewerMember, m);
return (
<PersonRow
key={m.pubkey}
pubkey={m.pubkey}
label={roleLabel}
size="sm"
onBan={canBanMember ? () => {
setBanTargetPubkey(m.pubkey);
setBanDialogOpen(true);
} : undefined}
/>
);
})}
</div>
</section>
))}
</div>
)}
</div>
{canAddMembers && community && (
<div className="border-t border-border px-5 py-4 shrink-0 max-h-[50vh] overflow-y-auto">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
<UserPlus className="size-3.5" />
Add members
</h3>
<AddMemberPanel
communityEvent={event}
community={community}
isFounder={isFounder}
existingMemberPubkeys={allMemberPubkeys}
/>
</div>
)}
</DialogContent>
</Dialog>
{/* Member ban confirmation dialog */}
{banTargetPubkey && communityATag && (
@@ -830,18 +905,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
/>
)}
{/* Add member dialog */}
{canAddMembers && community && (
<AddMemberDialog
open={addMemberOpen}
onOpenChange={setAddMemberOpen}
communityEvent={event}
community={community}
isFounder={isFounder}
existingMemberPubkeys={allMemberPubkeys}
/>
)}
{/* Edit community dialog — founder only */}
{isFounder && community && (
<CreateCommunityDialog
+173
View File
@@ -0,0 +1,173 @@
/**
* CommunityPulsePanel
*
* "Pulse" tab on the community detail page — an infinite-scrolling feed of
* posts published by community members *outside* this community. The intent
* is to surface what members are sharing in the wider Nostr ecosystem, as
* opposed to the in-community Activity tab.
*
* Implementation notes:
* - Authors come from the community `rankMap` (founders + moderators +
* members). Without authors the relay would return the entire global
* timeline.
* - Kinds come from `getEnabledFeedKinds(feedSettings)` so the feed
* respects the user's "Notes / Articles / Reposts / etc." preferences,
* exactly like the home feed.
* - Events tagged with this community's `a` reference are dropped — those
* belong on the Activity tab.
* - Replies (NIP-10 / NIP-22) are dropped so the Pulse reads like a
* timeline of top-level posts, not threaded responses.
* - Mute list, content-warning, and repost unwrap behavior come for free
* by reusing `useTabFeed` + the `feedUtils` helpers.
*/
import { useEffect, useMemo } from 'react';
import { useInView } from 'react-intersection-observer';
import { Loader2 } from 'lucide-react';
import type { NostrFilter } from '@nostrify/nostrify';
import { NoteCard } from '@/components/NoteCard';
import { Skeleton } from '@/components/ui/skeleton';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { isReplyEvent } from '@/lib/nostrEvents';
interface CommunityPulsePanelProps {
/** `34550:<pubkey>:<d>` — used both for the cache key and the in-community filter. */
communityATag: string;
/** Author allowlist — founders + moderators + members. */
memberPubkeys: string[];
/** True while membership is still resolving; suppresses an empty-state flash. */
isMembershipLoading: boolean;
}
export function CommunityPulsePanel({
communityATag,
memberPubkeys,
isMembershipLoading,
}: CommunityPulsePanelProps) {
const { muteItems } = useMuteList();
const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
// Build the TabFeed filter — kinds default to the user's enabled feed kinds
// (handled inside useTabFeed when `kinds` is omitted from the filter).
const filter = useMemo<NostrFilter | null>(
() => (memberPubkeys.length > 0 ? { authors: memberPubkeys } : null),
[memberPubkeys],
);
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTabFeed(filter, `community-pulse-${communityATag}`, memberPubkeys.length > 0);
// Fetch next page when the sentinel scrolls into view.
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
/**
* Drop events that reference *this* community via an `a` tag — they belong
* to the Activity tab, not Pulse. We check both the original event and the
* embedded event of a repost.
*/
const referencesThisCommunity = (tags: string[][]): boolean => {
for (const tag of tags) {
if (tag[0] === 'a' && tag[1] === communityATag) return true;
}
return false;
};
// Flatten pages, dedupe, and apply mute / content-warning / reply /
// in-community filters.
const feedItems = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
return data.pages
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
// Hide replies on original (non-repost) text notes; a repost of a
// reply is still a legitimate top-level surface.
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
return false;
}
// Drop anything authored against this community — that's Activity.
if (referencesThisCommunity(item.event.tags)) return false;
if (item.repostEvent && referencesThisCommunity(item.repostEvent.tags)) return false;
return true;
});
// `referencesThisCommunity` and `communityATag` referenced via closure —
// adding `communityATag` to deps is sufficient.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.pages, muteItems, communityATag]);
// ── States ────────────────────────────────────────────────────────────────
if (memberPubkeys.length === 0 && !isMembershipLoading) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No community members yet nothing to surface here.
</div>
);
}
if ((isLoading || isMembershipLoading) && feedItems.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No posts from community members elsewhere yet.
</div>
);
}
return (
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={sentinelRef} className="flex justify-center py-6">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</div>
);
}
+22 -4
View File
@@ -55,11 +55,22 @@ export function CommunityStatsPanel({ countryCode, className, compact = false }:
const [tf, setTf] = useState<StatsTimeframe>('7d');
if (isLoading && !stats) return <PanelSkeleton className={className} />;
if (isLoading && !stats) return <PanelSkeleton className={className} compact={compact} />;
if (!stats) return null;
return (
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
<section
className={cn(
// Standalone usage gets a card-style border. Compact usage is
// embedded inside another bordered surface (the world discovery
// modal / docked panel) where an extra border produces a
// box-in-a-box look — drop it and rely on spacing alone.
compact
? 'space-y-4'
: 'rounded-2xl border border-border bg-background/40 p-4 space-y-4',
className,
)}
>
<PanelHeader stats={stats} timeframe={tf} onTimeframeChange={setTf} />
<AggregateCounts stats={stats} timeframe={tf} compact={compact} />
<Leaderboards stats={stats} timeframe={tf} compact={compact} />
@@ -371,9 +382,16 @@ function RankBadge({ rank }: { rank: number }) {
// ── Skeleton ─────────────────────────────────────────────────────────────────
function PanelSkeleton({ className }: { className?: string }) {
function PanelSkeleton({ className, compact }: { className?: string; compact?: boolean }) {
return (
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
<section
className={cn(
compact
? 'space-y-4'
: 'rounded-2xl border border-border bg-background/40 p-4 space-y-4',
className,
)}
>
<Skeleton className="h-5 w-40" />
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{Array.from({ length: 5 }).map((_, i) => (
+1 -1
View File
@@ -236,7 +236,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
/>
)}
{!hideCompose && <ComposeBox compact />}
{!hideCompose && <ComposeBox compact hideBorder />}
{/* Tabs (logged in) */}
{user && (
+60 -2
View File
@@ -7,8 +7,10 @@ import {
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { FabButton } from '@/components/FabButton';
import type { FabMenuItem } from '@/contexts/LayoutContext';
// Lazy-load the compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import('@/components/ReplyComposeModal').then(m => ({ default: m.ReplyComposeModal })));
@@ -24,18 +26,74 @@ interface FloatingComposeButtonProps {
onFabClick?: () => void;
/** If set, overrides the default Plus icon. */
icon?: React.ReactNode;
/** If set, the FAB opens an anchored popover with these items. */
menu?: FabMenuItem[];
}
export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: FloatingComposeButtonProps) {
export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }: FloatingComposeButtonProps) {
const { user } = useCurrentUser();
const navigate = useNavigate();
const [composeOpen, setComposeOpen] = useState(false);
const [comingSoonOpen, setComingSoonOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
if (!user) {
return null;
}
const renderedIcon = icon ?? <Plus strokeWidth={4} size={16} />;
// ── Menu mode — anchor a Popover to the FAB itself ────────────────────────
if (menu && menu.length > 0) {
return (
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div className="absolute inset-0 bg-primary rounded-full" />
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{renderedIcon}
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
sideOffset={12}
className="w-auto min-w-[180px] p-1.5 rounded-2xl"
>
<div role="menu" aria-label="Add" className="flex flex-col gap-0.5">
{menu.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false);
item.onSelect();
}}
className="group flex items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground focus-visible:bg-primary focus-visible:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors text-left"
>
{item.icon && (
<span className="text-primary shrink-0 group-hover:text-primary-foreground group-focus-visible:text-primary-foreground transition-colors">
{item.icon}
</span>
)}
<span>{item.label}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}
const handleClick = () => {
if (onFabClick) {
onFabClick();
@@ -52,7 +110,7 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: Floa
<>
<FabButton
onClick={handleClick}
icon={icon ?? <Plus strokeWidth={4} size={16} />}
icon={renderedIcon}
/>
{/* Kind 1: Compose modal (lazy-loaded) */}
+33 -2
View File
@@ -29,6 +29,16 @@ interface FollowToggleButtonProps {
size?: 'default' | 'sm' | 'lg' | 'icon';
/** Disable the button. */
disabled?: boolean;
/** Optional leading icon shown before the label. */
icon?: React.ReactNode;
/** Optional leading icon shown when the target is followed (overrides `icon`). */
followingIcon?: React.ReactNode;
/**
* If true, the followed state shows "Following" by default and swaps to
* "Unfollow" on hover/focus (Twitter-style). When false (default), the
* followed state shows "Unfollow" directly.
*/
hoverToUnfollow?: boolean;
}
export function FollowToggleButton({
@@ -38,21 +48,42 @@ export function FollowToggleButton({
className,
size = 'sm',
disabled = false,
icon,
followingIcon,
hoverToUnfollow = false,
}: FollowToggleButtonProps) {
const leadingIcon = isFollowing ? (followingIcon ?? icon) : icon;
const followedLabel = (
isPending
? '...'
: isFollowing
? hoverToUnfollow
// Two spans crossfade on hover/focus via group state — keeps button width stable.
? (
<>
<span className="group-hover:hidden group-focus-visible:hidden">Following</span>
<span className="hidden group-hover:inline group-focus-visible:inline">Unfollow</span>
</>
)
: 'Unfollow'
: 'Follow'
);
return (
<Button
type="button"
size={size}
variant={isFollowing ? 'outline' : 'default'}
className={cn(
'rounded-full font-bold',
'group rounded-full font-bold gap-1.5',
isFollowing && 'bg-transparent border border-border text-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50',
className,
)}
onClick={onClick}
disabled={disabled || isPending}
>
{isPending ? '...' : isFollowing ? 'Unfollow' : 'Follow'}
{!isPending && leadingIcon}
{followedLabel}
</Button>
);
}
+3 -3
View File
@@ -51,7 +51,7 @@ function PageSkeleton() {
/** Inner component that reads layout options from the context store. */
function MainLayoutInner() {
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef<HTMLDivElement>(null);
@@ -98,7 +98,7 @@ function MainLayoutInner() {
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
</div>
</div>
</div>
@@ -128,7 +128,7 @@ function MainLayoutInner() {
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))` } : undefined}
>
<div className="pointer-events-auto">
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
</div>
</div>
)}
+119 -88
View File
@@ -1,14 +1,13 @@
import { useCallback, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Bell, Home, Search, User } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Bell, Earth, Search, Users } from 'lucide-react';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { cn } from '@/lib/utils';
import { selectionChanged } from '@/lib/haptics';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutSnapshot } from '@/contexts/LayoutContext';
import { getSidebarItem } from '@/lib/sidebarItems';
@@ -20,20 +19,51 @@ const hiddenStyle: React.CSSProperties = {
transform: `translateY(calc(100% + ${ARC_UP_OVERHANG_PX}px))`,
};
interface NavItemProps {
icon: React.ComponentType<{ className?: string }>;
label: string;
active: boolean;
badge?: boolean;
onClick?: (e: React.MouseEvent) => void;
to?: string;
/** 'sm' shrinks the slot (smaller flex basis + smaller icon/label) for outer items. */
size?: 'sm' | 'md';
}
/** A side item in the bottom nav row. */
function NavItem({ icon: Icon, label, active, badge, onClick, to, size = 'md' }: NavItemProps) {
const isSm = size === 'sm';
const className = cn(
'flex flex-col items-center justify-center gap-0.5 py-2 transition-colors min-w-0',
isSm ? 'flex-[0.7]' : 'flex-1',
active ? 'text-primary' : 'text-muted-foreground',
);
const inner = (
<>
<span className="relative">
<Icon className={isSm ? 'size-4' : 'size-5'} />
{badge && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className={cn('font-medium truncate', isSm ? 'text-[9px]' : 'text-[10px]')}>{label}</span>
</>
);
if (to) return <Link to={to} onClick={onClick} className={className}>{inner}</Link>;
return <button onClick={onClick} className={className}>{inner}</button>;
}
export function MobileBottomNav() {
const location = useLocation();
const queryClient = useQueryClient();
const { user, metadata } = useCurrentUser();
const { user } = useCurrentUser();
const hasUnread = useHasUnreadNotifications();
const { scrollContainer, noArcs } = useLayoutSnapshot();
const { hidden } = useScrollDirection(scrollContainer);
const profileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const { config } = useAppContext();
const homeItem = getSidebarItem(config.homePage);
const HomeIcon = homeItem?.icon ?? Home;
const homeLabel = homeItem?.label ?? 'Home';
const homePath = homeItem?.path;
const homePath = homeItem?.path ?? '/';
const [searchOpen, setSearchOpen] = useState(false);
@@ -43,11 +73,24 @@ export function MobileBottomNav() {
setSearchOpen((v) => !v);
}, []);
const handleFeedClick = useCallback((e: React.MouseEvent) => {
selectionChanged();
setSearchOpen(false);
if (location.pathname === '/' || location.pathname === homePath) {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
void queryClient.invalidateQueries({ queryKey: ['feed'] });
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
}
}, [location.pathname, homePath, queryClient]);
// Hide the nav when search sheet is open so it doesn't compete for space
const isHidden = hidden || searchOpen;
const displayName = metadata?.name || metadata?.display_name;
const isOnProfile = user && location.pathname === profileUrl;
const isOnFeed = location.pathname === '/' || location.pathname === homePath;
const isOnCommunities = location.pathname === '/communities' || location.pathname.startsWith('/communities/');
const isOnWorld = location.pathname === '/world' || location.pathname.startsWith('/world/');
const isOnNotifications = location.pathname === '/notifications';
return (
<>
@@ -63,91 +106,79 @@ export function MobileBottomNav() {
{/* Arc + items wrapper */}
<div className="relative">
<ArcBackground variant={noArcs ? 'rect' : 'up'} />
<div className="h-11 flex items-center relative">
<div className="h-12 flex items-end pb-0 relative translate-y-2">
{/* Home */}
<Link
to="/"
onClick={() => {
selectionChanged();
setSearchOpen(false);
// When already on the home page, scroll to top and refresh the feed
if (location.pathname === '/' || location.pathname === homePath) {
window.scrollTo({ top: 0, behavior: 'smooth' });
void queryClient.invalidateQueries({ queryKey: ['feed'] });
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
}
}}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
(location.pathname === '/' || location.pathname === homePath) ? 'text-primary' : 'text-muted-foreground',
)}
>
<HomeIcon className="size-5" />
<span className="text-[10px] font-medium">{homeLabel}</span>
</Link>
{/* Search */}
<NavItem
icon={Search}
label="Search"
active={searchOpen}
onClick={handleSearchClick}
size="sm"
/>
{/* Search */}
<button
onClick={handleSearchClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
searchOpen ? 'text-primary' : 'text-muted-foreground',
)}
>
<Search className="size-5" />
<span className="text-[10px] font-medium">Search</span>
</button>
{/* Notifications */}
{user && (
<Link
to="/notifications"
{/* Communities */}
<NavItem
icon={Users}
label="Communities"
active={isOnCommunities}
to="/communities"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
)}
>
<span className="relative">
<Bell className="size-5" />
{hasUnread && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className="text-[10px] font-medium">Notifications</span>
</Link>
)}
/>
{/* Profile */}
{user ? (
<Link
to={profileUrl}
{/* Center spacer — reserved for the apex Feed button */}
<div className="flex-[0.4]" aria-hidden="true" />
{/* Notifications */}
{user ? (
<NavItem
icon={Bell}
label="Notifications"
active={isOnNotifications}
badge={hasUnread}
to="/notifications"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
/>
) : (
<div className="flex-1" aria-hidden="true" />
)}
{/* World */}
<NavItem
icon={Earth}
label="World"
active={isOnWorld}
to="/world"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
isOnProfile ? 'text-primary' : 'text-muted-foreground',
)}
>
<Avatar className="size-5">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName?.[0]?.toUpperCase() || <User className="size-3" />}
</AvatarFallback>
</Avatar>
<span className="text-[10px] font-medium">Profile</span>
</Link>
) : (
<Link
to="/profile"
className="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors text-muted-foreground"
>
<User className="size-5" />
<span className="text-[10px] font-medium">Profile</span>
</Link>
)}
size="sm"
/>
</div>
{/* Apex Feed button — Agora bolt mark cradled in the V notch, with label below. */}
<Link
to={homePath}
onClick={handleFeedClick}
aria-label={homeItem?.label ?? 'Feed'}
className={cn(
'absolute left-1/2 -translate-x-1/2 z-10 -top-6',
'flex flex-col items-center gap-3',
'transition-transform hover:scale-105 active:scale-95',
)}
>
<AgoraBoltIcon
className={cn(
'size-16 drop-shadow-md',
isOnFeed && 'drop-shadow-[0_0_8px_hsl(var(--primary)/0.6)]',
)}
/>
<span className={cn(
'text-[10px] font-semibold leading-none',
isOnFeed ? 'text-primary' : 'text-foreground',
)}>
{homeItem?.label ?? 'Feed'}
</span>
</Link>
</div>
{/* Safe area fill — matches the arc's semi-transparent background */}
<div className="safe-area-bottom bg-background/85" />
+2 -2
View File
@@ -11,7 +11,7 @@ interface MobileTopBarProps {
hasSubHeader?: boolean;
}
export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps) {
export function MobileTopBar({ onAvatarClick, hasSubHeader: _hasSubHeader }: MobileTopBarProps) {
const location = useLocation();
const navHidden = useNavHidden();
@@ -34,7 +34,7 @@ export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps)
/>
{/* Relative wrapper so ArcBackground only covers the content area, not the safe-area padding above it. */}
<div className="relative">
<ArcBackground variant={hasSubHeader ? 'rect' : 'down'} />
<ArcBackground variant="rect" />
<div className="relative flex items-center px-3 h-10">
{/* Left: hamburger menu icon */}
<div className="flex items-center justify-center w-7 shrink-0">
+14 -17
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { ArcBackground } from '@/components/ArcBackground';
import { useNavHidden } from '@/contexts/LayoutContext';
import { SubHeaderBarContext } from '@/components/SubHeaderBarContext';
@@ -30,7 +30,7 @@ interface SubHeaderBarProps {
* Used by all tab bars (Feed, Search, Notifications, etc.) and the MobileTopBar
* fallback arc.
*/
export function SubHeaderBar({ children, className, innerClassName, noArc, pinned }: SubHeaderBarProps) {
export function SubHeaderBar({ children, className, innerClassName, noArc: _noArc, pinned }: SubHeaderBarProps) {
const [hover, setHover] = useState<HoverSlice | null>(null);
const [active, setActive] = useState<HoverSlice | null>(null);
const navHidden = useNavHidden();
@@ -125,38 +125,35 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
/>
)}
{/* Inner wrapper so ArcBackground covers only the tab area, not the safe-area padding above.
sidebar:pt-2 adds desktop top padding inside the arc rather than outside it. */}
<div className="relative sidebar:pt-2">
<ArcBackground variant={noArc ? 'rect' : 'down'} />
{/* Per-tab arc hover highlight: full-width arc, clipped to the hovered tab's x-slice */}
{hover && !noArc && (
{/* Inner wrapper holds the ArcBackground and tab content. */}
<div className="relative">
<ArcBackground variant="rect" />
{/* Per-tab hover highlight: a flat-bottomed slab clipped to the hovered tab's x-slice */}
{hover && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full pointer-events-none"
className="absolute top-0 left-0 w-full h-full pointer-events-none"
style={{
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
clipPath: `inset(0 calc(100% - ${hover.left + hover.width}px) 0 ${hover.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M0,0 L100,0 L100,44 Q50,64 0,44 Z" className="fill-secondary/40" />
<path d="M0,0 L100,0 L100,64 L0,64 Z" className="fill-secondary/40" />
</svg>
)}
{/* Active tab indicator: the arc's bottom edge as a stroke, clipped to the active tab's x-slice */}
{active && !noArc && (
{/* Active tab indicator: a flat underline along the bottom edge, clipped to the active tab's x-slice */}
{active && (
<svg
aria-hidden
className="absolute top-0 left-0 w-full pointer-events-none"
className="absolute top-0 left-0 w-full h-full pointer-events-none"
style={{
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
clipPath: `inset(0 calc(100% - ${active.left + active.width}px) 0 ${active.left}px)`,
}}
viewBox="0 0 100 64"
preserveAspectRatio="none"
>
<path d="M100,44 Q50,64 0,44" fill="none" className="stroke-primary" strokeWidth="3" />
<path d="M0,62 L100,62" fill="none" className="stroke-primary" strokeWidth="3" vectorEffect="non-scaling-stroke" />
</svg>
)}
{/* Tab content sits above the SVG background */}
@@ -174,7 +171,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
)}
<div
ref={scrollRef}
className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}
className={cn('relative flex overflow-x-auto scrollbar-none py-1', innerClassName)}
>
{children}
</div>
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
/**
* Agora lightning-bolt icon — a stylized double-bolt mark in primary brand orange.
* The artwork uses fixed brand colors and gradients (it's a logo mark, not a
* monochrome icon), so it ignores `currentColor`. Pass `className` to size it
* (e.g. `size-6`).
*/
export const AgoraBoltIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 720 880"
fill="none"
className={className}
aria-hidden="true"
{...props}
>
<g filter="url(#agora_bolt_inner_shadow)">
<path
d="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z"
fill="url(#agora_bolt_grad_a)"
fillOpacity="0.9"
/>
<path
d="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z"
fill="#FF6600"
/>
<path
d="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z"
fill="url(#agora_bolt_grad_b)"
fillOpacity="0.9"
/>
<path
d="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z"
fill="#FF6600"
/>
</g>
<defs>
<filter
id="agora_bolt_inner_shadow"
x="0"
y="0"
width="720"
height="914"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="34" />
<feGaussianBlur stdDeviation="27" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"
/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="7" />
<feGaussianBlur stdDeviation="0.5" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"
/>
<feBlend mode="normal" in2="effect1_innerShadow" result="effect2_innerShadow" />
</filter>
<linearGradient
id="agora_bolt_grad_a"
x1="-19.0481"
y1="318.823"
x2="373.469"
y2="591.355"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
<linearGradient
id="agora_bolt_grad_b"
x1="346.531"
y1="288.645"
x2="739.048"
y2="561.177"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
</defs>
</svg>
),
);
AgoraBoltIcon.displayName = 'AgoraBoltIcon';
+1 -1
View File
@@ -37,7 +37,7 @@ export function CountryBrowser({ gridClassName, className }: CountryBrowserProps
return (
<div className={cn('flex flex-col min-h-0', className)}>
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur px-4 py-2 border-b border-border/40">
<div className="sticky top-0 z-10 bg-background px-4 py-2 border-b border-border/40">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
+111 -146
View File
@@ -1,7 +1,13 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Drawer as Vaul } from 'vaul';
import { ChevronUp, Globe2, Flame } from 'lucide-react';
import { Compass, Flame, Globe2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { CommunityStatsPanel } from '@/components/CommunityStatsPanel';
import { COUNTRIES } from '@/lib/countries';
import { CountryBrowser } from './CountryBrowser';
@@ -9,49 +15,32 @@ import { cn } from '@/lib/utils';
const TOTAL_COUNTRIES = Object.keys(COUNTRIES).length;
type SnapPoint = string | number;
// Peek is intentionally generous (~160px) so the strip of trending country
// chips inside it is visible without dragging — that gives the bottom sheet
// real visual weight and an immediate reason to engage.
const SNAP_PEEK: SnapPoint = '168px';
const SNAP_MID: SnapPoint = 0.55;
const SNAP_FULL: SnapPoint = 0.92;
const SNAP_POINTS: SnapPoint[] = [SNAP_PEEK, SNAP_MID, SNAP_FULL];
interface WorldDiscoveryDrawerProps {
/**
* Element the drawer should portal into. The drawer renders as
* `position: absolute` inside this container, so passing the page wrapper
* keeps the drawer scoped to the center column on desktop instead of
* stretching across the full viewport. Set to `null` to defer rendering
* until the container ref is attached.
*/
container: HTMLElement | null;
interface WorldDiscoveryModalProps {
/**
* Per-country activity counts derived from the trusted global stats
* snapshot. Used to surface the top-N hottest countries as instant-tap
* chips in the peek state. Pass `undefined` while loading.
* snapshot. Drives the "trending" chips at the top and the active-country
* badge on the floating button.
*/
activities?: Map<string, number>;
}
/**
* Persistent bottom sheet that surfaces the global stats snapshot, country
* search, and the AZ grid as a single discovery surface alongside the
* full-bleed world map. The peek snap keeps the entry point visible at all
* times so neither the stats nor the country list ever feels hidden.
* Mobile / tablet (sub-sidebar breakpoint) discovery surface for the
* `/world` map. Replaces the previous persistent bottom drawer with a
* compact floating button that opens a centered information-style modal
* on demand — the map stays fully visible until the user explicitly opts
* into discovery, instead of having half the screen permanently occupied
* by a sheet.
*
* Built on vaul primitives (rather than the shared shadcn `Drawer` wrapper)
* because we need a non-modal, non-dismissible, snap-point sheet — the
* shared wrapper is hardcoded for modal dialogs with an overlay.
* Above the `sidebar` breakpoint (900px) the docked `WorldDiscoveryPanel`
* takes over and this component is unmounted by `WorldPage`.
*/
export function WorldDiscoveryDrawer({ container, activities }: WorldDiscoveryDrawerProps) {
const [snap, setSnap] = useState<SnapPoint | null>(SNAP_PEEK);
export function WorldDiscoveryDrawer({ activities }: WorldDiscoveryModalProps) {
const [open, setOpen] = useState(false);
// Top-N hottest countries for the peek strip. Subdivision codes (e.g.
// `US-TX`) are folded into their parent country so the strip mirrors the
// user's mental model of the world map. Sorted by activity count desc.
// Top-N hottest countries for the modal's trending strip. Subdivision
// codes (e.g. `US-TX`) are folded into their parent country so the strip
// mirrors the world map's mental model. Sorted by activity count desc.
const topCountries = useMemo(() => {
if (!activities || activities.size === 0) return [];
const byCountry = new Map<string, number>();
@@ -79,128 +68,104 @@ export function WorldDiscoveryDrawer({ container, activities }: WorldDiscoveryDr
return set;
}, new Set<string>()).size;
const isPeeking = snap === SNAP_PEEK;
// Defer rendering until the container ref is attached so vaul can portal
// into a known DOM node from the first render.
if (!container) return null;
return (
<Vaul.Root
open
modal={false}
dismissible={false}
snapPoints={SNAP_POINTS}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
container={container}
shouldScaleBackground={false}
>
<Vaul.Portal>
<Vaul.Content
aria-describedby={undefined}
className={cn(
// Anchor inside the page wrapper. `world-drawer-anchor` handles
// the bottom offset (clears the mobile bottom nav). The drawer
// is `absolute` (not `fixed`) so it stays inside the column on
// desktop instead of spanning the full viewport.
'world-drawer-anchor absolute inset-x-0 z-30 flex flex-col rounded-t-2xl border border-border/60 bg-background/95 backdrop-blur shadow-[0_-8px_32px_rgba(0,0,0,0.12)]',
// Snap-point sized: vaul translates this element via transform.
'h-full max-h-[92dvh]',
)}
>
{/* Drag handle — vaul ships its own [data-vaul-handle] CSS so this
element is fully draggable to change snap points. */}
<Vaul.Handle className="!mx-auto !mt-2.5 !h-1.5 !w-12 !rounded-full !bg-muted-foreground/40" />
<>
{/* Floating discovery launcher. Anchored to the top-right of the
page wrapper at the same vertical offset as Leaflet's zoom
controls (which dock at `top: 10px; left: 10px` inside the map
container), so the two sit on the same horizontal line. The
mobile top bar overlays the map column but is semi-transparent
on the world page (`fullBleed`), so a 10px offset still keeps
the button visually clear of the bar. */}
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open world discovery"
className={cn(
'absolute right-2.5 top-2.5 z-30 flex items-center gap-2 rounded-full',
'border border-border/60 bg-background/95 backdrop-blur',
'px-4 py-2.5 shadow-lg hover:bg-background',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
{topCountries.length > 0 ? (
<Flame className="size-4 text-primary shrink-0" />
) : (
<Compass className="size-4 text-muted-foreground shrink-0" />
)}
<span className="text-sm font-semibold">
{topCountries.length > 0
? `${totalActiveCountries.toLocaleString()} active`
: 'Discover'}
</span>
</button>
{/* Peek header — always visible. Tapping anywhere here toggles the
drawer between peek and mid snap so users discover the expanded
content without needing to drag. */}
<button
type="button"
onClick={() => setSnap(isPeeking ? SNAP_MID : SNAP_PEEK)}
className="flex items-center justify-between gap-3 px-4 pt-2 pb-2 text-left transition-colors hover:bg-secondary/30"
>
<div className="flex items-center gap-2 min-w-0">
<Dialog open={open} onOpenChange={setOpen}>
{/* Tall, scrollable modal — flex column so the embedded
`CountryBrowser` can manage its own internal scroll while the
sticky search header stays reachable. Responsive width: hugs
the viewport on phones, expands to a comfortable reading
width on tablets and small laptops (where the docked
discovery panel hasn't kicked in yet at `xl`/1280px). */}
<DialogContent className="flex max-h-[85dvh] w-[calc(100%-1.5rem)] max-w-lg sm:max-w-xl md:max-w-2xl lg:max-w-3xl flex-col gap-0 p-0 overflow-hidden">
<DialogHeader className="px-4 pt-5 pb-3 border-b border-border/40">
<DialogTitle className="flex items-center gap-2 text-base">
{topCountries.length > 0 ? (
<Flame className="size-4 text-primary shrink-0" />
) : (
<Globe2 className="size-4 text-muted-foreground shrink-0" />
)}
<span className="text-sm font-semibold truncate">
{topCountries.length > 0
? `${totalActiveCountries.toLocaleString()} countries active right now`
: `Explore ${TOTAL_COUNTRIES} countries`}
</span>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
{isPeeking ? 'More' : 'Hide'}
<ChevronUp
className={cn(
'size-4 transition-transform',
!isPeeking && 'rotate-180',
)}
/>
</span>
</button>
{topCountries.length > 0
? `${totalActiveCountries.toLocaleString()} countries active`
: `Explore ${TOTAL_COUNTRIES} countries`}
</DialogTitle>
<DialogDescription className="sr-only">
Trending countries, community stats, and the full AZ country browser.
</DialogDescription>
{/* Vaul drawer titles are required for accessibility; visually
hidden because the peek header above already labels it. */}
<Vaul.Title className="sr-only">Discover countries and community stats</Vaul.Title>
{/* Trending strip — horizontally scrollable chips of the hottest
countries. Visible in the peek state so the bottom sheet
immediately shows real, tappable value rather than just a
label. Each chip routes straight into the country feed. When
there's no trusted snapshot yet, falls back to a search hint. */}
<div className="px-4 pb-2 pt-0.5">
{topCountries.length > 0 ? (
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scroll-smooth">
{topCountries.map((c) => (
<Link
key={c.code}
to={`/i/iso3166:${c.code}`}
className="group flex items-center gap-2 shrink-0 rounded-full border border-border/60 bg-card hover:border-primary/40 hover:bg-primary/5 px-3 py-1.5 transition-colors"
>
<span className="text-base leading-none" role="img" aria-label={`Flag of ${c.name}`}>
{c.flag}
</span>
<span className="text-xs font-medium truncate max-w-[10ch] group-hover:text-foreground">
{c.name}
</span>
<span className="text-[10px] tabular-nums font-semibold text-primary">
{c.count.toLocaleString()}
</span>
</Link>
))}
{/* Trending strip — horizontally scrollable chips of the
hottest countries. Each chip routes straight into the
country feed and closes the modal so the user lands on the
destination immediately. */}
{topCountries.length > 0 && (
<div className="-mx-4 mt-2 overflow-x-auto px-4 pb-1">
<div className="flex gap-2">
{topCountries.map((c) => (
<Link
key={c.code}
to={`/i/iso3166:${c.code}`}
onClick={() => setOpen(false)}
className="group flex shrink-0 items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1.5 transition-colors hover:border-primary/40 hover:bg-primary/5"
>
<span
className="text-base leading-none"
role="img"
aria-label={`Flag of ${c.name}`}
>
{c.flag}
</span>
<span className="max-w-[10ch] truncate text-xs font-medium group-hover:text-foreground">
{c.name}
</span>
<span className="text-[10px] font-semibold tabular-nums text-primary">
{c.count.toLocaleString()}
</span>
</Link>
))}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground py-2">
Tap to search and browse all {TOTAL_COUNTRIES} countries.
</p>
)}
</div>
</DialogHeader>
{/* Expanded body — only mounted when the drawer is above the peek
snap point so the peek state stays light and the heavy
CommunityStatsPanel doesn't run its queries when the user
might never expand the sheet. */}
{!isPeeking && (
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
{/* Stats snapshot first — putting it on top means the hashtag
chips are immediately visible at the mid snap point.
Renders nothing when no trusted snapshot exists. */}
<div className="px-4 pt-2 pb-4">
<CommunityStatsPanel compact />
</div>
{/* Search input + AZ grid (shared with the desktop right
column). The browser owns its own sticky search header so
it stays reachable while the grid scrolls. */}
<CountryBrowser gridClassName="grid-cols-3 sm:grid-cols-4 md:grid-cols-5" />
{/* Scrollable body: stats snapshot + AZ browser. */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="px-4 pt-3 pb-2">
<CommunityStatsPanel compact />
</div>
)}
</Vaul.Content>
</Vaul.Portal>
</Vaul.Root>
<CountryBrowser gridClassName="grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6" />
</div>
</DialogContent>
</Dialog>
</>
);
}
+12 -9
View File
@@ -18,13 +18,15 @@ interface WorldDiscoveryPanelProps {
/**
* Desktop right-column variant of the world discovery surface. Always
* visible alongside the full-bleed map at the `sidebar` breakpoint and up.
* Mirrors the content of `WorldDiscoveryDrawer` (mobile) so users get the
* same affordances regardless of device — trending countries, community
* stats snapshot, and the full AZ country browser.
* visible alongside the full-bleed map at the `xl` breakpoint (1280px)
* and up. Mirrors the content of `WorldDiscoveryDrawer` (mobile / tablet)
* so users get the same affordances regardless of device — trending
* countries, community stats snapshot, and the full AZ country browser.
*
* Hidden below the sidebar breakpoint via `hidden sidebar:flex`; the
* mobile bottom drawer takes over there.
* Hidden below `xl` via `hidden xl:flex`; the floating discovery launcher
* + modal takes over there. The cutoff sits at `xl` (not the lower
* `sidebar` breakpoint) because the map needs at least ~700-800px of
* horizontal room to be readable next to the 360px panel.
*/
export function WorldDiscoveryPanel({ activities, className }: WorldDiscoveryPanelProps) {
// Same trending derivation as the drawer's peek strip — fold subdivision
@@ -54,11 +56,12 @@ export function WorldDiscoveryPanel({ activities, className }: WorldDiscoveryPan
return (
<aside
className={cn(
// `hidden sidebar:flex` keeps the panel out of the layout flow on
// mobile so the bottom drawer is the only discovery surface there.
// `hidden xl:flex` keeps the panel out of the layout flow below
// 1280px so the map gets the full column width and the floating
// discovery launcher is the only discovery surface there.
// `sticky top-0 h-screen` pins it next to the map column without
// affecting page scroll behavior.
'hidden sidebar:flex flex-col w-[360px] shrink-0 h-screen sticky top-0 border-l border-border bg-background overflow-hidden',
'hidden xl:flex flex-col w-[360px] shrink-0 h-screen sticky top-0 border-l border-border bg-background overflow-hidden',
className,
)}
aria-label="World discovery panel"
+18 -2
View File
@@ -25,6 +25,22 @@ const POSITRON_DARK_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y
const ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>';
/**
* Pick a sensible initial zoom so the world fills the viewport vertically
* — at zoom 2 the world tile is 1024px tall, which leaves visible ocean
* bands above and below the map on phones, tablets, and smaller laptops.
* Bumping to zoom 3 (2048px tall) on sub-`xl` viewports eliminates those
* gaps while still showing most of the globe. Wider viewports keep zoom
* 2 because the docked discovery panel appears there and we want as
* much of the world visible next to it.
*
* Matches the 1280px breakpoint used by `WorldPage` for the panel cutoff.
*/
function getInitialZoom(): number {
if (typeof window === 'undefined') return 2;
return window.innerWidth < 1280 ? 3 : 2;
}
interface ActivityMarker {
/** ISO 3166-1 / -2 code as published in the stats snapshot. */
countryCode: string;
@@ -535,8 +551,8 @@ export function WorldMap({
return (
<div ref={containerRef} className="w-full h-full relative isolate">
<MapContainer
center={[20, 0]}
zoom={2}
center={[8, -66]}
zoom={getInitialZoom()}
minZoom={2}
maxZoom={10}
style={{ height: '100%', width: '100%', background: 'transparent' }}
+20 -1
View File
@@ -1,5 +1,17 @@
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
/** A single entry inside the FAB action menu. */
export interface FabMenuItem {
/** Stable identifier, used as React key. */
id: string;
/** Visible label shown next to the icon. */
label: string;
/** Optional leading icon. */
icon?: React.ReactNode;
/** Invoked when the item is selected; the menu closes automatically. */
onSelect: () => void;
}
/** Options that pages can set to configure the persistent MainLayout. */
export interface LayoutOptions {
/** Optional custom right sidebar to replace the default one */
@@ -14,6 +26,13 @@ export interface LayoutOptions {
onFabClick?: () => void;
/** If set, overrides the default FAB icon (Plus). */
fabIcon?: React.ReactNode;
/**
* If set, the FAB renders as a Popover trigger; tapping it reveals this
* stack of menu items anchored to the FAB. Selecting an item closes the
* menu and fires its `onSelect`. Mutually exclusive with `onFabClick` —
* if both are set, the menu wins.
*/
fabMenu?: FabMenuItem[];
/** Additional classes for the wrapper div */
wrapperClassName?: string;
/**
@@ -80,7 +99,7 @@ export interface LayoutOptions {
/** All own-property keys of LayoutOptions used for shallow comparison. */
const LAYOUT_KEYS: (keyof LayoutOptions)[] = [
'showFAB', 'fabKind', 'fabHref', 'onFabClick', 'fabIcon',
'showFAB', 'fabKind', 'fabHref', 'onFabClick', 'fabIcon', 'fabMenu',
'wrapperClassName', 'rightSidebar', 'scrollContainer',
'noOverscroll', 'noMaxWidth', 'hasSubHeader', 'noArcs',
'hideTopBar', 'hideBottomNav',
+55 -5
View File
@@ -149,11 +149,10 @@
}
}
/* Anchor the world discovery drawer above the mobile bottom nav so the
bottom-nav stays usable while the drawer sits in its peek state. On
desktop there's no bottom nav, so the drawer anchors to the viewport
bottom. Vaul drives translation via [data-vaul-drawer] CSS, so we only
adjust the anchor — not the transform. */
/* Anchor world-page floating overlays (the discovery launcher button and
the docked desktop drawer slot) above the mobile bottom nav so the
bottom-nav stays usable. On desktop there's no bottom nav, so they
anchor to the viewport bottom. */
.world-drawer-anchor {
bottom: 0;
}
@@ -164,7 +163,58 @@
}
}
/* Leaflet control theming — override the default white/gray controls to
match the app's themed palette. The default `leaflet/dist/leaflet.css`
ships with hardcoded `background: white` / dark border colors that
clash with our themed surfaces (especially in dark mode and on the
`/world` map where controls sit on top of the dark Positron tiles).
We re-style only the controls; tile rendering and marker popovers are
untouched. Hover state uses the primary color so the controls match
the same orange accent as buttons elsewhere. */
.leaflet-bar {
border: 1px solid hsl(var(--border)) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18) !important;
border-radius: calc(var(--radius) - 0.25rem) !important;
overflow: hidden;
}
.leaflet-bar a,
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: hsl(var(--background) / 0.95) !important;
color: hsl(var(--foreground)) !important;
border-bottom: 1px solid hsl(var(--border)) !important;
backdrop-filter: blur(4px);
transition: background-color 120ms, color 120ms;
}
.leaflet-bar a:last-child {
border-bottom: none !important;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: hsl(var(--primary)) !important;
color: hsl(var(--primary-foreground)) !important;
}
.leaflet-bar a.leaflet-disabled {
background-color: hsl(var(--muted)) !important;
color: hsl(var(--muted-foreground) / 0.5) !important;
cursor: not-allowed;
}
/* Attribution control (hidden on /world via `attributionControl={false}`,
but kept consistent for any other map usage). */
.leaflet-control-attribution {
background-color: hsl(var(--background) / 0.85) !important;
color: hsl(var(--muted-foreground)) !important;
backdrop-filter: blur(4px);
}
.leaflet-control-attribution a {
color: hsl(var(--primary)) !important;
}
@keyframes spin {
from { transform: rotate(0deg); }
+1 -9
View File
@@ -1,7 +1,5 @@
import { useSeoMeta } from '@unhead/react';
import { Megaphone } from 'lucide-react';
import { Feed } from '@/components/Feed';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLayoutOptions } from '@/contexts/LayoutContext';
@@ -17,13 +15,7 @@ const Index = () => {
useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: !!user });
return (
<Feed
header={(
<PageHeader title="Feed" icon={<Megaphone className="size-5 text-primary" />} />
)}
/>
);
return <Feed />;
};
export default Index;
+19 -17
View File
@@ -14,14 +14,17 @@ import { ChatDialog } from '@/components/chat/ChatDialog';
const WorldMap = lazy(() => import('@/components/world/WorldMap'));
/**
* Match the `sidebar` Tailwind breakpoint (900px). At this width and above
* the layout has room for a docked right column (`WorldDiscoveryPanel`);
* below it we fall back to the bottom drawer (`WorldDiscoveryDrawer`).
* Breakpoint at which the world page has room for a docked right column
* (`WorldDiscoveryPanel`) alongside the left sidebar and a usable map.
* Below this width we fall back to the floating discovery launcher +
* modal so the map isn't crushed.
*
* Hardcoded to avoid pulling Tailwind config into the client bundle, same
* approach as `useIsMobile`.
* Matches the `xl` Tailwind breakpoint (1280px) — the same threshold the
* default `WidgetSidebar` uses. The earlier `sidebar` breakpoint (900px)
* left only ~540px of map between the 300px left rail and the 360px
* discovery panel, which was too cramped to be useful.
*/
const SIDEBAR_MEDIA_QUERY = '(min-width: 900px)';
const SIDEBAR_MEDIA_QUERY = '(min-width: 1280px)';
function useHasSidebar(): boolean {
const [hasSidebar, setHasSidebar] = useState(() =>
@@ -39,7 +42,6 @@ function useHasSidebar(): boolean {
export function WorldPage() {
const { config } = useAppContext();
const [pageEl, setPageEl] = useState<HTMLDivElement | null>(null);
const hasSidebar = useHasSidebar();
useSeoMeta({
@@ -84,10 +86,11 @@ export function WorldPage() {
return (
// h-dvh inside the column fills the full viewport on both mobile (where
// the column's negative margin pulls content under the translucent top
// bar) and desktop (where there's no top/bottom chrome). The drawer is
// portaled inside this wrapper so it inherits the column's horizontal
// bounds — no overlap with the docked desktop discovery panel.
<div ref={setPageEl} className="relative w-full h-dvh overflow-hidden bg-muted/20">
// bar) and desktop (where there's no top/bottom chrome). The floating
// discovery button is absolutely positioned inside this wrapper so it
// stays scoped to the column and doesn't overlap the docked desktop
// discovery panel.
<div className="relative w-full h-dvh overflow-hidden bg-muted/20">
<Suspense
fallback={
<div className="absolute inset-0">
@@ -105,12 +108,11 @@ export function WorldPage() {
</div>
</Suspense>
{/* Bottom discovery drawer — only mounted below the sidebar breakpoint.
Above it, the docked `WorldDiscoveryPanel` (rendered as the layout's
right sidebar) takes over and the drawer is unnecessary. We mount
conditionally rather than CSS-hiding so vaul's drag handlers and
listeners aren't running on desktop where the drawer is invisible. */}
{!hasSidebar && <WorldDiscoveryDrawer container={pageEl} activities={activities} />}
{/* Below the sidebar breakpoint, surface the discovery experience as
a floating button + modal. Above it, the docked
`WorldDiscoveryPanel` (rendered as the layout's right sidebar)
takes over and this component is unmounted. */}
{!hasSidebar && <WorldDiscoveryDrawer activities={activities} />}
{/* Per-geohash realtime chat. Only mounted while open so the relay
subscription tears down cleanly when the dialog closes. */}