Compare commits

...

16 Commits

Author SHA1 Message Date
lemon a159f97a43 Add calendar event editing 2026-05-06 19:50:57 -07:00
lemon eb03f3fcc0 Share image upload field across dialogs 2026-05-06 19:40:46 -07:00
lemon 003e7d3624 Add image uploads to event creation 2026-05-06 19:27:34 -07:00
lemon 28043378c3 Add engagement actions to calendar events 2026-05-06 13:13:51 -07:00
lemon 432eae4f79 Add RSVP controls to calendar event details
- Rename tentative label to 'Interested' (Facebook-style, Star icon)
- Auto-enroll event authors as 'accepted' when publishing
- Let authors change their own RSVP from the detail page
- Restyle RSVP section to match About/Attendees headers
- Remove optional note field; click a button to submit immediately
- Move Attendees above RSVP
2026-05-06 12:55:57 -07:00
lemon e0a52a5c32 Use event dialog on events page 2026-05-04 23:29:01 -07:00
lemon 725d6970c5 Add community event creation dialog 2026-05-04 23:23:55 -07:00
lemon 558b666220 Add community events tab 2026-05-04 23:02:43 -07:00
lemon 72d7962632 Improve community bookmark reliability 2026-05-04 21:44:34 -07:00
lemon 5e91f1d328 Add bookmark toggle to community detail page top bar
Places a NIP-51 kind 10004 bookmark button between the edit and share
buttons so users can save a community while viewing it, not just from
the feed card's more-menu.
2026-05-03 22:10:34 -07:00
lemon efe5d3db1c Show bookmarked communities in My Communities via NIP-51 kind 10004
Bookmarking a kind 34550 community now writes to the NIP-51 Communities
list (kind 10004) keyed by the addressable coordinate, so the reference
stays valid across community updates. My Communities merges bookmarked
communities as a third discovery source alongside founded and member-of,
with Founder/Member/Bookmarked badges on each card.

Bookmark toasts live on the mutation itself so they survive the more-menu
dialog unmounting between .mutate() and publish resolution.
2026-05-03 21:59:36 -07:00
lemon 9ac379b259 fix: remove duplicate community share action 2026-05-03 21:28:00 -07:00
lemon c8b3961da6 feat: add community editing 2026-05-03 21:28:00 -07:00
lemon 259c657c33 fix: improve community member management 2026-05-03 21:28:00 -07:00
lemon 2f6aeb05e4 refactor: split community creation into two steps
- CreateCommunityDialog now only publishes kind 34550 (name, image, description)
- New AddMemberDialog on the community detail page handles membership:
  - Founder can add moderators and members
  - Moderators can add members only
  - Badge definition (kind 30009) created lazily on first member add
  - Community definition republished once with all changes batched
  - Kind 8 badge awards published for each member
- Add Members button on Members tab, visible to rank 0 users
- Search dropdown moved outside ScrollArea to prevent clipping
2026-05-03 21:27:41 -07:00
lemon 5cea93de34 feat: add community creation flow and improve discovery UX
- Add CreateCommunityDialog with name, image upload, description, and moderator type-ahead search
- Publish kind 30009 badge definition + kind 34550 community definition with d-tag collision check
- Context-aware FAB on My Communities tab opens the create dialog
- Default Search page tab to Communities instead of Posts
- Add Search to default sidebar order for new accounts
- Improve empty states on both Activities and My Communities tabs to guide users toward discovery
2026-05-03 21:26:48 -07:00
19 changed files with 2570 additions and 432 deletions
+17
View File
@@ -791,6 +791,22 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank
2. Extract badge `a` tags from results.
3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }`
**Communities a user has bookmarked:**
Agora uses [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) kind `10004` ("Communities") to let users save communities they want quick access to without requiring membership. Bookmarked communities are surfaced in the "My Communities" view alongside founded and member-of communities.
1. `{ "kinds": [10004], "authors": ["<user-pubkey>"], "limit": 1 }`
2. Extract `a` tags whose value begins with `34550:` from the result.
3. For each coordinate `34550:<author-pubkey>:<d-tag>`, query the community definition with both `authors` and `#d` filters to prevent spoofing:
```jsonc
{ "kinds": [34550], "authors": ["<author-pubkey>"], "#d": ["<d-tag>"], "limit": 1 }
```
Clients toggling a bookmark MUST perform a read-modify-write cycle on the replaceable kind `10004` event: fetch the freshest version from relays, add or remove the matching `["a", "34550:<pubkey>:<d-tag>"]` tag, and republish the full tag list. Appending new entries to the end preserves chronological bookmark order per NIP-51.
When the same community appears in multiple discovery sources, clients SHOULD display a single card but MAY indicate all applicable relationships (e.g. a member who has also bookmarked a community).
### Security Considerations
- **Author filtering**: Clients MUST filter community definitions by `authors` to prevent impersonation.
@@ -804,6 +820,7 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank
- [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md) -- Comment
- [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) -- Unknown Event Kinds (`alt` tag)
- [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) -- Labeling (moderation `ban` label)
- [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) -- Lists (kind `10004` Communities list for bookmarks)
- [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) -- Reporting
- [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) -- Badges
- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities
+575
View File
@@ -0,0 +1,575 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { UserPlus, Loader2, X, Search, Crown, Users } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ImageUploadField } from '@/components/ImageUploadField';
import { getAvatarShape } from '@/lib/avatarShape';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { genUserName } from '@/lib/genUserName';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_DEFINITION_KIND,
BADGE_AWARD_KIND,
EMPTY_MODERATION,
type CommunityMember,
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
} from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
// ── Types ─────────────────────────────────────────────────────────────────────
type MemberRole = 'moderator' | 'member';
interface PendingMember {
profile: SearchProfile;
role: MemberRole;
}
interface CommunityMembersCacheValue {
membership: CommunityMembership;
moderation: CommunityModeration;
rankMap: Map<string, CommunityMember>;
}
interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function AddMemberDialog({
open,
onOpenChange,
communityEvent,
community,
isFounder,
}: AddMemberDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
// Form state
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
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();
// Does this community already have a badge definition?
const existingBadgeATag = community.ranks.find((r) => r.rank === 1)?.badgeATag;
const hasBadge = !!existingBadgeATag;
// Are there any pending members with the "member" role?
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
// Will we need to create a badge? (members added + no badge exists yet)
const needsBadgeCreation = hasPendingMembers && !hasBadge;
const resetForm = useCallback(() => {
setPendingMembers([]);
setBadgeImageUrl('');
setIsBadgeImageUploading(false);
setIsPublishing(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// ── People management ─────────────────────────────────────────────────────
const addPerson = useCallback((profile: SearchProfile) => {
if (!user) return;
if (profile.pubkey === community.founderPubkey) {
toast({ title: 'Already the founder' });
return;
}
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
toast({ title: 'Already added' });
return;
}
// Default role: member if they're not already a moderator, moderator if founder is adding
const defaultRole: MemberRole = isFounder ? 'moderator' : 'member';
setPendingMembers((prev) => [...prev, { profile, role: defaultRole }]);
}, [user, community.founderPubkey, pendingMembers, isFounder, toast]);
const removePerson = useCallback((pubkey: string) => {
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
}, []);
const toggleRole = useCallback((pubkey: string) => {
if (!isFounder) return; // Only founder can toggle to moderator
setPendingMembers((prev) => prev.map((m) =>
m.profile.pubkey === pubkey
? { ...m, role: m.role === 'moderator' ? 'member' : 'moderator' }
: m,
));
}, [isFounder]);
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
const moderation = prev?.moderation ?? EMPTY_MODERATION;
const rankMap = new Map(prev?.rankMap ?? []);
const membershipByPubkey = new Map(
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
);
const seedRankZero = (pubkey: string) => {
if (moderation.bannedPubkeys.has(pubkey)) return;
const member: CommunityMember = { pubkey, rank: 0 };
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
};
seedRankZero(community.founderPubkey);
community.moderatorPubkeys.forEach(seedRankZero);
for (const pending of members) {
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
const nextMember: CommunityMember = pending.role === 'moderator'
? { pubkey: pending.profile.pubkey, rank: 0 }
: {
pubkey: pending.profile.pubkey,
rank: 1,
awardEvent: awardEvents.get(pending.profile.pubkey),
awardedBy: user?.pubkey,
};
const current = membershipByPubkey.get(nextMember.pubkey);
if (!current || nextMember.rank < current.rank) {
membershipByPubkey.set(nextMember.pubkey, nextMember);
}
const currentRank = rankMap.get(nextMember.pubkey);
if (!currentRank || nextMember.rank < currentRank.rank) {
rankMap.set(nextMember.pubkey, nextMember);
}
}
const membership: CommunityMembership = {
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
};
return { membership, moderation, rankMap };
});
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleSubmit = useCallback(async () => {
if (!user || pendingMembers.length === 0) return;
if (isBadgeImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
const newMembers = pendingMembers.filter((m) => m.role === 'member');
let badgeATag = existingBadgeATag;
// Step 1: Create badge definition if needed
if (newMembers.length > 0 && !hasBadge) {
const badgeDTag = `${community.dTag}-member`;
const badgeTags: string[][] = [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${community.name}`],
];
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
if (sanitizedBadgeImage) {
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
}
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: badgeTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
}
// Step 2: Republish community definition if needed
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
if (needsCommunityUpdate) {
// Fetch fresh community event to avoid stale overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseTags = prev?.tags ?? communityEvent.tags;
const updatedTags = [...baseTags];
// Add new moderator p tags
for (const mod of newModerators) {
// Don't add if already exists
const exists = updatedTags.some(
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
);
if (!exists) {
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
}
}
// Add badge a tag if badge was just created
if (badgeATag && !hasBadge) {
updatedTags.push(['a', badgeATag, '', '1']);
}
await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? '',
tags: updatedTags,
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
}
// Step 3: Publish badge awards for each member
const memberAwardEvents = new Map<string, NostrEvent>();
if (newMembers.length > 0 && badgeATag) {
for (const member of newMembers) {
const awardEvent = await publishEvent({
kind: BADGE_AWARD_KIND,
content: '',
tags: [
['a', badgeATag],
['p', member.profile.pubkey],
['alt', `Badge award: Member in ${community.name}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
memberAwardEvents.set(member.profile.pubkey, awardEvent);
}
}
applyOptimisticMembership(pendingMembers, memberAwardEvents);
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
if (!hasBadge && newMembers.length > 0) {
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
}
const addedCount = pendingMembers.length;
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
handleOpenChange(false);
} catch (err) {
toast({
title: 'Failed to add members',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, pendingMembers, existingBadgeATag, hasBadge, community, communityEvent,
badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, 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>
{isFounder
? 'Add moderators or members to your community.'
: 'Invite members to the community.'}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5 space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search people</Label>
<PersonSearch
onAdd={addPerson}
excludePubkeys={[
community.founderPubkey,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</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}
onToggleRole={isFounder ? toggleRole : undefined}
/>
))}
</div>
</div>
)}
{/* Badge image — only shown when badge needs to be created */}
{needsBadgeCreation && (
<ImageUploadField
id="member-badge-image"
label={<>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"
/>
)}
{/* 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>
);
}
// ── Sub-Components ────────────────────────────────────────────────────────────
/** Inline type-ahead person search. */
function PersonSearch({
onAdd,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
excludePubkeys: string[];
}) {
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
useEffect(() => {
if (query.trim().length > 0 && filteredProfiles.length > 0) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [filteredProfiles, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isFetching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && filteredProfiles.length > 0) {
setDropdownOpen(true);
}
}}
placeholder="Search people to add..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
autoComplete="off"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{filteredProfiles.length > 0 ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
</div>
) : query.trim().length >= 2 && !isFetching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** A pending member chip with role toggle and remove button. */
function PendingMemberChip({
pending,
onRemove,
onToggleRole,
}: {
pending: PendingMember;
onRemove: (pubkey: string) => void;
onToggleRole?: (pubkey: string) => void;
}) {
const { profile, role } = pending;
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
<Avatar shape={getAvatarShape(metadata)} className="size-7 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{/* Role badge — clickable if founder can toggle */}
<button
type="button"
onClick={onToggleRole ? () => onToggleRole(pubkey) : undefined}
disabled={!onToggleRole}
className={cn(
'flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full shrink-0 transition-colors',
role === 'moderator'
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'bg-primary/10 text-primary',
onToggleRole && 'cursor-pointer hover:opacity-80',
)}
title={onToggleRole ? 'Click to toggle role' : undefined}
>
{role === 'moderator' ? <Crown className="size-3" /> : <Users className="size-3" />}
{role === 'moderator' ? 'Moderator' : 'Member'}
</button>
<button
type="button"
onClick={() => onRemove(pubkey)}
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
title="Remove"
>
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
</button>
</div>
);
}
/** A profile search result row. */
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar shape={getAvatarShape(metadata)} className="size-8 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}
+151 -191
View File
@@ -1,34 +1,28 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { CalendarDays, MapPin, Clock, Users } from 'lucide-react';
import { CalendarDays, Clock, MapPin, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { NoteContent } from '@/components/NoteContent';
import { Badge } from '@/components/ui/badge';
import { RSVPAvatars } from '@/components/RSVPAvatars';
import { Badge } from '@/components/ui/badge';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface CalendarEventContentProps {
event: NostrEvent;
/** When true, limits the description to 2 lines for compact feed display. */
/** When true, renders a compact feed card. */
compact?: boolean;
className?: string;
}
/** Extract the first value for a given tag name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Collect all values for a repeated tag name. */
function getAllTags(tags: string[][], name: string): string[][] {
return tags.filter(([n]) => n === name);
}
/**
* Parse a location tag value. Some clients encode location as JSON
* (e.g. `{"description":"Riga, Latvia","coordinates":{"lat":56.9,"lon":24.1}}`).
* Extract a human-readable string when possible, otherwise return the raw value.
*/
function parseLocation(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith('{')) return raw;
@@ -43,14 +37,12 @@ function parseLocation(raw: string): string {
return raw;
}
/** Date-only formatter: "Jan 15, 2026" */
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
/** Date+time formatter: "Jan 15, 2026 at 3:00 PM" */
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
@@ -59,52 +51,35 @@ const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
minute: '2-digit',
});
/** Time-only formatter: "3:00 PM" */
const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
});
/** Check if two dates fall on the same calendar day. */
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
);
}
/**
* Format the date/time display for a NIP-52 calendar event.
*
* Kind 31922 (date-based): "Jan 15, 2026" or "Jan 15 - Jan 17, 2026"
* Kind 31923 (time-based): "Jan 15, 2026 at 3:00 PM" or time ranges
*/
function formatEventDate(event: NostrEvent): string {
const start = getTag(event.tags, 'start');
if (!start) return '';
if (event.kind === 31922) {
// Date-based: start/end are YYYY-MM-DD strings
// Parse as UTC to avoid timezone shifting the date
const startDate = new Date(start + 'T00:00:00Z');
const startDate = new Date(`${start}T00:00:00Z`);
if (isNaN(startDate.getTime())) return start;
const end = getTag(event.tags, 'end');
if (end) {
const endDate = new Date(end + 'T00:00:00Z');
const endDate = new Date(`${end}T00:00:00Z`);
if (!isNaN(endDate.getTime()) && endDate > startDate) {
// Multi-day range: "Jan 15 - Jan 17, 2026"
// NIP-52: end date is exclusive, so display the last inclusive day
const lastDay = new Date(endDate.getTime() - 86400000);
if (lastDay > startDate) {
const startParts = dateFormatter.formatToParts(startDate);
const startStr = startParts
.filter((p) => p.type !== 'year' && p.type !== 'literal' || p.value === ' ')
.map((p) => (p.type === 'literal' && p.value.includes(',') ? '' : p.value))
.join('')
.trim();
return `${startStr} ${dateFormatter.format(lastDay)}`;
const startStr = dateFormatter.format(startDate).replace(/, \d{4}$/, '');
return `${startStr} - ${dateFormatter.format(lastDay)}`;
}
}
}
@@ -113,7 +88,6 @@ function formatEventDate(event: NostrEvent): string {
}
if (event.kind === 31923) {
// Time-based: start/end are Unix timestamps
const startTs = parseInt(start, 10);
if (isNaN(startTs)) return start;
const startDate = new Date(startTs * 1000);
@@ -123,13 +97,10 @@ function formatEventDate(event: NostrEvent): string {
const endTs = parseInt(end, 10);
if (!isNaN(endTs) && endTs > startTs) {
const endDate = new Date(endTs * 1000);
if (isSameDay(startDate, endDate)) {
// Same day: "Jan 15, 2026 at 3:00 PM 5:00 PM"
return `${dateTimeFormatter.format(startDate)} ${timeFormatter.format(endDate)}`;
return `${dateTimeFormatter.format(startDate)} - ${timeFormatter.format(endDate)}`;
}
// Different days: "Jan 15, 2026 at 3:00 PM Jan 16, 2026 at 5:00 PM"
return `${dateTimeFormatter.format(startDate)} ${dateTimeFormatter.format(endDate)}`;
return `${dateTimeFormatter.format(startDate)} - ${dateTimeFormatter.format(endDate)}`;
}
}
@@ -139,173 +110,162 @@ function formatEventDate(event: NostrEvent): string {
return start;
}
function getEventEndTimestamp(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const end = getTag(event.tags, 'end');
const endDate = new Date(`${end || start}T00:00:00Z`);
if (isNaN(endDate.getTime())) return 0;
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
}
const end = getTag(event.tags, 'end') || start;
const endTs = parseInt(end, 10);
return isNaN(endTs) ? 0 : endTs;
}
/** Renders NIP-52 calendar event content (kind 31922 and 31923). */
export function CalendarEventContent({ event, compact, className }: CalendarEventContentProps) {
const title = useMemo(() => getTag(event.tags, 'title'), [event.tags]);
const image = useMemo(() => getTag(event.tags, 'image'), [event.tags]);
const image = useMemo(() => sanitizeUrl(getTag(event.tags, 'image')), [event.tags]);
const locationRaw = useMemo(() => getTag(event.tags, 'location'), [event.tags]);
const location = useMemo(() => locationRaw ? parseLocation(locationRaw) : undefined, [locationRaw]);
const dateDisplay = useMemo(() => formatEventDate(event), [event]);
const hashtags = useMemo(() => getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean), [event.tags]);
const participants = useMemo(() => getAllTags(event.tags, 'p'), [event.tags]);
const hasContent = event.content.trim().length > 0;
const summary = useMemo(() => getTag(event.tags, 'summary'), [event.tags]);
const ended = useMemo(() => getEventEndTimestamp(event) < Math.floor(Date.now() / 1000), [event]);
const hasContent = event.content.trim().length > 0;
const participantPubkeys = useMemo(
() => participants.map(([, pubkey]) => pubkey).filter(Boolean),
[participants],
);
if (compact) {
return (
<div className={cn('mt-3 space-y-3', className)}>
{image && (
<div className="relative -mx-4 aspect-[21/9] overflow-hidden">
<img src={image} alt={title ?? 'Calendar event'} className="w-full h-full object-cover" loading="lazy" />
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
)}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CalendarDays className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-[15px] leading-tight line-clamp-2">{title ?? 'Untitled event'}</h3>
</div>
{ended ? (
<Badge variant="secondary" className="shrink-0">Ended</Badge>
) : dateDisplay ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 max-w-[45%]">
<Clock className="size-3" />
<span className="truncate">{dateDisplay}</span>
</span>
) : null}
</div>
{dateDisplay && ended && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="size-3" />
<span>{dateDisplay}</span>
</div>
)}
{(summary || hasContent) && (
<div className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{summary && !hasContent ? (
<p>{summary}</p>
) : (
<NoteContent event={event} className="text-sm" hideEmbedImages />
)}
</div>
)}
{(location || participantPubkeys.length > 0) && (
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
{location ? (
<>
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm truncate flex-1">{location}</span>
</>
) : (
<>
<Users className="h-4 w-4 shrink-0 text-primary" />
<span className="text-sm text-muted-foreground flex-1">Participants</span>
</>
)}
{participantPubkeys.length > 0 && (
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="sm" />
)}
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
{tag}
</Badge>
))}
</div>
)}
</div>
);
}
return (
<div className={cn('mt-2 rounded-xl border border-border overflow-hidden', className)}>
{compact ? (
/* ── Compact feed card matching reference design ── */
<>
{/* Cover image with capped height, or gradient placeholder */}
{image ? (
<div className="relative h-[180px] overflow-hidden">
<img
src={image}
alt={title ?? 'Calendar event'}
className="h-full w-full object-cover"
loading="lazy"
/>
{/* Participant avatars overlaid on the image */}
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
) : (
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px]">
<CalendarDays className="h-10 w-10 text-primary/30" />
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
)}
{/* Event details below image */}
<div className="p-3 space-y-2">
{/* Title */}
{title && (
<h3 className="text-base font-bold leading-snug line-clamp-2">{title}</h3>
)}
{/* Date/time */}
{dateDisplay && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{/* Description snippet — hard-capped to ~2 lines */}
{(summary || hasContent) && (
<div className="text-sm text-muted-foreground max-h-[2.8em] overflow-hidden relative">
{summary && !hasContent ? (
<p className="line-clamp-2">{summary}</p>
) : (
<NoteContent event={event} className="text-sm" hideEmbedImages />
)}
</div>
)}
{/* Location pill */}
{location && (
<div className="flex items-center gap-2.5 rounded-lg bg-secondary/50 px-3 py-2">
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm truncate">{location}</span>
</div>
)}
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
{tag}
</Badge>
))}
</div>
)}
</div>
</>
{image ? (
<div className="aspect-video rounded-lg overflow-hidden">
<img src={image} alt={title ?? 'Calendar event'} className="h-full w-full object-cover" loading="lazy" />
</div>
) : (
/* ── Full detail layout (detail page, expanded view) ── */
<>
{/* Cover image or gradient header */}
{image ? (
<div className="aspect-video rounded-lg overflow-hidden">
<img
src={image}
alt={title ?? 'Calendar event'}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
) : (
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
</div>
)}
{/* Event details */}
<div className="space-y-2 p-3">
{title && (
<h3 className="text-[15px] font-semibold leading-snug">{title}</h3>
)}
{dateDisplay && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{location}</span>
</div>
)}
{participants.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{participants.length} {participants.length === 1 ? 'participant' : 'participants'}
</span>
</div>
)}
{summary && !hasContent && (
<p className="text-sm text-muted-foreground">
{summary}
</p>
)}
{hasContent && (
<div>
<NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
{tag}
</Badge>
))}
</div>
)}
</div>
</>
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
</div>
)}
<div className="space-y-2 p-3">
{title && <h3 className="text-[15px] font-semibold leading-snug">{title}</h3>}
{dateDisplay && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{location}</span>
</div>
)}
{participants.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>{participants.length} {participants.length === 1 ? 'participant' : 'participants'}</span>
</div>
)}
{summary && !hasContent && <p className="text-sm text-muted-foreground">{summary}</p>}
{hasContent && <NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
{tag}
</Badge>
))}
</div>
)}
</div>
</div>
);
}
+150 -136
View File
@@ -1,6 +1,5 @@
import { useState, useMemo, useCallback } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
ArrowLeft,
CalendarDays,
@@ -9,10 +8,9 @@ import {
Users,
Check,
X as XIcon,
HelpCircle,
Share2,
Star,
Pencil,
ExternalLink,
Zap,
Link as LinkIcon,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
@@ -22,10 +20,15 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { NoteContent } from '@/components/NoteContent';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { PostActionBar } from '@/components/PostActionBar';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { RSVPAvatars } from '@/components/RSVPAvatars';
import { ZapDialog } from '@/components/ZapDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useComments } from '@/hooks/useComments';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
@@ -184,48 +187,47 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const rsvps = useEventRSVPs(eventCoord);
const myRsvp = useMyRSVP(eventCoord);
const publishRSVP = usePublishRSVP();
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const canEdit = user?.pubkey === event.pubkey;
const [selectedStatus, setSelectedStatus] = useState<'accepted' | 'declined' | 'tentative' | null>(null);
const [rsvpNote, setRsvpNote] = useState('');
const replyTree = useMemo((): ReplyNode[] => {
const buildNode = (comment: NostrEvent): ReplyNode => {
const children = commentsData?.getDirectReplies(comment.id) ?? [];
if (children.length <= 1) {
return { event: comment, children: children.map((child) => buildNode(child)) };
}
const activeStatus = selectedStatus ?? myRsvp.status;
const hasChanged = selectedStatus !== null && selectedStatus !== myRsvp.status;
const [first, ...rest] = children;
return {
event: comment,
children: [buildNode(first)],
hiddenChildren: rest.map((child) => buildNode(child)),
};
};
const handleRSVP = useCallback(async () => {
if (!activeStatus) return;
return [...(commentsData?.topLevelComments ?? [])]
.sort((a, b) => a.created_at - b.created_at)
.map((comment) => buildNode(comment));
}, [commentsData]);
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
if (status === myRsvp.status) return;
try {
await publishRSVP.mutateAsync({
eventCoord,
eventAuthorPubkey: event.pubkey,
status: activeStatus,
note: rsvpNote || undefined,
status,
});
setSelectedStatus(null);
setRsvpNote('');
toast({ title: 'RSVP updated' });
} catch {
toast({ title: 'Failed to update RSVP', variant: 'destructive' });
}
}, [activeStatus, eventCoord, event.pubkey, rsvpNote, publishRSVP, toast]);
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
const isAuthor = user?.pubkey === event.pubkey;
const showRSVP = !!user && !isAuthor;
const showRSVP = !!user;
return (
<div className="max-w-2xl mx-auto pb-16">
@@ -239,6 +241,15 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1">Event Details</h1>
{canEdit && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditOpen(true)}
aria-label="Edit event"
>
<Pencil className="size-5" />
</button>
)}
</div>
{/* ── Cover image ── */}
@@ -256,25 +267,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<div className="px-5 mt-5 space-y-5">
{/* Title */}
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
{/* Organizer row + actions */}
{/* Organizer row */}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<PersonRow pubkey={event.pubkey} />
</div>
<div className="flex items-center gap-1 shrink-0">
<ZapDialog target={event}>
<button className="p-2 rounded-full hover:bg-secondary/60 transition-colors" aria-label="Zap">
<Zap className="size-5" />
</button>
</ZapDialog>
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
aria-label="Share"
>
<Share2 className="size-5" />
</button>
</div>
</div>
{/* Date & Location — sidebar-style pills */}
@@ -355,96 +352,113 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
</>
)}
{/* RSVP section */}
{showRSVP && (
<div className="rounded-[1.25rem] bg-background/85 p-4 space-y-3">
<h2 className="text-sm font-semibold px-1">Your RSVP</h2>
{myRsvp.status && !selectedStatus && (
<div className="px-1">
<Badge
variant="outline"
className={cn(
myRsvp.status === 'accepted' && 'border-green-500 text-green-600',
myRsvp.status === 'tentative' && 'border-amber-500 text-amber-600',
myRsvp.status === 'declined' && 'border-destructive text-destructive',
)}
>
{myRsvp.status === 'accepted' ? 'Going' : myRsvp.status === 'tentative' ? 'Maybe' : "Can't Go"}
</Badge>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
variant={activeStatus === 'accepted' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => setSelectedStatus('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={activeStatus === 'tentative' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => setSelectedStatus('tentative')}
>
<HelpCircle className="size-3.5 mr-1.5" /> Maybe
</Button>
<Button
size="sm"
variant={activeStatus === 'declined' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => setSelectedStatus('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
{activeStatus && (
<Textarea
placeholder="Add a note (optional)"
value={rsvpNote}
onChange={(e) => setRsvpNote(e.target.value)}
className="mt-1 resize-none rounded-xl"
rows={2}
/>
)}
{(hasChanged || (activeStatus && !myRsvp.status)) && (
<Button
size="sm"
onClick={handleRSVP}
disabled={publishRSVP.isPending}
className="w-full mt-1 rounded-full"
>
{publishRSVP.isPending ? 'Updating...' : myRsvp.status ? 'Update RSVP' : 'Submit RSVP'}
</Button>
)}
</div>
)}
{/* Attendees */}
{rsvps.total > 0 && (
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Maybe', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</section>
</>
)}
{/* RSVP section */}
{showRSVP && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Check className="size-4" /> RSVP
</h2>
<div className="flex gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1.5" /> Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
</section>
</>
)}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="-mx-5 px-5"
/>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
{canEdit && (
<CreateCommunityEventDialog
open={editOpen}
onOpenChange={setEditOpen}
event={event}
/>
)}
<section>
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
</section>
)}
) : replyTree.length > 0 ? (
<div className="-mx-5">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</section>
</div>
</div>
);
+24 -4
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Crown, Shield, Users } from 'lucide-react';
import { Bookmark, Crown, Shield, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -18,6 +18,10 @@ interface CommunityCardProps {
event: NostrEvent;
/** Whether the current user founded this community. */
isFounded?: boolean;
/** Whether the current user is a validated member (rank > 0 via badge chain). */
isMember?: boolean;
/** Whether the current user has bookmarked this community (NIP-51 kind 10004). */
isBookmarked?: boolean;
className?: string;
}
@@ -25,7 +29,13 @@ interface CommunityCardProps {
* Compact card for displaying a community in a list.
* Shows image, name, description snippet, founder info, and moderator count.
*/
export function CommunityCard({ event, isFounded, className }: CommunityCardProps) {
export function CommunityCard({
event,
isFounded,
isMember,
isBookmarked,
className,
}: CommunityCardProps) {
const community = useMemo(() => parseCommunityEvent(event), [event]);
const founderAuthor = useAuthor(event.pubkey);
@@ -75,12 +85,22 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
<h3 className="text-sm font-semibold truncate flex-1 group-hover:text-primary transition-colors">
{community.name}
</h3>
{isFounded && (
{isFounded ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
<Crown className="size-2.5" />
Founder
</Badge>
)}
) : isMember ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<Shield className="size-2.5" />
Member
</Badge>
) : isBookmarked ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-primary/10 text-primary border-primary/20">
<Bookmark className="size-2.5 fill-current" />
Bookmarked
</Badge>
) : null}
</div>
{/* Description */}
+2 -29
View File
@@ -1,15 +1,12 @@
import { useMemo, useCallback } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Users, Share2, Globe } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Users, Globe } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -43,7 +40,6 @@ function parseCommunityEvent(event: NostrEvent) {
// --- Main Component ---
export function CommunityContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { name, description, image } = useMemo(
() => parseCommunityEvent(event),
[event],
@@ -68,22 +64,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
return (
<div className="mt-3 space-y-5">
{/* Community hero image */}
@@ -110,13 +90,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
</div>
)}
{/* Share button */}
<div className="flex items-center">
<Button variant="outline" size="icon" className="ml-auto size-8 shrink-0" onClick={handleShare}>
<Share2 className="size-3.5" />
</Button>
</div>
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
+193 -3
View File
@@ -3,16 +3,23 @@ import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
ArrowLeft,
Bookmark,
CalendarDays,
Crown,
MessageCircle,
Pencil,
Shield,
ShieldBan,
Share2,
Target,
UserPlus,
Users,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { AddMemberDialog } from '@/components/AddMemberDialog';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
@@ -28,6 +35,8 @@ import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyLis
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useComments } from '@/hooks/useComments';
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { useCommunityEvents } from '@/hooks/useCommunityEvents';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCommunityGoals } from '@/hooks/useCommunityGoals';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -118,6 +127,39 @@ function ReplyCardSkeleton() {
);
}
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function getCalendarEventStart(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const date = new Date(`${start}T00:00:00Z`);
return isNaN(date.getTime()) ? 0 : Math.floor(date.getTime() / 1000);
}
const timestamp = parseInt(start, 10);
return isNaN(timestamp) ? 0 : timestamp;
}
function getCalendarEventEnd(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const end = getTag(event.tags, 'end');
const endDate = new Date(`${end || start}T00:00:00Z`);
if (isNaN(endDate.getTime())) return 0;
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
}
const end = getTag(event.tags, 'end') || start;
const endTs = parseInt(end, 10);
return isNaN(endTs) ? 0 : endTs;
}
// ── Main component ────────────────────────────────────────────────────────────
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
@@ -133,6 +175,9 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const [activeTab, setActiveTab] = useState('members');
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);
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
@@ -156,6 +201,22 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const { data: membership, moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
const viewerMember = user ? getViewerAuthority(user.pubkey, rankMap, moderation) : undefined;
// Founder can add moderators + members; moderators (rank 0) can add members
const isFounder = !!user && user.pubkey === event.pubkey;
const canAddMembers = !!viewerMember && viewerMember.rank === 0;
// NIP-51 kind 10004 community bookmark toggle. Toasts are fired from inside
// the mutation so they survive even if this page unmounts mid-publish.
const {
isBookmarked: isCommunityBookmarked,
toggleBookmark: toggleCommunityBookmark,
} = useCommunityBookmarks();
const bookmarked = !!communityATag && isCommunityBookmarked(communityATag);
const handleToggleBookmark = useCallback(() => {
if (!user || !communityATag || toggleCommunityBookmark.isPending) return;
toggleCommunityBookmark.mutate({ aTag: communityATag });
}, [user, communityATag, toggleCommunityBookmark]);
// Batch-fetch profiles for all members
const allMemberPubkeys = useMemo(
() => membership?.members.map((m) => m.pubkey) ?? [],
@@ -205,6 +266,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// ── Fundraising goals (NIP-75) ──────────────────────────────────────────────
const { data: goals, isLoading: goalsLoading } = useCommunityGoals(communityATag || undefined);
const { data: communityEvents, isLoading: eventsLoading } = useCommunityEvents(communityATag || undefined);
const now = useNow(60_000);
/** Check if a goal event's `closed_at` deadline has passed. */
@@ -235,6 +297,30 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
});
}, [moderatedGoals, membersOnly, rankMap, isExpired]);
const eventItems = useMemo(() => {
const moderated = applyCommunityModerationToEvents(communityEvents ?? [], moderation);
const visible = membersOnly ? moderated.filter((e) => rankMap.has(e.pubkey)) : moderated;
return [...visible].sort((a, b) => {
const aStart = getCalendarEventStart(a);
const bStart = getCalendarEventStart(b);
const aFuture = aStart >= now;
const bFuture = bStart >= now;
if (aFuture && !bFuture) return -1;
if (!aFuture && bFuture) return 1;
if (aFuture && bFuture) return aStart - bStart;
return bStart - aStart;
});
}, [communityEvents, moderation, membersOnly, rankMap, now]);
const activeEventItems = useMemo(
() => eventItems.filter((e) => getCalendarEventEnd(e) >= now),
[eventItems, now],
);
const pastEventItems = useMemo(
() => eventItems.filter((e) => getCalendarEventEnd(e) < now),
[eventItems, now],
);
const replyTree = useMemo((): ReplyNode[] => {
if (!commentsData) return [];
const topLevel = commentsData.topLevelComments ?? [];
@@ -287,21 +373,33 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}
}, [event, toast]);
// ── FAB — visible on comments & fundraising tabs ──────────────────────────
// ── FAB — visible on comments, fundraising, and members tabs ──────────────
const handleFabClick = useCallback(() => {
if (activeTab === 'comments') {
setComposeOpen(true);
} else if (activeTab === 'fundraising') {
setGoalDialogOpen(true);
} else if (activeTab === 'events') {
setEventDialogOpen(true);
} else if (activeTab === 'members') {
setAddMemberOpen(true);
}
}, [activeTab]);
const fabIcon = activeTab === 'fundraising'
? <Target strokeWidth={3} size={18} />
: undefined; // default Plus icon for comments
: activeTab === 'members'
? <UserPlus className="size-5" />
: activeTab === 'events'
? <CalendarDays className="size-5" />
: undefined; // default Plus icon for comments
useLayoutOptions({
showFAB: activeTab === 'comments' || activeTab === 'fundraising',
showFAB:
activeTab === 'comments'
|| activeTab === 'fundraising'
|| activeTab === 'events'
|| (activeTab === 'members' && canAddMembers),
onFabClick: handleFabClick,
fabIcon,
});
@@ -324,6 +422,27 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1 truncate">Community</h1>
{isFounder && community && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditCommunityOpen(true)}
aria-label="Edit community"
>
<Pencil className="size-5" />
</button>
)}
{user && communityATag && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors disabled:opacity-50 disabled:pointer-events-none"
onClick={handleToggleBookmark}
disabled={toggleCommunityBookmark.isPending}
aria-label={bookmarked ? 'Remove community bookmark' : 'Bookmark community'}
aria-pressed={bookmarked}
aria-busy={toggleCommunityBookmark.isPending}
>
<Bookmark className={cn('size-5', bookmarked && 'fill-current')} />
</button>
)}
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
@@ -395,6 +514,13 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<Target className="size-4 mr-1.5" />
Fundraising
</TabsTrigger>
<TabsTrigger
value="events"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
>
<CalendarDays className="size-4 mr-1.5" />
Events
</TabsTrigger>
</TabsList>
{/* ── Members tab ── */}
@@ -502,6 +628,40 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</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>
) : (
<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>
)}
</TabsContent>
</Tabs>
</CommunityModerationContext.Provider>
</div>
@@ -535,6 +695,36 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
onOpenChange={setGoalDialogOpen}
/>
)}
{/* FAB-triggered event creation dialog for the events tab */}
{communityATag && (
<CreateCommunityEventDialog
communityATag={communityATag}
open={eventDialogOpen}
onOpenChange={setEventDialogOpen}
/>
)}
{/* Add member dialog */}
{canAddMembers && community && (
<AddMemberDialog
open={addMemberOpen}
onOpenChange={setAddMemberOpen}
communityEvent={event}
community={community}
isFounder={isFounder}
/>
)}
{/* Edit community dialog — founder only */}
{isFounder && community && (
<CreateCommunityDialog
open={editCommunityOpen}
onOpenChange={setEditCommunityOpen}
communityEvent={event}
community={community}
/>
)}
</div>
);
}
+299
View File
@@ -0,0 +1,299 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert text into a URL-safe slug for the d-tag identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface CreateCommunityDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing community event when editing. Omit to create a new community. */
communityEvent?: NostrEvent;
/** Parsed existing community data when editing. */
community?: ParsedCommunity;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEditing = !!communityEvent && !!community;
// Form state
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
const populateFromCommunity = useCallback(() => {
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setIsPublishing(false);
setIsImageUploading(false);
}, [community]);
const resetForm = useCallback(() => {
if (isEditing) {
populateFromCommunity();
} else {
setName('');
setDescription('');
setImageUrl('');
setIsPublishing(false);
setIsImageUploading(false);
}
}, [isEditing, populateFromCommunity]);
useEffect(() => {
if (open && isEditing) {
populateFromCommunity();
}
}, [open, isEditing, populateFromCommunity]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
const nextTags: string[][] = [
['d', effectiveSlug],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(...tags);
nextTags.push(['alt', `Community: ${name.trim()}`]);
return nextTags;
}, [description, effectiveSlug, imageUrl, name]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleCreate = useCallback(async () => {
if (!user || !name.trim() || !effectiveSlug) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
if (isEditing && communityEvent && community) {
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? communityEvent.content,
tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags),
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
toast({ title: 'Community updated!' });
handleOpenChange(false);
return;
}
// Check for d-tag collision (same author, same kind, same d-tag)
const existing = await nostr.query([{
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [effectiveSlug],
limit: 1,
}]);
if (existing.length > 0) {
toast({
title: 'Name already in use',
description: 'You already have a community with this name. Please choose a different name.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
// Founder as moderator (p tag)
const communityTags = buildUpdatedCommunityTags([['p', user.pubkey, '', 'moderator']]);
// Publish community definition (kind 34550)
const createdEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags: communityTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Navigate to the new community
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: createdEvent.pubkey,
identifier: effectiveSlug,
});
toast({ title: 'Community created!' });
handleOpenChange(false);
navigate(`/${naddr}`);
} catch (err) {
toast({
title: isEditing ? 'Failed to update community' : 'Failed to create community',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
{isEditing ? 'Edit Community' : 'Create a Community'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the name, image, and description. Moderators are preserved.'
: "Start a new community on Nostr. You'll be the founder."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
<Label htmlFor="community-name">Community Name *</Label>
<Input
id="community-name"
placeholder="e.g. The Arbiter's Guard"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
{name.trim() && (
<p className="text-xs text-muted-foreground font-mono">
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
</p>
)}
</div>
<ImageUploadField
id="community-image"
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Community image preview"
dropAreaClassName="min-h-32"
/>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="community-description">
Description
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<Textarea
id="community-description"
placeholder="What is this community about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
) : (
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,523 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface CreateCommunityEventDialogProps {
communityATag?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
event?: NostrEvent;
}
const MANAGED_EDIT_TAGS = new Set([
'd',
'title',
'alt',
'summary',
'location',
'image',
'start',
'end',
'D',
'start_tzid',
'end_tzid',
'A',
'K',
'P',
]);
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function addDays(date: string, days: number): string {
const parsed = new Date(`${date}T00:00:00Z`);
parsed.setUTCDate(parsed.getUTCDate() + days);
return parsed.toISOString().slice(0, 10);
}
function subtractDays(date: string, days: number): string {
return addDays(date, -days);
}
function formatLocalDateTimeFields(timestamp: string): { date: string; time: string } {
const parsed = parseInt(timestamp, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return { date: '', time: '' };
const date = new Date(parsed * 1000);
const pad = (n: number) => n.toString().padStart(2, '0');
return {
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`,
};
}
function toLocalTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
}
function parseCommunityAuthor(communityATag: string): string | undefined {
const [, pubkey] = communityATag.split(':');
return pubkey || undefined;
}
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: publishRSVP } = usePublishRSVP();
const [step, setStep] = useState<1 | 2>(1);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [allDay, setAllDay] = useState(true);
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [location, setLocation] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[],
);
const isEditing = !!event;
const effectiveCommunityATag = communityATag ?? event?.tags.find(([name]) => name === 'A')?.[1];
const isCommunityEvent = !!effectiveCommunityATag;
const resetForm = useCallback(() => {
setStep(1);
setTitle('');
setDescription('');
setImageUrl('');
setAllDay(true);
setStartDate('');
setStartTime('');
setEndDate('');
setEndTime('');
setLocation('');
setIsImageUploading(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
useEffect(() => {
if (!open || !event) return;
const titleTag = event.tags.find(([name]) => name === 'title')?.[1] ?? '';
const summaryTag = event.tags.find(([name]) => name === 'summary')?.[1] ?? '';
const imageTag = event.tags.find(([name]) => name === 'image')?.[1] ?? '';
const locationTag = event.tags.find(([name]) => name === 'location')?.[1] ?? '';
const startTag = event.tags.find(([name]) => name === 'start')?.[1] ?? '';
const endTag = event.tags.find(([name]) => name === 'end')?.[1] ?? '';
const isAllDay = event.kind === 31922;
setStep(1);
setTitle(titleTag);
setDescription(summaryTag || event.content);
setImageUrl(imageTag);
setLocation(locationTag);
setAllDay(isAllDay);
setIsImageUploading(false);
if (isAllDay) {
setStartDate(startTag);
setStartTime('');
setEndDate(endTag ? subtractDays(endTag, 1) : '');
setEndTime('');
return;
}
const startFields = formatLocalDateTimeFields(startTag);
const endFields = formatLocalDateTimeFields(endTag);
setStartDate(startFields.date);
setStartTime(startFields.time);
setEndDate(endFields.date);
setEndTime(endFields.time);
}, [event, open]);
const validateInfoStep = useCallback((): boolean => {
if (!title.trim()) {
toast({ title: 'Enter an event title', variant: 'destructive' });
return false;
}
return true;
}, [title, toast]);
const handleNext = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (isImageUploading) return;
if (!validateInfoStep()) return;
setStep(2);
}, [isImageUploading, validateInfoStep]);
const handleSubmit = useCallback(async () => {
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (!validateInfoStep()) return;
if (!startDate) {
toast({ title: 'Choose a start date', variant: 'destructive' });
return;
}
if (!allDay && !startTime) {
toast({ title: 'Choose a start time or turn on all-day', variant: 'destructive' });
return;
}
if (!allDay && endDate && !endTime) {
toast({ title: 'Add an end time or clear the end date', variant: 'destructive' });
return;
}
const trimmedTitle = title.trim();
const dTag = event?.tags.find(([name]) => name === 'd')?.[1] || `${slugify(trimmedTitle) || 'event'}-${Date.now()}`;
let kind = isEditing && event ? event.kind : 31922;
try {
const prev = isEditing && event
? await fetchFreshEvent(nostr, {
kinds: [event.kind],
authors: [event.pubkey],
'#d': [dTag],
})
: undefined;
const preservedTags = isEditing
? (prev?.tags ?? event?.tags ?? []).filter(([name]) => !MANAGED_EDIT_TAGS.has(name))
: [];
const tags: string[][] = [
['d', dTag],
['title', trimmedTitle],
['alt', `${isCommunityEvent ? 'Community event' : 'Calendar event'}: ${trimmedTitle}`],
...preservedTags,
];
if (effectiveCommunityATag) {
const communityAuthor = parseCommunityAuthor(effectiveCommunityATag);
tags.push(['A', effectiveCommunityATag], ['K', '34550']);
if (communityAuthor) {
tags.push(['P', communityAuthor]);
}
}
if (description.trim()) {
tags.push(['summary', description.trim()]);
}
if (location.trim()) {
tags.push(['location', location.trim()]);
}
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (allDay) {
tags.push(['start', startDate]);
if (endDate) {
if (endDate < startDate) {
toast({ title: 'End date must be on or after the start date', variant: 'destructive' });
return;
}
tags.push(['end', addDays(endDate, 1)]);
}
} else {
if (!isEditing) kind = 31923;
const startTs = toLocalTimestamp(startDate, startTime);
if (!Number.isFinite(startTs) || startTs <= 0) {
toast({ title: 'Start date or time is invalid', variant: 'destructive' });
return;
}
tags.push(['start', String(startTs)]);
tags.push(['D', String(Math.floor(startTs / 86400))]);
tags.push(['start_tzid', timezone]);
if (endTime) {
const effectiveEndDate = endDate || startDate;
const endTs = toLocalTimestamp(effectiveEndDate, endTime);
if (!Number.isFinite(endTs) || endTs <= startTs) {
toast({ title: 'End time must be after the start time', variant: 'destructive' });
return;
}
tags.push(['end', String(endTs)]);
tags.push(['end_tzid', timezone]);
}
}
const publishedEvent = await publishEvent({
kind,
content: description.trim(),
tags,
prev: prev ?? undefined,
});
if (!isEditing) {
// Auto-RSVP the author as "accepted" so they appear in the attendees list.
// Best-effort: don't block on failure -- the event itself is already published.
const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
publishRSVP({
eventCoord,
eventAuthorPubkey: user.pubkey,
status: 'accepted',
}).catch(() => {
// Silently ignore -- user can manually RSVP from the detail page if needed.
});
}
queryClient.setQueryData(['addr-event', kind, publishedEvent.pubkey, dTag], publishedEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['feed'] }),
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
...(effectiveCommunityATag ? [
queryClient.invalidateQueries({ queryKey: ['community-events', effectiveCommunityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(effectiveCommunityATag);
},
}),
] : []),
]);
toast({ title: isEditing ? 'Event updated!' : 'Event created!' });
handleOpenChange(false);
} catch (err) {
toast({
title: 'Failed to create event',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [
allDay,
description,
endDate,
endTime,
effectiveCommunityATag,
handleOpenChange,
imageUrl,
isImageUploading,
isEditing,
location,
nostr,
publishEvent,
publishRSVP,
queryClient,
startDate,
startTime,
timezone,
title,
toast,
user,
validateInfoStep,
isCommunityEvent,
event,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<CalendarDays className="size-5 text-primary" />
{isEditing ? 'Edit Event' : 'Create Event'}
</DialogTitle>
<DialogDescription>
Step {step} of 2 · {step === 1 ? 'What is happening?' : 'When and where?'}
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => e.preventDefault()}>
<ScrollArea className="max-h-[62vh]">
<div className="px-5 pb-5 space-y-4">
{step === 1 ? (
<>
<div className="space-y-1.5">
<Label htmlFor="community-event-title">Title *</Label>
<Input
id="community-event-title"
placeholder="e.g. Neighborhood cleanup"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-description">Description (recommended)</Label>
<Textarea
id="community-event-description"
placeholder="Tell people what to expect..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
<ImageUploadField
id="community-event-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Event image preview"
/>
</>
) : (
<>
<div className="flex items-center justify-between gap-4 rounded-xl border border-border px-3 py-3">
<div className="space-y-0.5">
<Label htmlFor="community-event-all-day">All-day event</Label>
<p className="text-xs text-muted-foreground">
{isEditing ? "Event type can't be changed while editing." : 'Turn off to add start and end times.'}
</p>
</div>
<Switch
id="community-event-all-day"
checked={allDay}
onCheckedChange={setAllDay}
disabled={isEditing}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-date">Start date *</Label>
<Input
id="community-event-start-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-date">End date (optional)</Label>
<Input
id="community-event-end-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{!allDay && (
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-time">Start time *</Label>
<Input
id="community-event-start-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-time">End time (optional)</Label>
<Input
id="community-event-end-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="community-event-location">Location (recommended)</Label>
<Input
id="community-event-location"
placeholder="Address, venue, or video call link"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<div className="flex items-center gap-2 border-t border-border px-5 py-4 bg-background">
{step === 1 ? (
<>
<Button type="button" variant="outline" className="flex-1" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="button" className="flex-1" onClick={handleNext} disabled={isImageUploading}>
{isImageUploading ? 'Uploading...' : 'Next'}
</Button>
</>
) : (
<>
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => setStep(1)}>
<ChevronLeft className="size-4" />
Back
</Button>
<Button type="button" className="flex-1" onClick={handleSubmit} disabled={isPending || isImageUploading}>
{isPending ? (isEditing ? 'Saving...' : 'Creating...') : isImageUploading ? 'Uploading...' : isEditing ? 'Save Event' : 'Create Event'}
</Button>
</>
)}
</div>
</form>
</DialogContent>
</Dialog>
);
}
+44 -35
View File
@@ -11,6 +11,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -45,6 +46,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
const [summary, setSummary] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [deadlineDate, setDeadlineDate] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const resetForm = () => {
setTitle('');
@@ -52,11 +54,16 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
setSummary('');
setImageUrl('');
setDeadlineDate('');
setIsImageUploading(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
const sats = parseInt(amountSats, 10);
if (isNaN(sats) || sats <= 0) {
@@ -92,9 +99,11 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
}
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
tags.push(['image', sanitizedImage]);
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (deadlineDate) {
const deadline = Math.floor(new Date(deadlineDate).getTime() / 1000);
@@ -163,17 +172,29 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-amount">Target Amount (sats)</Label>
<Input
id="goal-amount"
type="number"
min="1"
placeholder="e.g. 100000"
value={amountSats}
onChange={(e) => setAmountSats(e.target.value)}
required
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="goal-amount">Amount (sats)</Label>
<Input
id="goal-amount"
type="number"
min="1"
placeholder="e.g. 100000"
value={amountSats}
onChange={(e) => setAmountSats(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
<Input
id="goal-deadline"
type="datetime-local"
value={deadlineDate}
onChange={(e) => setDeadlineDate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
@@ -187,29 +208,17 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-image">Image URL (optional)</Label>
<Input
id="goal-image"
type="url"
placeholder="https://..."
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
/>
</div>
<ImageUploadField
id="goal-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Fundraising goal image preview"
/>
<div className="space-y-2">
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
<Input
id="goal-deadline"
type="datetime-local"
value={deadlineDate}
onChange={(e) => setDeadlineDate(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Goal'}
<Button type="submit" className="w-full" disabled={isPending || isImageUploading}>
{isPending ? 'Creating...' : isImageUploading ? 'Uploading...' : 'Create Goal'}
</Button>
</form>
</DialogContent>
+178
View File
@@ -0,0 +1,178 @@
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { Loader2, Upload, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { resizeImage } from '@/lib/resizeImage';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface ImageUploadFieldProps {
id: string;
label: ReactNode;
value: string;
onChange: (value: string) => void;
onUploadingChange?: (isUploading: boolean) => void;
placeholder?: string;
uploadText?: string;
uploadingText?: string;
uploadToastTitle?: string;
previewAlt?: string;
objectFit?: 'cover' | 'contain';
className?: string;
dropAreaClassName?: string;
disabled?: boolean;
}
export function ImageUploadField({
id,
label,
value,
onChange,
onUploadingChange,
placeholder = 'Paste an image URL, or upload above',
uploadText = 'Paste, drop, or click to upload an image',
uploadingText = 'Uploading image...',
uploadToastTitle = 'Image uploaded',
previewAlt = 'Image preview',
objectFit = 'cover',
className,
dropAreaClassName,
disabled,
}: ImageUploadFieldProps) {
const { config } = useAppContext();
const { toast } = useToast();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const imageInputRef = useRef<HTMLInputElement>(null);
const previewUrl = sanitizeUrl(value);
useEffect(() => {
onUploadingChange?.(isUploading);
}, [isUploading, onUploadingChange]);
const handleImageFile = useCallback(async (file: File) => {
if (!file.type.startsWith('image/')) {
toast({ title: 'Invalid file', description: 'Please choose an image file.', variant: 'destructive' });
return;
}
try {
const uploadableFile = config.imageQuality === 'compressed'
? (await resizeImage(file)).file
: file;
const [[, url]] = await uploadFile(uploadableFile);
onChange(url);
toast({ title: uploadToastTitle });
} catch (err) {
toast({
title: 'Upload failed',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [config.imageQuality, onChange, toast, uploadFile, uploadToastTitle]);
const handleImagePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items || disabled) return;
for (const item of Array.from(items)) {
if (!item.type.startsWith('image/')) continue;
const file = item.getAsFile();
if (!file) return;
e.preventDefault();
void handleImageFile(file);
return;
}
}, [disabled, handleImageFile]);
const handleImageDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (disabled) return;
const file = e.dataTransfer.files[0];
if (file) void handleImageFile(file);
}, [disabled, handleImageFile]);
const clearImage = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
if (imageInputRef.current) imageInputRef.current.value = '';
}, [onChange]);
return (
<div className={cn('space-y-1.5', className)} onPaste={handleImagePaste}>
<Label htmlFor={id}>{label}</Label>
<div>
<div
role="button"
tabIndex={disabled ? -1 : 0}
onClick={() => {
if (!disabled) imageInputRef.current?.click();
}}
onDrop={handleImageDrop}
onDragOver={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) imageInputRef.current?.click();
}}
className={cn(
'relative flex min-h-28 w-full cursor-pointer flex-col items-center justify-center overflow-hidden rounded-t-xl border border-b-0 border-dashed border-border bg-secondary/20 text-center transition-colors hover:bg-secondary/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60',
dropAreaClassName,
)}
>
{isUploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-xs">{uploadingText}</span>
</div>
) : previewUrl ? (
<>
<img
src={previewUrl}
alt={previewAlt}
className={cn('absolute inset-0 h-full w-full', objectFit === 'contain' ? 'object-contain p-3' : 'object-cover')}
/>
<button
type="button"
aria-label="Remove image"
onClick={clearImage}
disabled={disabled}
className="absolute right-2 top-2 rounded-full bg-background/90 p-1 text-muted-foreground shadow-sm transition-colors hover:text-destructive disabled:opacity-60"
>
<X className="size-4" />
</button>
</>
) : (
<div className="flex flex-col items-center gap-2 px-4 text-muted-foreground">
<Upload className="size-5 opacity-50" />
<span className="text-xs">{uploadText}</span>
</div>
)}
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
disabled={disabled}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImageFile(file);
}}
/>
</div>
<Input
id={id}
type="url"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-t-none rounded-b-xl"
disabled={disabled}
/>
</div>
</div>
);
}
+36 -4
View File
@@ -47,6 +47,7 @@ import { CommunityReportDialog } from '@/components/CommunityReportDialog';
import { AddToListDialog } from '@/components/AddToListDialog';
import { useNostr } from '@nostrify/react';
import { useBookmarks } from '@/hooks/useBookmarks';
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
@@ -57,7 +58,7 @@ import { useOrganizers } from '@/hooks/useOrganizers';
import { usePinnedPosts } from '@/hooks/usePinnedPosts';
import { useCountryFeed } from '@/contexts/CountryFeedContext';
import { useCommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { type CommunityMenuContext, canBanTarget, getViewerAuthority } from '@/lib/communityUtils';
import { type CommunityMenuContext, canBanTarget, getViewerAuthority, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
// NOTE: `CommunityMenuContext` is derived automatically from
// `useCommunityModerationContext()`. Parents install a
// `CommunityModerationContext.Provider` to enable community-aware menu items.
@@ -368,7 +369,26 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const navigate = useNavigate();
const { user } = useCurrentUser();
const { isBookmarked, toggleBookmark } = useBookmarks();
const bookmarked = isBookmarked(event.id);
const {
isBookmarked: isCommunityBookmarked,
toggleBookmark: toggleCommunityBookmark,
} = useCommunityBookmarks();
// Community events (kind 34550) are bookmarked via NIP-51 kind 10004
// using their addressable `a` tag coordinate, so the reference stays
// valid across community updates. Non-community events use the standard
// kind 10003 bookmark list keyed by event id.
const isCommunityEvent = event.kind === COMMUNITY_DEFINITION_KIND;
const communityATag = useMemo(() => {
if (!isCommunityEvent) return undefined;
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (!dTag) return undefined;
return `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`;
}, [isCommunityEvent, event.pubkey, event.tags]);
const bookmarked = isCommunityEvent
? !!communityATag && isCommunityBookmarked(communityATag)
: isBookmarked(event.id);
const { isPinned, togglePin } = usePinnedNotes(user?.pubkey);
const pinned = isPinned(event.id);
const isOwnPost = user?.pubkey === event.pubkey;
@@ -411,7 +431,15 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const handleBookmark = () => {
impactLight();
toggleBookmark.mutate(event.id);
if (isCommunityEvent) {
if (!communityATag) return;
// Success/error toasts are fired from the mutation itself in
// useCommunityBookmarks so they survive this dialog unmounting
// before the publish resolves.
toggleCommunityBookmark.mutate({ aTag: communityATag });
} else {
toggleBookmark.mutate(event.id);
}
close();
};
@@ -548,7 +576,11 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
/>
<MenuItem
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
label={bookmarked ? 'Remove Bookmark' : 'Bookmark'}
label={
isCommunityEvent
? (bookmarked ? 'Remove community bookmark' : 'Bookmark community')
: (bookmarked ? 'Remove Bookmark' : 'Bookmark')
}
onClick={handleBookmark}
/>
{user && (
+139
View File
@@ -0,0 +1,139 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
import { parseATagCoordinate } from '@/lib/nostrEvents';
import { toast } from '@/hooks/useToast';
/** NIP-51 Communities list — kind 10004. */
export const COMMUNITIES_LIST_KIND = 10004;
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
/** Parse and validate a NIP-51 community list coordinate. */
export function parseCommunityBookmarkATag(aTag: string): { pubkey: string; dTag: string } | undefined {
const coord = parseATagCoordinate(aTag);
if (!coord || coord.kind !== COMMUNITY_DEFINITION_KIND) return undefined;
if (!HEX_PUBKEY_RE.test(coord.pubkey) || !coord.identifier) return undefined;
return { pubkey: coord.pubkey, dTag: coord.identifier };
}
/**
* Hook to manage the user's NIP-51 Communities list (kind 10004).
*
* This list stores `a` tag coordinates for kind 34550 community definitions
* that the user has bookmarked / "saved". Unlike `useBookmarks` (kind 10003)
* which targets event IDs, this list targets addressable coordinates so the
* reference remains stable across community updates.
*/
export function useCommunityBookmarks() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
// Query the user's communities list (kind 10004 — replaceable event)
const listQuery = useQuery({
queryKey: ['community-bookmarks', user?.pubkey],
queryFn: async () => {
if (!user) return null;
const events = await nostr.query([{
kinds: [COMMUNITIES_LIST_KIND],
authors: [user.pubkey],
limit: 1,
}]);
return events[0] ?? null;
},
enabled: !!user,
});
// Extract bookmarked community a-tags (only `34550:` coordinates)
const bookmarkedATags: string[] = (listQuery.data?.tags ?? [])
.filter(([name, value]) =>
name === 'a' && typeof value === 'string' && !!parseCommunityBookmarkATag(value),
)
.map(([, value]) => value);
/** Check if a community `a` tag coordinate is bookmarked. */
function isBookmarked(aTag: string): boolean {
return bookmarkedATags.includes(aTag);
}
/**
* Toggle bookmark for a given community coordinate.
* `aTag` is expected to be a `34550:<pubkey>:<d-tag>` string.
* `relayHint` is optional — appended to the tag per NIP-51 when provided.
*/
const toggleBookmark = useMutation({
mutationFn: async ({ aTag, relayHint }: { aTag: string; relayHint?: string }) => {
if (!user) throw new Error('User is not logged in');
// Fetch the freshest kind 10004 from relays before mutating
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITIES_LIST_KIND],
authors: [user.pubkey],
});
const currentTags = prev?.tags ?? [];
const currentlyBookmarked = currentTags.some(
([name, value]) => name === 'a' && value === aTag,
);
let newTags: string[][];
if (currentlyBookmarked) {
// Remove all matching a-tags for this coordinate
newTags = currentTags.filter(
([name, value]) => !(name === 'a' && value === aTag),
);
} else {
// Append the new bookmark per NIP-51 recommendation
const newTag: string[] = relayHint ? ['a', aTag, relayHint] : ['a', aTag];
newTags = [...currentTags, newTag];
}
await publishEvent({
kind: COMMUNITIES_LIST_KIND,
content: prev?.content ?? '',
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
prev: prev ?? undefined,
});
// Return whether this was a remove or add so onSuccess can pick the
// right toast wording. Callbacks live on the mutation (not per-call)
// so they still fire when the triggering UI (e.g. a dialog) unmounts
// before the publish resolves.
return { removed: currentlyBookmarked };
},
onSuccess: ({ removed }) => {
queryClient.invalidateQueries({ queryKey: ['community-bookmarks', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['my-communities'] });
toast({
title: removed ? 'Community removed from bookmarks' : 'Community bookmarked',
});
},
onError: () => {
toast({
title: 'Failed to update bookmark',
variant: 'destructive',
});
},
});
return {
/** The kind 10004 list event itself. */
listEvent: listQuery.data,
/** Array of bookmarked community `a` tag coordinates. */
bookmarkedATags,
/** Whether the list query is still loading. */
isLoading: listQuery.isLoading,
/** Check whether a given `a` tag coordinate is bookmarked. */
isBookmarked,
/** Toggle a community bookmark on/off. */
toggleBookmark,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
const CALENDAR_EVENT_KINDS = [31922, 31923];
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function isValidCalendarEvent(event: NostrEvent): boolean {
if (!CALENDAR_EVENT_KINDS.includes(event.kind)) return false;
const d = getTag(event.tags, 'd');
const title = getTag(event.tags, 'title');
const start = getTag(event.tags, 'start');
if (!d || !title || !start) return false;
if (event.kind === 31922) {
return /^\d{4}-\d{2}-\d{2}$/.test(start);
}
const startTs = parseInt(start, 10);
return Number.isFinite(startTs) && startTs > 0;
}
/** Fetches NIP-52 calendar events scoped to a community via the uppercase `A` tag. */
export function useCommunityEvents(communityATag: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['community-events', communityATag],
queryFn: async ({ signal }): Promise<NostrEvent[]> => {
if (!communityATag) return [];
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const events = await nostr.query(
[{ kinds: CALENDAR_EVENT_KINDS, '#A': [communityATag], limit: 50 }],
{ signal: combinedSignal },
);
return events.filter(isValidCalendarEvent);
},
enabled: !!communityATag,
staleTime: 60_000,
});
}
+1
View File
@@ -13,6 +13,7 @@ import { useCallback, useMemo } from "react";
*/
const DEFAULT_SIDEBAR_ORDER: string[] = [
'wallet',
'search',
'verified',
'actions',
'polls',
+123 -17
View File
@@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { COMMUNITIES_LIST_KIND, parseCommunityBookmarkATag } from './useCommunityBookmarks';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_AWARD_KIND,
@@ -17,15 +18,26 @@ export interface MyCommunityEntry {
event: NostrEvent;
/** Whether the current user is the founder. */
isFounded: boolean;
/** Whether the current user is a validated (chain-derived) member. */
isMember: boolean;
/** Whether the current user has bookmarked the community via kind 10004. */
isBookmarked: boolean;
}
/**
* Fetch communities the logged-in user has founded or been recruited into.
* Fetch communities the logged-in user has founded, been recruited into,
* or bookmarked via their NIP-51 Communities list (kind 10004).
*
* Discovery follows the NIP:
* 1. Founded: `{ kinds: [34550], authors: [<user-pubkey>] }`
* 2. Member-of: query kind 8 awards targeting the user, extract badge `a` tags,
* Discovery:
*
* 1. Founded -- `{ kinds: [34550], authors: [user.pubkey] }`
* 2. Member-of -- kind 8 awards targeting the user, extract badge `a` tags,
* then find the community definitions referencing those badges.
* 3. Bookmarked -- read kind 10004 authored by user, extract `a` tags
* pointing at kind 34550 events, and fetch those community definitions.
*
* Priority when the same community appears in multiple sources:
* founded > member > bookmarked.
*/
export function useMyCommunities() {
const { nostr } = useNostr();
@@ -39,17 +51,27 @@ export function useMyCommunities() {
const timeout = AbortSignal.timeout(10_000);
const combinedSignal = AbortSignal.any([signal, timeout]);
// Step 1: Communities founded by the user
// ── Step 1: Communities founded by the user ───────────────────────────
const foundedEvents = await nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [user.pubkey], limit: 50 }],
{ signal: combinedSignal },
);
// Step 2: Badge awards targeting the user
const awards = await nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }],
{ signal: combinedSignal },
);
// ── Step 2: Badge awards targeting the user + Bookmarks list ──────────
//
// Batched into a single relay round-trip. The kind 10004 list is a
// replaceable event, so pulling it here keeps the read path tight and
// reuses the same connection.
const [awards, bookmarkListEvents] = await Promise.all([
nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }],
{ signal: combinedSignal },
),
nostr.query(
[{ kinds: [COMMUNITIES_LIST_KIND], authors: [user.pubkey], limit: 1 }],
{ signal: combinedSignal },
),
]);
// Extract badge a-tag coordinates from awards
const badgeATags = new Set<string>();
@@ -70,26 +92,110 @@ export function useMyCommunities() {
);
}
// Merge and deduplicate (founded takes priority)
// ── Step 4: Resolve bookmarked community coordinates ──────────────────
//
// NIP-51 kind 10004 stores community definitions as `a` tags like
// `34550:<pubkey>:<d-tag>`. For each bookmarked coordinate we query
// with both `authors` and `#d` so relays return a single authentic
// event per bookmark (per AGENTS.md security guidance on addressable
// events).
//
// Multiple coordinates with the same author are grouped to minimise
// the number of relay queries while keeping the author filter intact.
const bookmarkListEvent = bookmarkListEvents[0];
const bookmarkedCoords: string[] = (bookmarkListEvent?.tags ?? [])
.filter(([n, v]) =>
n === 'a'
&& typeof v === 'string'
&& !!parseCommunityBookmarkATag(v),
)
.map(([, v]) => v);
// Group bookmarked coords by author pubkey: author -> Set<d-tag>
const coordsByAuthor = new Map<string, Set<string>>();
for (const coord of bookmarkedCoords) {
const parsed = parseCommunityBookmarkATag(coord);
if (!parsed) continue;
const existing = coordsByAuthor.get(parsed.pubkey);
if (existing) {
existing.add(parsed.dTag);
} else {
coordsByAuthor.set(parsed.pubkey, new Set([parsed.dTag]));
}
}
let bookmarkedCommunityEvents: NostrEvent[] = [];
if (coordsByAuthor.size > 0) {
bookmarkedCommunityEvents = await nostr.query(
Array.from(coordsByAuthor.entries()).map(([authorPubkey, dTags]) => ({
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [authorPubkey],
'#d': [...dTags],
limit: dTags.size,
})),
{ signal: combinedSignal },
);
}
const bookmarkedATagSet = new Set(bookmarkedCoords);
// ── Merge & deduplicate ──────────────────────────────────────────────
// Priority: founded > member > bookmarked. `isBookmarked` is resolved
// from the bookmark list irrespective of which bucket produced the
// entry, so founders/members who have also bookmarked see both flags.
const seen = new Map<string, MyCommunityEntry>();
for (const event of foundedEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
seen.set(community.aTag, { community, event, isFounded: true });
seen.set(community.aTag, {
community,
event,
isFounded: true,
isMember: false,
isBookmarked: bookmarkedATagSet.has(community.aTag),
});
}
for (const event of memberCommunityEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
if (!seen.has(community.aTag)) {
seen.set(community.aTag, { community, event, isFounded: false });
}
if (seen.has(community.aTag)) continue;
seen.set(community.aTag, {
community,
event,
isFounded: false,
isMember: true,
isBookmarked: bookmarkedATagSet.has(community.aTag),
});
}
// Sort: founded first, then by created_at descending
for (const event of bookmarkedCommunityEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
if (!bookmarkedATagSet.has(community.aTag)) continue;
if (seen.has(community.aTag)) continue;
seen.set(community.aTag, {
community,
event,
isFounded: false,
isMember: false,
isBookmarked: true,
});
}
// Sort: founded first, then member, then bookmarked-only;
// tie-break by created_at descending.
const sortRank = (entry: MyCommunityEntry): number => {
if (entry.isFounded) return 0;
if (entry.isMember) return 1;
return 2;
};
return Array.from(seen.values()).sort((a, b) => {
if (a.isFounded !== b.isFounded) return a.isFounded ? -1 : 1;
const rankDiff = sortRank(a) - sortRank(b);
if (rankDiff !== 0) return rankDiff;
return b.event.created_at - a.event.created_at;
});
},
+55 -8
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { Loader2, Users } from 'lucide-react';
import { Loader2, Search, Users } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useInView } from 'react-intersection-observer';
import { CommunityCard } from '@/components/CommunityCard';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { LoginArea } from '@/components/auth/LoginArea';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
@@ -14,6 +16,7 @@ import { PageHeader } from '@/components/PageHeader';
import { PullToRefresh } from '@/components/PullToRefresh';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityModerationContext, type CommunityModerationContextValue } from '@/contexts/CommunityModerationContext';
import { COMMUNITY_DEFINITION_KIND, EMPTY_MODERATION } from '@/lib/communityUtils';
@@ -78,16 +81,20 @@ export function CommunitiesPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
useLayoutOptions({
hasSubHeader: !!user,
});
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useFeedTab<CommunitiesTab>('communities', [
'activities',
'mine',
]);
useLayoutOptions({
hasSubHeader: !!user,
showFAB: !!user && activeTab === 'mine',
onFabClick: () => setCreateDialogOpen(true),
fabIcon: <Users className="size-5" />,
});
useSeoMeta({
title: `Communities | ${config.appName}`,
description: 'Discover and join hierarchical communities on Nostr',
@@ -131,6 +138,8 @@ export function CommunitiesPage() {
}
/>
)}
<CreateCommunityDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} />
</main>
);
}
@@ -177,7 +186,25 @@ function MyCommunitiesContent() {
if (!myCommunities || myCommunities.length === 0) {
return (
<FeedEmptyState message="You haven't founded or joined any communities yet." />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No communities yet</h2>
<p className="text-muted-foreground text-sm">
Discover communities to join via the Search page, or create your own using the button below.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button asChild className="rounded-full">
<Link to="/search?tab=communities">
<Search className="size-4 mr-2" />
Search communities
</Link>
</Button>
</div>
</div>
);
}
@@ -188,6 +215,8 @@ function MyCommunitiesContent() {
key={entry.community.aTag}
event={entry.event}
isFounded={entry.isFounded}
isMember={entry.isMember}
isBookmarked={entry.isBookmarked}
/>
))}
</div>
@@ -294,7 +323,25 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
) : membersOnly && activityEvents && activityEvents.length > 0 ? (
<FeedEmptyState message="No activity from members of your communities yet. Toggle the shield icon to see all community activity." />
) : (
<FeedEmptyState message="No activity from your communities yet." />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No activity yet</h2>
<p className="text-muted-foreground text-sm">
Discover communities to join via the Search page, or create your own from the My Communities tab.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button asChild className="rounded-full">
<Link to="/search?tab=communities">
<Search className="size-4 mr-2" />
Search communities
</Link>
</Button>
</div>
</div>
)}
{!isLoading && hasNextPage && (
<div ref={scrollRef} className="py-4 flex justify-center">
+11 -2
View File
@@ -1,7 +1,8 @@
import type { NostrEvent } from "@nostrify/nostrify";
import { useSeoMeta } from "@unhead/react";
import { CalendarDays, Loader2 } from "lucide-react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { CreateCommunityEventDialog } from "@/components/CreateCommunityEventDialog";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { KindInfoButton } from "@/components/KindInfoButton";
import { NoteCard } from "@/components/NoteCard";
@@ -37,6 +38,7 @@ export function EventsFeedPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
const [createOpen, setCreateOpen] = useState(false);
const [activeTab, setActiveTab] = useFeedTab<FeedTab>("events", [
"follows",
@@ -44,7 +46,12 @@ export function EventsFeedPage() {
]);
useSeoMeta({ title: `Events | ${config.appName}` });
useLayoutOptions({ showFAB: true, fabKind: 31923, hasSubHeader: !!user });
useLayoutOptions({
showFAB: true,
onFabClick: () => setCreateOpen(true),
fabIcon: <CalendarDays className="size-5" />,
hasSubHeader: !!user,
});
// Calendar events feed
const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
@@ -160,6 +167,8 @@ export function EventsFeedPage() {
/>
)}
</PullToRefresh>
<CreateCommunityEventDialog open={createOpen} onOpenChange={setCreateOpen} />
</main>
);
}
+2 -3
View File
@@ -62,7 +62,7 @@ type TabType = 'communities' | 'posts' | 'accounts';
const VALID_TABS: TabType[] = ['communities', 'posts', 'accounts'];
function parseTab(value: string | null): TabType {
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts';
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'communities';
}
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
@@ -199,7 +199,7 @@ export function SearchPage() {
const setActiveTab = useCallback((tab: TabType) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (tab === 'posts') {
if (tab === 'communities') {
next.delete('tab');
} else {
next.set('tab', tab);
@@ -1208,4 +1208,3 @@ function SaveDestinationRow({
);
}