Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e144832b0 | |||
| 32bf4bdab4 | |||
| a5adbf2fed | |||
| 8120162960 | |||
| 94a26d3da1 | |||
| d939934b7b | |||
| e12716722a | |||
| aa96c0089c | |||
| da4116a1d1 |
@@ -791,22 +791,6 @@ 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.
|
||||
@@ -820,7 +804,6 @@ When the same community appears in multiple discovery sources, clients SHOULD di
|
||||
- [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
|
||||
|
||||
+1
-4
@@ -141,10 +141,7 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
],
|
||||
sidebarWidgets: [],
|
||||
messaging: {
|
||||
enabled: true,
|
||||
relayMode: 'hybrid',
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Search, Loader2, Plus, UserPlus, Check } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSearchProfiles } from '@/hooks/useSearchProfiles';
|
||||
import { useUserLists } from '@/hooks/useUserLists';
|
||||
@@ -156,7 +155,7 @@ export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: Ad
|
||||
onClick={() => handleAdd(profile)}
|
||||
onMouseEnter={() => setSelectedIdx(idx)}
|
||||
>
|
||||
<Avatar shape={getAvatarShape(profile.metadata)} className="size-9 shrink-0">
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage src={profile.metadata.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{name[0]?.toUpperCase()}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { Play, Pause, Volume1, Volume2, VolumeX } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import type { AvatarShape } from '@/lib/avatarShape';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePlayerControls } from '@/hooks/usePlayerControls';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
@@ -13,8 +12,6 @@ interface AudioVisualizerProps {
|
||||
avatarUrl?: string;
|
||||
/** Fallback display letter for the avatar */
|
||||
avatarFallback?: string;
|
||||
/** Avatar mask shape, forwarded from the author's profile metadata */
|
||||
avatarShape?: AvatarShape;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -29,7 +26,6 @@ export function AudioVisualizer({
|
||||
mime,
|
||||
avatarUrl,
|
||||
avatarFallback = '?',
|
||||
avatarShape,
|
||||
className,
|
||||
}: AudioVisualizerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
@@ -266,7 +262,7 @@ export function AudioVisualizer({
|
||||
: 'ring-border',
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-20 border-2 border-white/20" shape={avatarShape}>
|
||||
<Avatar className="size-20 border-2 border-white/20">
|
||||
<AvatarImage src={avatarUrl} alt={avatarFallback} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-2xl font-semibold">
|
||||
{avatarFallback}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
@@ -55,7 +54,6 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
|
||||
|
||||
@@ -141,7 +139,7 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) {
|
||||
{/* Issuer row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to={`/${npub}`}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<Avatar className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -452,7 +450,6 @@ function CommentsTab({ event, orderedReplies, commentsLoading }: {
|
||||
function AwardeeCard({ pubkey, metadata }: { pubkey: string; metadata?: NostrMetadata }) {
|
||||
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
|
||||
const about = metadata?.about;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
return (
|
||||
@@ -460,7 +457,7 @@ function AwardeeCard({ pubkey, metadata }: { pubkey: string; metadata?: NostrMet
|
||||
to={profileUrl}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-11 shrink-0">
|
||||
<Avatar className="size-11 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { BookOpen, MessageCircle, MessageSquare, MoreHorizontal, Star, Zap, Aler
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -53,7 +52,6 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
@@ -126,7 +124,7 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<Avatar className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { CalendarDays, Clock, MapPin, Users } from 'lucide-react';
|
||||
import { CalendarDays, MapPin, Clock, Users } from 'lucide-react';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { RSVPAvatars } from '@/components/RSVPAvatars';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RSVPAvatars } from '@/components/RSVPAvatars';
|
||||
|
||||
interface CalendarEventContentProps {
|
||||
event: NostrEvent;
|
||||
/** When true, renders a compact feed card. */
|
||||
/** When true, limits the description to 2 lines for compact feed display. */
|
||||
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;
|
||||
@@ -37,12 +43,14 @@ 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',
|
||||
@@ -51,35 +59,52 @@ 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) {
|
||||
const startDate = new Date(`${start}T00:00:00Z`);
|
||||
// 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');
|
||||
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 startStr = dateFormatter.format(startDate).replace(/, \d{4}$/, '');
|
||||
return `${startStr} - ${dateFormatter.format(lastDay)}`;
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +113,7 @@ 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);
|
||||
@@ -97,10 +123,13 @@ 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)) {
|
||||
return `${dateTimeFormatter.format(startDate)} - ${timeFormatter.format(endDate)}`;
|
||||
// Same day: "Jan 15, 2026 at 3:00 PM – 5:00 PM"
|
||||
return `${dateTimeFormatter.format(startDate)} – ${timeFormatter.format(endDate)}`;
|
||||
}
|
||||
return `${dateTimeFormatter.format(startDate)} - ${dateTimeFormatter.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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,162 +139,173 @@ 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(() => sanitizeUrl(getTag(event.tags, 'image')), [event.tags]);
|
||||
const image = useMemo(() => 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 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 summary = useMemo(() => getTag(event.tags, 'summary'), [event.tags]);
|
||||
|
||||
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" />
|
||||
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>
|
||||
)}
|
||||
|
||||
<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)}>
|
||||
{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>
|
||||
)}
|
||||
/* ── 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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
@@ -8,27 +9,22 @@ import {
|
||||
Users,
|
||||
Check,
|
||||
X as XIcon,
|
||||
Star,
|
||||
Pencil,
|
||||
HelpCircle,
|
||||
Share2,
|
||||
ExternalLink,
|
||||
Zap,
|
||||
Link as LinkIcon,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
|
||||
@@ -125,7 +121,6 @@ function roleSort(a: string, b: string): number {
|
||||
function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: string; size?: 'sm' | 'md' }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
const avatarCls = size === 'sm' ? 'size-8' : 'size-11';
|
||||
@@ -133,7 +128,7 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str
|
||||
|
||||
return (
|
||||
<Link to={profileUrl} className="flex items-center gap-3 group">
|
||||
<Avatar shape={avatarShape} className={cn(avatarCls, 'ring-2 ring-background')}>
|
||||
<Avatar className={cn(avatarCls, 'ring-2 ring-background')}>
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className={cn('bg-muted text-muted-foreground', fallbackCls)}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
@@ -187,47 +182,48 @@ 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 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 [selectedStatus, setSelectedStatus] = useState<'accepted' | 'declined' | 'tentative' | null>(null);
|
||||
const [rsvpNote, setRsvpNote] = useState('');
|
||||
|
||||
const [first, ...rest] = children;
|
||||
return {
|
||||
event: comment,
|
||||
children: [buildNode(first)],
|
||||
hiddenChildren: rest.map((child) => buildNode(child)),
|
||||
};
|
||||
};
|
||||
const activeStatus = selectedStatus ?? myRsvp.status;
|
||||
const hasChanged = selectedStatus !== null && selectedStatus !== myRsvp.status;
|
||||
|
||||
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;
|
||||
const handleRSVP = useCallback(async () => {
|
||||
if (!activeStatus) return;
|
||||
try {
|
||||
await publishRSVP.mutateAsync({
|
||||
eventCoord,
|
||||
eventAuthorPubkey: event.pubkey,
|
||||
status,
|
||||
status: activeStatus,
|
||||
note: rsvpNote || undefined,
|
||||
});
|
||||
setSelectedStatus(null);
|
||||
setRsvpNote('');
|
||||
toast({ title: 'RSVP updated' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to update RSVP', variant: 'destructive' });
|
||||
}
|
||||
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
|
||||
}, [activeStatus, eventCoord, event.pubkey, rsvpNote, publishRSVP, toast]);
|
||||
|
||||
const showRSVP = !!user;
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto pb-16">
|
||||
@@ -241,15 +237,6 @@ 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 ── */}
|
||||
@@ -267,11 +254,25 @@ 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 */}
|
||||
{/* Organizer row + actions */}
|
||||
<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 */}
|
||||
@@ -352,113 +353,96 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Attendees */}
|
||||
{rsvps.total > 0 && (
|
||||
<>
|
||||
<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')}
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{myRsvp.status === 'accepted' ? 'Going' : myRsvp.status === 'tentative' ? 'Maybe' : "Can't Go"}
|
||||
</Badge>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* 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" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : 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>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type ReactNode, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
|
||||
Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film,
|
||||
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
|
||||
Target, Users, Vote, Zap,
|
||||
@@ -186,7 +186,6 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
39089: PartyPopper,
|
||||
3367: Palette,
|
||||
9041: Target,
|
||||
31124: Egg,
|
||||
9735: Zap,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
@@ -64,7 +63,6 @@ function useEventComments(event: NostrEvent | undefined) {
|
||||
function CommentRow({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
@@ -75,7 +73,7 @@ function CommentRow({ event }: { event: NostrEvent }) {
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="size-7 rounded-full" />
|
||||
) : (
|
||||
<Avatar shape={avatarShape} className="size-7">
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="text-[10px] bg-primary/20 text-primary">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Bookmark, Crown, Shield, Users } from 'lucide-react';
|
||||
import { Crown, Shield, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { parseCommunityEvent, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -18,10 +17,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -29,18 +24,11 @@ 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,
|
||||
isMember,
|
||||
isBookmarked,
|
||||
className,
|
||||
}: CommunityCardProps) {
|
||||
export function CommunityCard({ event, isFounded, className }: CommunityCardProps) {
|
||||
const community = useMemo(() => parseCommunityEvent(event), [event]);
|
||||
|
||||
const founderAuthor = useAuthor(event.pubkey);
|
||||
const founderMeta = founderAuthor.data?.metadata;
|
||||
const founderAvatarShape = getAvatarShape(founderMeta);
|
||||
const founderName = founderMeta?.display_name || founderMeta?.name || genUserName(event.pubkey);
|
||||
const founderProfileUrl = useProfileUrl(event.pubkey, founderMeta);
|
||||
|
||||
@@ -85,22 +73,12 @@ export function CommunityCard({
|
||||
<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 */}
|
||||
@@ -117,7 +95,7 @@ export function CommunityCard({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-1.5 min-w-0"
|
||||
>
|
||||
<Avatar shape={founderAvatarShape} className="size-5">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={founderMeta?.picture} />
|
||||
<AvatarFallback className="text-[8px] bg-muted">
|
||||
{founderName.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users, Globe } from 'lucide-react';
|
||||
import { Users, Share2, Globe } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
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';
|
||||
@@ -40,6 +42,7 @@ function parseCommunityEvent(event: NostrEvent) {
|
||||
// --- Main Component ---
|
||||
|
||||
export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
const { name, description, image } = useMemo(
|
||||
() => parseCommunityEvent(event),
|
||||
[event],
|
||||
@@ -48,7 +51,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
// Owner
|
||||
const ownerAuthor = useAuthor(event.pubkey);
|
||||
const ownerMetadata = ownerAuthor.data?.metadata;
|
||||
const ownerAvatarShape = getAvatarShape(ownerMetadata);
|
||||
const ownerName = ownerMetadata?.display_name || ownerMetadata?.name || genUserName(event.pubkey);
|
||||
const ownerProfileUrl = useProfileUrl(event.pubkey, ownerMetadata);
|
||||
|
||||
@@ -64,6 +66,22 @@ 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 */}
|
||||
@@ -90,6 +108,13 @@ 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>
|
||||
@@ -112,7 +137,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">Created by</p>
|
||||
<Link to={ownerProfileUrl} className="flex items-center gap-3 group">
|
||||
<Avatar shape={ownerAvatarShape} className={cn('size-10 ring-2 ring-background')}>
|
||||
<Avatar className={cn('size-10 ring-2 ring-background')}>
|
||||
<AvatarImage src={ownerMetadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground">
|
||||
{ownerName.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -3,29 +3,22 @@ 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';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
|
||||
import { CommunityRightSidebar } from '@/components/CommunityRightSidebar';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
|
||||
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
|
||||
@@ -35,8 +28,6 @@ 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';
|
||||
@@ -56,7 +47,6 @@ import { cn } from '@/lib/utils';
|
||||
function PersonRow({ pubkey, label, size = 'md', onBan }: { pubkey: string; label?: string; size?: 'sm' | 'md'; onBan?: () => void }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
const avatarCls = size === 'sm' ? 'size-8' : 'size-10';
|
||||
@@ -65,7 +55,7 @@ function PersonRow({ pubkey, label, size = 'md', onBan }: { pubkey: string; labe
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<Link to={profileUrl} className="flex items-center gap-3 group flex-1 min-w-0">
|
||||
<Avatar shape={avatarShape} className={cn(avatarCls, 'ring-2 ring-background')}>
|
||||
<Avatar className={cn(avatarCls, 'ring-2 ring-background')}>
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className={cn('bg-muted text-muted-foreground', fallbackCls)}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
@@ -127,39 +117,6 @@ 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 }) {
|
||||
@@ -175,9 +132,6 @@ 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]);
|
||||
@@ -201,22 +155,6 @@ 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) ?? [],
|
||||
@@ -266,7 +204,6 @@ 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. */
|
||||
@@ -297,30 +234,6 @@ 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 ?? [];
|
||||
@@ -373,35 +286,33 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
}, [event, toast]);
|
||||
|
||||
// ── FAB — visible on comments, fundraising, and members tabs ──────────────
|
||||
// ── FAB — visible on comments & fundraising 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} />
|
||||
: activeTab === 'members'
|
||||
? <UserPlus className="size-5" />
|
||||
: activeTab === 'events'
|
||||
? <CalendarDays className="size-5" />
|
||||
: undefined; // default Plus icon for comments
|
||||
: undefined; // default Plus icon for comments
|
||||
|
||||
useLayoutOptions({
|
||||
showFAB:
|
||||
activeTab === 'comments'
|
||||
|| activeTab === 'fundraising'
|
||||
|| activeTab === 'events'
|
||||
|| (activeTab === 'members' && canAddMembers),
|
||||
showFAB: activeTab === 'comments' || activeTab === 'fundraising',
|
||||
onFabClick: handleFabClick,
|
||||
fabIcon,
|
||||
rightSidebar: communityATag ? (
|
||||
<CommunityRightSidebar
|
||||
scopedATag={communityATag}
|
||||
community={community}
|
||||
memberCount={membership?.members.length}
|
||||
viewerRank={viewerMember?.rank}
|
||||
reportsCount={moderation.allReports.length}
|
||||
activeGoalsCount={activeGoals.length}
|
||||
/>
|
||||
) : undefined,
|
||||
});
|
||||
|
||||
const moderationCtx = useMemo(
|
||||
@@ -422,27 +333,6 @@ 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}
|
||||
@@ -514,13 +404,6 @@ 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 ── */}
|
||||
@@ -628,40 +511,6 @@ 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>
|
||||
@@ -695,36 +544,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { MessageCircle, Shield, Target, Users } from 'lucide-react';
|
||||
import type { ParsedCommunity } from '@/lib/communityUtils';
|
||||
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { ActiveConversationsWidget } from '@/components/widgets/ActiveConversationsWidget';
|
||||
import { MyCommunitiesWidget } from '@/components/widgets/MyCommunitiesWidget';
|
||||
import { CommunityFundraisingWidget } from '@/components/widgets/CommunityFundraisingWidget';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommunityRightSidebarProps {
|
||||
scopedATag?: string;
|
||||
community?: ParsedCommunity | null;
|
||||
memberCount?: number;
|
||||
viewerRank?: number;
|
||||
reportsCount?: number;
|
||||
activeGoalsCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ title, icon, children }: SectionProps) {
|
||||
return (
|
||||
<section className="bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<h2 className="text-sm font-bold mb-2 flex items-center gap-1.5 text-foreground/90">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function getRoleLabel(rank: number | undefined): string {
|
||||
if (rank === undefined) return 'Observer';
|
||||
if (rank === 0) return 'Leadership';
|
||||
return `Member (rank ${rank})`;
|
||||
}
|
||||
|
||||
function CommunitySnapshot({
|
||||
community,
|
||||
memberCount,
|
||||
viewerRank,
|
||||
reportsCount,
|
||||
activeGoalsCount,
|
||||
}: {
|
||||
community: ParsedCommunity;
|
||||
memberCount?: number;
|
||||
viewerRank?: number;
|
||||
reportsCount?: number;
|
||||
activeGoalsCount?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 px-1">
|
||||
<p className="text-xs font-semibold truncate">{community.name}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Members</p>
|
||||
<p className="font-semibold">{memberCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Moderators</p>
|
||||
<p className="font-semibold">{community.moderatorPubkeys.length}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Your role</p>
|
||||
<p className="font-semibold">{getRoleLabel(viewerRank)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Open reports</p>
|
||||
<p className="font-semibold">{reportsCount ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{activeGoalsCount ?? 0} active goal{activeGoalsCount === 1 ? '' : 's'} in fundraising.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Community-focused right sidebar for overview and detail pages. */
|
||||
export function CommunityRightSidebar({
|
||||
scopedATag,
|
||||
community,
|
||||
memberCount,
|
||||
viewerRank,
|
||||
reportsCount,
|
||||
activeGoalsCount,
|
||||
className,
|
||||
}: CommunityRightSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-2 gap-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{scopedATag && community ? (
|
||||
<Section title="Community snapshot" icon={<Users className="size-3.5" />}>
|
||||
<CommunitySnapshot
|
||||
community={community}
|
||||
memberCount={memberCount}
|
||||
viewerRank={viewerRank}
|
||||
reportsCount={reportsCount}
|
||||
activeGoalsCount={activeGoalsCount}
|
||||
/>
|
||||
</Section>
|
||||
) : (
|
||||
<Section title="My communities" icon={<Users className="size-3.5" />}>
|
||||
<MyCommunitiesWidget />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Active conversations" icon={<MessageCircle className="size-3.5" />}>
|
||||
<ActiveConversationsWidget scopedATag={scopedATag} />
|
||||
</Section>
|
||||
|
||||
<Section title="Fundraising" icon={<Target className="size-3.5" />}>
|
||||
<CommunityFundraisingWidget scopedATag={scopedATag} />
|
||||
</Section>
|
||||
|
||||
{scopedATag && (
|
||||
<Section title="Moderation" icon={<Shield className="size-3.5" />}>
|
||||
<p className="text-sm text-muted-foreground p-1">
|
||||
Reports and member bans are scoped to this community and enforced in feed rendering.
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
<LinkFooter />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { encode as blurhashEncode } from 'blurhash';
|
||||
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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -178,7 +177,6 @@ export function ComposeBox({
|
||||
initialMode = 'post',
|
||||
}: ComposeBoxProps) {
|
||||
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
const { mutateAsync: createEvent, isPending, isPending: isPollPending } = useNostrPublish();
|
||||
const { mutateAsync: postComment, isPending: isCommentPending } = usePostComment();
|
||||
@@ -1082,7 +1080,7 @@ export function ComposeBox({
|
||||
<Skeleton className="size-12 shrink-0 mt-0.5 rounded-full" />
|
||||
) : (
|
||||
<Link to={userProfileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-12 shrink-0 mt-0.5">
|
||||
<Avatar className="size-12 shrink-0 mt-0.5">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
@@ -1144,7 +1143,6 @@ export function MuteSettingsInternals() {
|
||||
function MutedUserProfile({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
|
||||
if (author.isLoading) {
|
||||
@@ -1158,7 +1156,7 @@ function MutedUserProfile({ pubkey }: { pubkey: string }) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Avatar shape={avatarShape} className="size-7 shrink-0">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase() ?? '?'}
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ 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';
|
||||
@@ -46,7 +45,6 @@ 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('');
|
||||
@@ -54,16 +52,11 @@ 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) {
|
||||
@@ -99,11 +92,9 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
}
|
||||
if (imageUrl.trim()) {
|
||||
const sanitizedImage = sanitizeUrl(imageUrl.trim());
|
||||
if (!sanitizedImage) {
|
||||
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
|
||||
return;
|
||||
if (sanitizedImage) {
|
||||
tags.push(['image', sanitizedImage]);
|
||||
}
|
||||
tags.push(['image', sanitizedImage]);
|
||||
}
|
||||
if (deadlineDate) {
|
||||
const deadline = Math.floor(new Date(deadlineDate).getTime() / 1000);
|
||||
@@ -172,29 +163,17 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -208,17 +187,29 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
/>
|
||||
</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-image">Image URL (optional)</Label>
|
||||
<Input
|
||||
id="goal-image"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending || isImageUploading}>
|
||||
{isPending ? 'Creating...' : isImageUploading ? 'Uploading...' : 'Create Goal'}
|
||||
<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>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -31,15 +31,13 @@ import {
|
||||
import { z } from 'zod';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
import { isValidAvatarShape } from '@/lib/avatarShape';
|
||||
|
||||
// Extended form schema that includes custom fields and avatar shape
|
||||
// Extended form schema that includes custom fields
|
||||
const formSchema = n.metadata().extend({
|
||||
fields: z.array(z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
})).optional(),
|
||||
shape: z.string().optional(),
|
||||
});
|
||||
|
||||
type ExtendedMetadata = z.infer<typeof formSchema>;
|
||||
@@ -83,16 +81,6 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
return [];
|
||||
};
|
||||
|
||||
// Parse existing shape from raw event content
|
||||
const parseShape = (): string => {
|
||||
if (!event) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(event.content);
|
||||
if (isValidAvatarShape(parsed.shape)) return parsed.shape;
|
||||
} catch { /* ignore */ }
|
||||
return '';
|
||||
};
|
||||
|
||||
// Initialize the form with default values
|
||||
const form = useForm<ExtendedMetadata>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -106,7 +94,6 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
lud16: '',
|
||||
bot: false,
|
||||
fields: [],
|
||||
shape: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,7 +114,6 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
lud16: metadata.lud16 || '',
|
||||
bot: metadata.bot || false,
|
||||
fields: existingFields,
|
||||
shape: parseShape(),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -147,7 +133,6 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
nip05: v.nip05,
|
||||
lud16: v.lud16,
|
||||
bot: v.bot,
|
||||
shape: v.shape,
|
||||
} as Partial<NostrMetadata>);
|
||||
}, [form, onValuesChange]);
|
||||
|
||||
@@ -213,18 +198,13 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract fields, shape, and other metadata
|
||||
const { fields: customFields, shape, ...standardMetadata } = values;
|
||||
const { fields: customFields, ...standardMetadata } = values;
|
||||
|
||||
// Combine existing metadata with new values
|
||||
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
|
||||
|
||||
// Add shape only if set (an emoji string)
|
||||
if (shape && isValidAvatarShape(shape)) {
|
||||
data.shape = shape;
|
||||
} else {
|
||||
delete data.shape;
|
||||
}
|
||||
// Strip any legacy avatar shape data from old Ditto-style profiles
|
||||
delete data.shape;
|
||||
|
||||
// Clean up empty values in standard metadata
|
||||
for (const key in data) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
@@ -43,7 +42,6 @@ export function EmbeddedCardShell({
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
@@ -84,7 +82,7 @@ export function EmbeddedCardShell({
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Award, Image, MessageSquareOff } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
|
||||
@@ -194,7 +193,6 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
@@ -272,7 +270,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
import { extractZapAmount, extractZapSender, extractZapMessage } from '@/hooks/useEventInteractions';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
@@ -101,7 +100,6 @@ function EmbeddedZapCard({ event, className, disableHoverCards }: { event: Nostr
|
||||
const sender = useAuthor(senderPubkey || undefined);
|
||||
const senderMeta = sender.data?.metadata;
|
||||
const senderName = senderMeta?.name || (senderPubkey ? genUserName(senderPubkey) : 'Someone');
|
||||
const senderShape = getAvatarShape(senderMeta);
|
||||
const senderProfileUrl = useProfileUrl(senderPubkey, senderMeta);
|
||||
|
||||
return (
|
||||
@@ -135,7 +133,7 @@ function EmbeddedZapCard({ event, className, disableHoverCards }: { event: Nostr
|
||||
{senderPubkey && (
|
||||
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
|
||||
<Link to={senderProfileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={senderShape} className="size-5">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={senderMeta?.picture} alt={senderName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{senderName[0]?.toUpperCase()}
|
||||
|
||||
@@ -27,7 +27,6 @@ import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -107,13 +106,12 @@ function SealAvatar({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, pubkey);
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-12 ring-2 ring-amber-900/30 shadow-lg">
|
||||
<Avatar className="size-12 ring-2 ring-amber-900/30 shadow-lg">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-amber-900/20 text-amber-900 text-sm font-bold">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -456,7 +454,6 @@ export function EncryptedLetterCompact({ event, className }: EncryptedLetterComp
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const recipientPubkey = event.tags.find(([n]) => n === 'p')?.[1];
|
||||
const recipientAuthor = useAuthor(recipientPubkey ?? '');
|
||||
@@ -507,7 +504,7 @@ export function EncryptedLetterCompact({ event, className }: EncryptedLetterComp
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -26,7 +25,6 @@ interface EncryptedMessageContentProps {
|
||||
function Participant({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
@@ -43,7 +41,7 @@ function Participant({ pubkey }: { pubkey: string }) {
|
||||
<div className="flex flex-col items-center gap-1.5 min-w-0">
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-10 ring-2 ring-background shadow-md">
|
||||
<Avatar className="size-10 ring-2 ring-background shadow-md">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs font-semibold">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -129,7 +127,6 @@ export function EncryptedMessageCompact({ event, className }: EncryptedMessageCo
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const recipientPubkey = event.tags.find(([n]) => n === 'p')?.[1];
|
||||
const recipientAuthor = useAuthor(recipientPubkey ?? '');
|
||||
@@ -181,7 +178,7 @@ export function EncryptedMessageCompact({ event, className }: EncryptedMessageCo
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
|
||||
import { BookOpen, Droplets, ExternalLink, FileText, Globe, MapPin, MessageCircle, Package, Play, Repeat2, Share2, User, Users, Wind, Zap } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
|
||||
@@ -995,7 +994,6 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
|
||||
export function ProfilePreview({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
@@ -1018,7 +1016,7 @@ export function ProfilePreview({ pubkey }: { pubkey: string }) {
|
||||
to={profileUrl}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-12 shrink-0">
|
||||
<Avatar className="size-12 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary">
|
||||
<User className="size-5" />
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { getAvatarShape, getEmojiMaskUrl } from '@/lib/avatarShape';
|
||||
|
||||
interface FabButtonProps {
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
@@ -11,29 +7,9 @@ interface FabButtonProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable FAB that inherits the current user's avatar shape (emoji mask or
|
||||
* circle fallback), matching the FloatingComposeButton style exactly.
|
||||
* Reusable circular FAB.
|
||||
*/
|
||||
export function FabButton({ onClick, icon, disabled, className = '', title }: FabButtonProps) {
|
||||
const { metadata } = useCurrentUser();
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
|
||||
const shapeMaskStyle = useMemo<React.CSSProperties | undefined>(() => {
|
||||
if (!avatarShape) return undefined;
|
||||
const maskUrl = getEmojiMaskUrl(avatarShape);
|
||||
if (!maskUrl) return undefined;
|
||||
return {
|
||||
WebkitMaskImage: `url(${maskUrl})`,
|
||||
maskImage: `url(${maskUrl})`,
|
||||
WebkitMaskSize: 'contain',
|
||||
maskSize: 'contain' as string,
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskRepeat: 'no-repeat' as string,
|
||||
WebkitMaskPosition: 'center',
|
||||
maskPosition: 'center' as string,
|
||||
};
|
||||
}, [avatarShape]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
@@ -42,10 +18,7 @@ export function FabButton({ onClick, icon, disabled, className = '', title }: Fa
|
||||
className={`relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none ${className}`}
|
||||
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-primary ${shapeMaskStyle ? '' : 'rounded-full'}`}
|
||||
style={shapeMaskStyle}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary rounded-full" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Extract the first value of a tag by name. */
|
||||
@@ -56,7 +55,6 @@ function AudioFileContent({
|
||||
mime={mime}
|
||||
avatarUrl={metadata?.picture}
|
||||
avatarFallback={displayName[0]?.toUpperCase() ?? '?'}
|
||||
avatarShape={getAvatarShape(metadata)}
|
||||
/>
|
||||
{description && <DescriptionCard text={description} />}
|
||||
</div>
|
||||
|
||||
@@ -27,14 +27,12 @@ interface FloatingComposeButtonProps {
|
||||
}
|
||||
|
||||
export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: FloatingComposeButtonProps) {
|
||||
const { user, isLoading } = useCurrentUser();
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [comingSoonOpen, setComingSoonOpen] = useState(false);
|
||||
|
||||
// Hide until user metadata is resolved so the shape mask is immediately
|
||||
// correct — avoids a brief flash of the default circle fallback.
|
||||
if (!user || isLoading) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Users, PartyPopper } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -68,9 +67,8 @@ export function FollowPackContent({ event }: { event: NostrEvent }) {
|
||||
{previewPubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const name = member?.metadata?.name || genUserName(pk);
|
||||
const shape = getAvatarShape(member?.metadata);
|
||||
return (
|
||||
<Avatar key={pk} shape={shape} className="size-7">
|
||||
<Avatar key={pk} className="size-7">
|
||||
<AvatarImage src={member?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -147,7 +146,6 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
|
||||
|
||||
@@ -247,7 +245,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to={`/${npub}`}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<Avatar className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -361,7 +359,6 @@ export function MemberCard({
|
||||
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
|
||||
const about = metadata?.about;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const { follow, unfollow, isPending } = useFollowActions();
|
||||
|
||||
const handleFollowToggle = useCallback(
|
||||
@@ -382,7 +379,7 @@ export function MemberCard({
|
||||
onClick={() => navigate(`/${npub}`)}
|
||||
>
|
||||
<Link to={`/${npub}`} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<Avatar className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -4,7 +4,6 @@ import QRCode from 'qrcode';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
@@ -62,7 +61,7 @@ export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) {
|
||||
|
||||
{/* Avatar + name */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-16 ring-2 ring-secondary">
|
||||
<Avatar className="size-16 ring-2 ring-secondary">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="text-xl font-semibold">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -122,7 +122,7 @@ function GoalCardInner({ event, goal }: { event: NostrEvent; goal: ParsedGoal })
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
|
||||
<Link to={d.profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={d.avatarShape} className="size-8 ring-2 ring-background">
|
||||
<Avatar className="size-8 ring-2 ring-background">
|
||||
<AvatarImage src={d.metadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
|
||||
{d.displayName.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -10,8 +10,6 @@ import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
|
||||
/** Minimal imeta fields needed for pre-load sizing. */
|
||||
interface ImetaDimensions {
|
||||
dim?: string;
|
||||
@@ -863,7 +861,6 @@ function LightboxSlot({
|
||||
mime={meta?.mime}
|
||||
avatarUrl={authorMeta?.picture}
|
||||
avatarFallback={fallback[0]?.toUpperCase()}
|
||||
avatarShape={getAvatarShape(authorMeta)}
|
||||
className="w-full max-w-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,6 @@ import { OnboardingContext } from "@/hooks/useOnboarding";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||
import { genUserName } from "@/lib/genUserName";
|
||||
import { getAvatarShape, isValidAvatarShape } from "@/lib/avatarShape";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -707,12 +706,7 @@ function ProfileStep({
|
||||
const hasData = Object.values(profileData).some((v) => v);
|
||||
if (hasData) {
|
||||
try {
|
||||
// Build the outgoing metadata, stripping empty strings and validating shape.
|
||||
const { shape, ...rest } = profileData;
|
||||
const data: Record<string, unknown> = { ...rest };
|
||||
if (shape && isValidAvatarShape(shape)) {
|
||||
data.shape = shape;
|
||||
}
|
||||
const data: Record<string, unknown> = { ...profileData };
|
||||
for (const key in data) {
|
||||
if (data[key] === "") delete data[key];
|
||||
}
|
||||
@@ -774,9 +768,6 @@ function ProfileStep({
|
||||
setProfileData((prev) => ({ ...prev, ...patch }))
|
||||
}
|
||||
onPickImage={handlePickImage}
|
||||
onAvatarShape={(shape) =>
|
||||
setProfileData((prev) => ({ ...prev, shape }))
|
||||
}
|
||||
showNip05={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -1086,9 +1077,9 @@ function AuthorAttribution({ pubkey }: { pubkey: string }) {
|
||||
}
|
||||
|
||||
/** Tiny avatar used in pack member stacks. */
|
||||
function MiniAvatar({ src, name, metadata }: { src?: string; name: string; metadata?: NostrMetadata }) {
|
||||
function MiniAvatar({ src, name }: { src?: string; name: string; metadata?: NostrMetadata }) {
|
||||
return (
|
||||
<Avatar className="size-7 ring-2 ring-background" shape={getAvatarShape(metadata)}>
|
||||
<Avatar className="size-7 ring-2 ring-background">
|
||||
<AvatarImage src={src} alt={name} />
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-[10px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CustomEmojiImg, EmojifiedText } from '@/components/CustomEmoji';
|
||||
@@ -236,7 +235,6 @@ function ZapsTab({ zaps }: { zaps: ZapEntry[] }) {
|
||||
function RepostRow({ entry }: { entry: RepostEntry }) {
|
||||
const author = useAuthor(entry.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(entry.pubkey);
|
||||
const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]);
|
||||
|
||||
@@ -245,7 +243,7 @@ function RepostRow({ entry }: { entry: RepostEntry }) {
|
||||
to={`/${nevent}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
@@ -274,7 +272,6 @@ function RepostRow({ entry }: { entry: RepostEntry }) {
|
||||
function ReactionRow({ entry }: { entry: ReactionEntry }) {
|
||||
const author = useAuthor(entry.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(entry.pubkey);
|
||||
const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]);
|
||||
const customName = isCustomEmoji(entry.emoji) ? entry.emoji.slice(1, -1) : undefined;
|
||||
@@ -284,7 +281,7 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
|
||||
to={`/${nevent}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
@@ -323,7 +320,6 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
|
||||
function ZapRow({ zap }: { zap: ZapEntry }) {
|
||||
const author = useAuthor(zap.senderPubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(zap.senderPubkey);
|
||||
const nevent = useMemo(() => nip19.neventEncode({ id: zap.eventId, author: zap.senderPubkey }), [zap.eventId, zap.senderPubkey]);
|
||||
|
||||
@@ -332,7 +328,7 @@ function ZapRow({ zap }: { zap: ZapEntry }) {
|
||||
to={`/${nevent}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
@@ -369,7 +365,6 @@ function ZapRow({ zap }: { zap: ZapEntry }) {
|
||||
function QuoteRow({ quote }: { quote: QuoteEntry }) {
|
||||
const author = useAuthor(quote.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(quote.pubkey);
|
||||
const nevent = useMemo(() => nip19.neventEncode({ id: quote.eventId, author: quote.pubkey }), [quote.eventId, quote.pubkey]);
|
||||
|
||||
@@ -378,7 +373,7 @@ function QuoteRow({ quote }: { quote: QuoteEntry }) {
|
||||
to={`/${nevent}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { AgoraLogo } from '@/components/AgoraLogo';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
@@ -39,7 +38,6 @@ export function LeftSidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, metadata, event: currentUserEvent, isLoading: isProfileLoading } = useCurrentUser();
|
||||
const currentUserAvatarShape = getAvatarShape(metadata);
|
||||
const { currentUser, otherUsers, setLogin } = useLoggedInAccounts();
|
||||
const { logout } = useLoginActions();
|
||||
|
||||
@@ -154,7 +152,7 @@ export function LeftSidebar() {
|
||||
{isProfileLoading ? (
|
||||
<Skeleton className="size-10 shrink-0 rounded-full" />
|
||||
) : (
|
||||
<Avatar shape={currentUserAvatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
@@ -183,7 +181,7 @@ export function LeftSidebar() {
|
||||
{/* Current user */}
|
||||
<Link to={userProfileUrl} onClick={() => setAccountPopoverOpen(false)} className="block p-4 border-b border-border hover:bg-secondary/60 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar shape={currentUserAvatarShape} className="size-11 shrink-0">
|
||||
<Avatar className="size-11 shrink-0">
|
||||
<AvatarImage src={currentUser.metadata.picture} alt={getDisplayName(currentUser)} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">{getDisplayName(currentUser).charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -284,7 +282,7 @@ export function LeftSidebar() {
|
||||
<div className="border-b border-border">
|
||||
{otherUsers.map((account) => (
|
||||
<button key={account.id} onClick={() => { setLogin(account.id); setAccountPopoverOpen(false); }} className="flex items-center gap-3 w-full px-4 py-3 hover:bg-secondary/60 transition-colors">
|
||||
<Avatar shape={getAvatarShape(account.metadata)} className="size-9 shrink-0">
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage src={account.metadata.picture} alt={getDisplayName(account)} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">{getDisplayName(account).charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -195,14 +194,13 @@ export function LiveStreamChat({ aTag, className }: LiveStreamChatProps) {
|
||||
function ChatMessage({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-2 py-1 px-1 rounded hover:bg-secondary/40 transition-colors">
|
||||
<Link to={profileUrl} className="shrink-0 mt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[9px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { PageHeader } from '@/components/PageHeader';
|
||||
import { LiveStreamPlayer } from '@/components/LiveStreamPlayer';
|
||||
import { LiveStreamChat } from '@/components/LiveStreamChat';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
@@ -285,7 +284,6 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
function StreamAuthorRow({ event, participants }: { event: NostrEvent; participants: Participant[] }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
@@ -321,7 +319,7 @@ function StreamAuthorRow({ event, participants }: { event: NostrEvent; participa
|
||||
<div className="flex items-center gap-3">
|
||||
<ProfileHoverCard pubkey={showPubkey} asChild>
|
||||
<Link to={showProfileUrl}>
|
||||
<Avatar shape={avatarShape} className="size-10">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={showMetadata?.picture} alt={showName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{showName[0]?.toUpperCase()}
|
||||
@@ -368,7 +366,6 @@ function ZapButton({ event }: { event: NostrEvent }) {
|
||||
function ParticipantRow({ pubkey, role }: { pubkey: string; role?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
@@ -376,7 +373,7 @@ function ParticipantRow({ pubkey, role }: { pubkey: string; role?: string }) {
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-7">
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Blurhash } from 'react-blurhash';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Lightbox, LOADING_SENTINEL } from '@/components/ImageGallery';
|
||||
import { PhotoBottomBar } from '@/components/PhotoBottomBar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
@@ -105,7 +104,6 @@ interface FlatEntry {
|
||||
function AudioThumb({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.name ?? genUserName(pubkey);
|
||||
|
||||
return (
|
||||
@@ -115,7 +113,7 @@ function AudioThumb({ pubkey }: { pubkey: string }) {
|
||||
<div className="size-24 rounded-full border border-primary animate-ping" style={{ animationDuration: '3s' }} />
|
||||
<div className="absolute size-16 rounded-full border border-primary animate-ping" style={{ animationDuration: '2.3s', animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
<Avatar shape={avatarShape} className="size-12 relative ring-2 ring-primary/40">
|
||||
<Avatar className="size-12 relative ring-2 ring-primary/40">
|
||||
<AvatarImage src={metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="text-base">{name[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { UserRoundCheck } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -314,7 +313,7 @@ function MentionItem({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-8">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Link, useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Home, Search, User } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectionChanged } from '@/lib/haptics';
|
||||
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
||||
@@ -130,7 +129,7 @@ export function MobileBottomNav() {
|
||||
isOnProfile ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-5">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName?.[0]?.toUpperCase() || <User className="size-3" />}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useId, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2, QrCode } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
||||
import { SidebarNavList } from '@/components/SidebarNavItem';
|
||||
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
||||
@@ -49,7 +48,6 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, metadata, event: currentUserEvent } = useCurrentUser();
|
||||
const currentUserAvatarShape = getAvatarShape(metadata);
|
||||
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
const { logout } = useLoginActions();
|
||||
const { otherUsers, setLogin } = useLoggedInAccounts();
|
||||
@@ -142,7 +140,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
className="flex items-center gap-3 px-3 hover:bg-secondary/60 transition-colors w-full text-left"
|
||||
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
|
||||
>
|
||||
<Avatar shape={currentUserAvatarShape} className="size-7 shrink-0">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{displayName[0].toUpperCase()}
|
||||
@@ -253,7 +251,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
onClick={() => { setLogin(account.id); handleClose(); }}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<Avatar shape={getAvatarShape(account.metadata)} className="size-7 shrink-0">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={account.metadata.picture} alt={getDisplayName(account)} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{getDisplayName(account).charAt(0).toUpperCase()}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Search, UserRoundCheck, X, MessageSquare, FileText, Hash, Archive } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -440,7 +439,7 @@ function MobileNip05Item({
|
||||
onClick={() => onNavigate(`/${identifier}`)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-9 shrink-0">
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
@@ -484,7 +483,7 @@ function MobilePubkeyItem({
|
||||
onClick={() => onNavigate(`/${raw}`)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-9 shrink-0">
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
@@ -768,7 +767,7 @@ function SearchProfileItem({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-9">
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
|
||||
@@ -22,8 +22,6 @@ import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
|
||||
import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
@@ -56,7 +54,6 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { user } = useCurrentUser();
|
||||
@@ -123,7 +120,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
{parsed?.artist && <p className="text-base text-muted-foreground">{parsed.artist}</p>}
|
||||
{!parsed?.artist && (
|
||||
<Link to={profileUrl} className="flex items-center gap-2 group" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -284,7 +281,6 @@ function PlaylistDetail({ event }: { event: NostrEvent }) {
|
||||
const parsed = useMemo(() => parseMusicPlaylist(event), [event]);
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
@@ -316,7 +312,7 @@ function PlaylistDetail({ event }: { event: NostrEvent }) {
|
||||
<h2 className="text-xl sm:text-2xl font-bold leading-tight">{parsed?.title ?? 'Untitled'}</h2>
|
||||
|
||||
<Link to={profileUrl} className="flex items-center gap-2 group">
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { nostrUriToNip19 } from '@/lib/sidebarItems';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getKindIcon } from '@/lib/extraKinds';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useNostrEventSidebar } from '@/hooks/useNostrEventSidebar';
|
||||
@@ -42,10 +41,8 @@ export interface NostrEventSidebarItemProps {
|
||||
function ProfileSidebarIcon({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const shape = getAvatarShape(metadata);
|
||||
|
||||
return (
|
||||
<Avatar shape={shape} className={cn('size-6 shrink-0', className)}>
|
||||
<Avatar className={cn('size-6 shrink-0', className)}>
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{(metadata?.name?.[0] || '?').toUpperCase()}
|
||||
|
||||
@@ -77,7 +77,6 @@ import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
|
||||
import { ZapstoreReleaseContent, ZapstoreAssetContent } from "@/components/ZapstoreReleaseContent";
|
||||
import { AppHandlerContent } from "@/components/AppHandlerContent";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||
@@ -184,7 +183,6 @@ export function ActivityCard({
|
||||
export interface ActorRowProps {
|
||||
pubkey: string;
|
||||
profileUrl: string;
|
||||
avatarShape: Parameters<typeof Avatar>[0]['shape'];
|
||||
picture?: string;
|
||||
displayName: string;
|
||||
authorEvent?: NostrEvent;
|
||||
@@ -196,7 +194,7 @@ export interface ActorRowProps {
|
||||
timestampLabel: string;
|
||||
}
|
||||
|
||||
export function ActorRow({ pubkey, profileUrl, avatarShape, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
|
||||
export function ActorRow({ pubkey, profileUrl, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -209,7 +207,7 @@ export function ActorRow({ pubkey, profileUrl, avatarShape, picture, displayName
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -324,14 +322,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
const zapSenderPubkey = useMemo(() => event.kind === 9735 ? extractZapSender(event) : '', [event]);
|
||||
const zapSender = useAuthor(zapSenderPubkey || undefined);
|
||||
const zapSenderMeta = zapSender.data?.metadata;
|
||||
const zapSenderShape = getAvatarShape(zapSenderMeta);
|
||||
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
|
||||
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
|
||||
|
||||
const pollVoteLabel = usePollVoteLabel(event);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const nip05 = metadata?.nip05;
|
||||
const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify(
|
||||
@@ -731,7 +727,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className={threaded || threadedLast ? "size-10" : "size-11"}>
|
||||
<Avatar className={threaded || threadedLast ? "size-10" : "size-11"}>
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -900,7 +896,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
|
||||
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} picture={metadata?.picture}
|
||||
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reacted" timestampLabel={timeAgo(event.created_at)} />
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
@@ -920,7 +916,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
|
||||
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} picture={metadata?.picture}
|
||||
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reposted" timestampLabel={timeAgo(event.created_at)} />
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
@@ -942,7 +938,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} avatarShape={zapSenderShape} picture={zapSenderMeta?.picture}
|
||||
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} picture={zapSenderMeta?.picture}
|
||||
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
|
||||
extra={zapAmountSats > 0 ? (
|
||||
<span className="text-sm font-semibold text-amber-500 shrink-0">
|
||||
@@ -967,7 +963,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
icon={
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className={iconSize}>
|
||||
<Avatar className={iconSize}>
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { nip19 } from 'nostr-tools';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
@@ -685,7 +684,6 @@ export function NoteContent({
|
||||
mime={imeta?.mime}
|
||||
avatarUrl={authorMetadata?.picture}
|
||||
avatarFallback={authorDisplayName[0]?.toUpperCase() ?? '?'}
|
||||
avatarShape={getAvatarShape(authorMetadata)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
|
||||
@@ -47,7 +46,6 @@ 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';
|
||||
@@ -58,7 +56,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, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
import { type CommunityMenuContext, canBanTarget, getViewerAuthority } from '@/lib/communityUtils';
|
||||
// NOTE: `CommunityMenuContext` is derived automatically from
|
||||
// `useCommunityModerationContext()`. Parents install a
|
||||
// `CommunityModerationContext.Provider` to enable community-aware menu items.
|
||||
@@ -369,26 +367,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const { isBookmarked, toggleBookmark } = useBookmarks();
|
||||
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 bookmarked = isBookmarked(event.id);
|
||||
const { isPinned, togglePin } = usePinnedNotes(user?.pubkey);
|
||||
const pinned = isPinned(event.id);
|
||||
const isOwnPost = user?.pubkey === event.pubkey;
|
||||
@@ -412,7 +391,6 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
const postIsPinnedInCountry = !!countryCode && isPinnedInCountry(event.id);
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const { addMute, removeMute, isMuted } = useMuteList();
|
||||
const userMuted = isMuted('pubkey', event.pubkey);
|
||||
@@ -431,15 +409,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
|
||||
const handleBookmark = () => {
|
||||
impactLight();
|
||||
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);
|
||||
}
|
||||
toggleBookmark.mutate(event.id);
|
||||
close();
|
||||
};
|
||||
|
||||
@@ -534,7 +504,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
{/* Post preview */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex gap-3">
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
@@ -576,11 +546,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
|
||||
label={
|
||||
isCommunityEvent
|
||||
? (bookmarked ? 'Remove community bookmark' : 'Bookmark community')
|
||||
: (bookmarked ? 'Remove Bookmark' : 'Bookmark')
|
||||
}
|
||||
label={bookmarked ? 'Remove Bookmark' : 'Bookmark'}
|
||||
onClick={handleBookmark}
|
||||
/>
|
||||
{user && (
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Link } from 'react-router-dom';
|
||||
import { MessageCircle, Zap, MoreHorizontal } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
@@ -34,7 +33,6 @@ export function PhotoBottomBar({ event }: PhotoBottomBarProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey) ?? genUserName(event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
@@ -53,7 +51,7 @@ export function PhotoBottomBar({ event }: PhotoBottomBarProps) {
|
||||
{/* Avatar + name */}
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-7">
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-white/20 text-white text-xs">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -22,8 +22,6 @@ import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
|
||||
import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
@@ -55,7 +53,6 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
|
||||
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { user } = useCurrentUser();
|
||||
@@ -124,7 +121,7 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
|
||||
<h2 className="text-xl sm:text-2xl font-bold leading-tight">{parsed?.title ?? 'Untitled'}</h2>
|
||||
|
||||
<Link to={profileUrl} className="flex items-center gap-2 group" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -285,7 +282,6 @@ function TrailerDetail({ event }: { event: NostrEvent }) {
|
||||
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
@@ -327,7 +323,7 @@ function TrailerDetail({ event }: { event: NostrEvent }) {
|
||||
<h2 className="text-xl sm:text-2xl font-bold leading-tight">{parsed?.title ?? 'Untitled'}</h2>
|
||||
|
||||
<Link to={profileUrl} className="flex items-center gap-2 group">
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -109,10 +108,9 @@ function VoterAvatarsButton({
|
||||
{votes.slice(0, 6).map((vote) => {
|
||||
const authorData = authorsMap?.get(vote.pubkey);
|
||||
const metadata = authorData?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.name || genUserName(vote.pubkey);
|
||||
return (
|
||||
<Avatar key={vote.pubkey} shape={avatarShape} className="size-5 ring-1 ring-background">
|
||||
<Avatar key={vote.pubkey} className="size-5 ring-1 ring-background">
|
||||
<AvatarImage src={metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
@@ -484,7 +482,6 @@ function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps)
|
||||
const individualAuthor = useAuthor(authorsMap?.has(vote.pubkey) ? undefined : vote.pubkey);
|
||||
const authorData = authorsMap?.get(vote.pubkey) ?? individualAuthor.data;
|
||||
const metadata = authorData?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(vote.pubkey);
|
||||
|
||||
const nevent = useMemo(
|
||||
@@ -521,7 +518,7 @@ function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps)
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
|
||||
+33
-126
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { type AvatarShape, isValidAvatarShape, isEmoji, getAvatarMaskUrlAsync, shapedAvatarBorderStyle } from '@/lib/avatarShape';
|
||||
import { CheckCircle2, Pencil, Plus, Trash2, ChevronDown, ImagePlus, SmilePlus, X as XIcon } from 'lucide-react';
|
||||
import { CheckCircle2, Pencil, Plus, Trash2, ChevronDown, ImagePlus, X as XIcon } from 'lucide-react';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { BioContent } from '@/components/BioContent';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -12,8 +11,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { EmojiPicker, type EmojiSelection } from '@/components/EmojiPicker';
|
||||
import { useProfileBadges } from '@/hooks/useProfileBadges';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { BadgeShowcaseGrid } from '@/components/BadgeShowcaseGrid';
|
||||
@@ -94,8 +91,6 @@ export interface ProfileCardProps {
|
||||
metadata: Partial<NostrMetadata>;
|
||||
onChange?: (patch: Partial<NostrMetadata>) => void;
|
||||
onPickImage?: (field: 'picture' | 'banner') => void;
|
||||
/** Called when user picks an avatar shape (emoji string, or empty to clear). */
|
||||
onAvatarShape?: (shape: string) => void;
|
||||
/** Called when user removes their avatar picture. */
|
||||
onRemoveAvatar?: () => void;
|
||||
/** Show NIP-05 row (default true) */
|
||||
@@ -110,7 +105,6 @@ export function ProfileCard({
|
||||
metadata,
|
||||
onChange,
|
||||
onPickImage,
|
||||
onAvatarShape,
|
||||
onRemoveAvatar,
|
||||
showNip05 = true,
|
||||
extraFields,
|
||||
@@ -119,7 +113,6 @@ export function ProfileCard({
|
||||
const editable = !!onChange;
|
||||
const [nip05Focused, setNip05Focused] = useState(false);
|
||||
const [fieldsOpen, setFieldsOpen] = useState(false);
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const isOwnProfile = !!pubkey && !!user && pubkey === user.pubkey;
|
||||
@@ -133,49 +126,6 @@ export function ProfileCard({
|
||||
// Sanitize banner URL from untrusted metadata before CSS url() interpolation
|
||||
const bannerUrl = sanitizeUrl(metadata.banner);
|
||||
|
||||
// Read shape from metadata (it's a custom property passed through the loose schema)
|
||||
const rawShape = metadata.shape;
|
||||
const shape: AvatarShape | undefined = isValidAvatarShape(rawShape) ? rawShape : undefined;
|
||||
const isEmojiShape = !!shape && isEmoji(shape);
|
||||
const hasCustomShape = isEmojiShape;
|
||||
|
||||
// State for async-loaded mask URL for the hover overlay
|
||||
const [overlayMaskUrl, setOverlayMaskUrl] = useState<string>('');
|
||||
|
||||
// Load mask URL asynchronously when shape changes
|
||||
useEffect(() => {
|
||||
if (!hasCustomShape || !shape) {
|
||||
setOverlayMaskUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
getAvatarMaskUrlAsync(shape).then((url) => {
|
||||
if (!cancelled) {
|
||||
setOverlayMaskUrl(url);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasCustomShape, shape]);
|
||||
|
||||
// Memoized mask style for the hover overlay on shaped avatars
|
||||
const overlayMaskStyle = useMemo<React.CSSProperties | undefined>(() => {
|
||||
if (!overlayMaskUrl) return undefined;
|
||||
return {
|
||||
WebkitMaskImage: `url(${overlayMaskUrl})`,
|
||||
maskImage: `url(${overlayMaskUrl})`,
|
||||
WebkitMaskSize: 'contain',
|
||||
maskSize: 'contain' as string,
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskRepeat: 'no-repeat' as string,
|
||||
WebkitMaskPosition: 'center',
|
||||
maskPosition: 'center' as string,
|
||||
};
|
||||
}, [overlayMaskUrl]);
|
||||
|
||||
const nip05 = metadata.nip05;
|
||||
const nip05Domain = nip05 ? getNip05Domain(nip05) : undefined;
|
||||
|
||||
@@ -225,84 +175,41 @@ export function ProfileCard({
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-between items-start -mt-12 mb-3">
|
||||
{editable ? (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="relative shrink-0 cursor-pointer group outline-none">
|
||||
<div style={hasCustomShape ? shapedAvatarBorderStyle : undefined}>
|
||||
<Avatar shape={shape} className={cn("shadow-sm", hasCustomShape ? "size-[88px]" : "size-24 border-4 border-background")}>
|
||||
<AvatarImage src={metadata.picture} alt={displayName} className="object-cover" />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-2xl font-bold">
|
||||
{metadata.picture ? initial : <Plus className="size-8 text-muted-foreground" strokeWidth={4} />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bg-black/0 group-hover:bg-black/45 transition-colors flex items-center justify-center',
|
||||
!hasCustomShape && 'rounded-full',
|
||||
)}
|
||||
style={overlayMaskStyle}
|
||||
>
|
||||
<Pencil className="size-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" />
|
||||
</div>
|
||||
{metadata.picture && (
|
||||
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => onPickImage?.('picture')}>
|
||||
<ImagePlus className="size-4 mr-2" />
|
||||
Change avatar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEmojiPickerOpen(true)}>
|
||||
<SmilePlus className="size-4 mr-2" />
|
||||
Set avatar shape
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="relative shrink-0 cursor-pointer group outline-none">
|
||||
<Avatar className="shadow-sm size-24 border-4 border-background">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} className="object-cover" />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-2xl font-bold">
|
||||
{metadata.picture ? initial : <Plus className="size-8 text-muted-foreground" strokeWidth={4} />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/45 transition-colors flex items-center justify-center rounded-full">
|
||||
<Pencil className="size-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" />
|
||||
</div>
|
||||
{metadata.picture && (
|
||||
<DropdownMenuItem onClick={() => onRemoveAvatar?.()} className="text-destructive focus:text-destructive">
|
||||
<XIcon className="size-4 mr-2" />
|
||||
Remove avatar
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={emojiPickerOpen} onOpenChange={setEmojiPickerOpen}>
|
||||
<DialogContent className="w-fit max-w-[calc(100vw-2rem)] p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle className="text-base">Set avatar shape</DialogTitle>
|
||||
<DialogDescription>Pick an emoji to mask your avatar</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EmojiPicker onSelect={(selection: EmojiSelection) => {
|
||||
if (selection.type === 'native') {
|
||||
onAvatarShape?.(selection.emoji);
|
||||
setEmojiPickerOpen(false);
|
||||
}
|
||||
}} />
|
||||
{hasCustomShape && (
|
||||
<div className="px-4 pb-4 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-destructive hover:text-destructive"
|
||||
onClick={() => { onAvatarShape?.(''); setEmojiPickerOpen(false); }}
|
||||
>
|
||||
<XIcon className="size-3.5 mr-1.5" />
|
||||
Remove avatar shape
|
||||
</Button>
|
||||
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
<DropdownMenuItem onClick={() => onPickImage?.('picture')}>
|
||||
<ImagePlus className="size-4 mr-2" />
|
||||
Change avatar
|
||||
</DropdownMenuItem>
|
||||
{metadata.picture && (
|
||||
<DropdownMenuItem onClick={() => onRemoveAvatar?.()} className="text-destructive focus:text-destructive">
|
||||
<XIcon className="size-4 mr-2" />
|
||||
Remove avatar
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="relative shrink-0" style={hasCustomShape ? shapedAvatarBorderStyle : undefined}>
|
||||
<Avatar shape={shape} className={cn("shadow-sm", hasCustomShape ? "size-[88px]" : "size-24 border-4 border-background")}>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="shadow-sm size-24 border-4 border-background">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} className="object-cover" />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-2xl font-bold">
|
||||
{initial}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { nip19 } from 'nostr-tools';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { BioContent } from '@/components/BioContent';
|
||||
@@ -35,7 +34,6 @@ function ProfileHoverCardBody({ pubkey }: { pubkey: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
const nip05 = metadata?.nip05;
|
||||
@@ -74,7 +72,7 @@ function ProfileHoverCardBody({ pubkey }: { pubkey: string }) {
|
||||
{/* Avatar overlapping the banner */}
|
||||
<div className="-mt-8 mb-2">
|
||||
<Link to={profileUrl} onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-16 border-3 border-background">
|
||||
<Avatar className="size-16 border-3 border-background">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-lg">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -86,8 +85,6 @@ function ProfileSnapshotCard({
|
||||
}) {
|
||||
const metadata = useMemo(() => parseMetadata(event.content), [event.content]);
|
||||
const displayName = metadata?.display_name || metadata?.name || genUserName(event.pubkey);
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -106,7 +103,7 @@ function ProfileSnapshotCard({
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<Avatar shape={avatarShape} className="size-11 shrink-0 ring-2 ring-background">
|
||||
<Avatar className="size-11 shrink-0 ring-2 ring-background">
|
||||
{metadata?.picture ? (
|
||||
<AvatarImage src={metadata.picture} alt={displayName} />
|
||||
) : null}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Search, UserRoundCheck, MessageSquare, FileText, Hash, Archive } from '
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -567,7 +566,7 @@ function Nip05IdentifierItem({
|
||||
onClick={() => onNavigate(`/${identifier}`)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
@@ -611,7 +610,7 @@ function PubkeyIdentifierItem({
|
||||
onClick={() => onNavigate(`/${raw}`)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
@@ -905,7 +904,7 @@ function ProfileItem({
|
||||
onMouseDown={(e) => e.preventDefault()} // Prevent input blur
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-10">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -23,15 +22,13 @@ const spacingClasses: Record<AvatarSize, string> = {
|
||||
function RSVPAvatar({ pubkey, size = 'sm' }: { pubkey: string; size?: AvatarSize }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
|
||||
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar shape={avatarShape} className={cn(sizeClasses[size], 'ring-2 ring-background')}>
|
||||
<Avatar className={cn(sizeClasses[size], 'ring-2 ring-background')}>
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground">
|
||||
{initial}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { X } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useTrendingTags, useLatestAccounts, useSortedPosts, useTagSparklines } from '@/hooks/useTrending';
|
||||
@@ -187,7 +186,6 @@ export function RightSidebar() {
|
||||
function HotPostCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
const { onClick: openPost, onAuxClick } = useOpenPost(`/${encodedId}`);
|
||||
@@ -207,7 +205,7 @@ function HotPostCard({ event }: { event: NostrEvent }) {
|
||||
className="block w-full text-left hover:bg-secondary/40 -mx-2 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -234,13 +232,12 @@ function LatestAccountCard({ event, onDismiss }: { event: NostrEvent; onDismiss:
|
||||
}
|
||||
|
||||
const displayName = metadata.name || genUserName(event.pubkey);
|
||||
const latestAvatarShape = getAvatarShape(metadata);
|
||||
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 group hover:bg-secondary/40 -mx-2 px-2 py-2 rounded-lg transition-colors">
|
||||
<Link to={`/${npub}`} className="shrink-0">
|
||||
<Avatar shape={latestAvatarShape} className="size-10">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { nip19 } from 'nostr-tools';
|
||||
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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
@@ -158,9 +157,8 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
|
||||
{previewPubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const name = member?.metadata?.name || genUserName(pk);
|
||||
const shape = getAvatarShape(member?.metadata);
|
||||
return (
|
||||
<Avatar key={pk} shape={shape} className="size-8 ring-2 ring-background">
|
||||
<Avatar key={pk} className="size-8 ring-2 ring-background">
|
||||
<AvatarImage src={member?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
|
||||
@@ -6,8 +6,6 @@ import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
|
||||
/** Parse NIP-A0 imeta fields from an event's tags. */
|
||||
function parseVoiceImeta(tags: string[][]): { waveform?: number[]; duration?: number } {
|
||||
for (const tag of tags) {
|
||||
@@ -61,7 +59,6 @@ export function VoiceMessagePlayer({ event, className }: VoiceMessagePlayerProps
|
||||
src={audioUrl}
|
||||
avatarUrl={avatarUrl}
|
||||
avatarFallback={displayName[0]?.toUpperCase() ?? '?'}
|
||||
avatarShape={getAvatarShape(metadata)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check, Plus } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { WIDGET_DEFINITIONS, WIDGET_CATEGORIES } from '@/lib/sidebarWidgets';
|
||||
import type { WidgetConfig } from '@/contexts/AppContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WidgetPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentWidgets: WidgetConfig[];
|
||||
onAdd: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Dialog for adding/removing widgets from the sidebar. */
|
||||
export function WidgetPickerDialog({ open, onOpenChange, currentWidgets, onAdd, onRemove }: WidgetPickerDialogProps) {
|
||||
const activeIds = useMemo(() => new Set(currentWidgets.map((w) => w.id)), [currentWidgets]);
|
||||
|
||||
// Group widgets by category
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, typeof WIDGET_DEFINITIONS> = {};
|
||||
for (const w of WIDGET_DEFINITIONS) {
|
||||
(groups[w.category] ??= []).push(w);
|
||||
}
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Widget</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-5 pr-2">
|
||||
{Object.entries(grouped).map(([category, widgets]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{WIDGET_CATEGORIES[category] ?? category}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{widgets.map((widget) => {
|
||||
const isActive = activeIds.has(widget.id);
|
||||
const Icon = widget.icon;
|
||||
return (
|
||||
<button
|
||||
key={widget.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onRemove(widget.id);
|
||||
} else {
|
||||
onAdd(widget.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full px-3 py-2.5 rounded-xl transition-colors text-left',
|
||||
isActive
|
||||
? 'bg-primary/10 hover:bg-primary/15'
|
||||
: 'hover:bg-secondary/60',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'size-9 rounded-lg flex items-center justify-center shrink-0',
|
||||
isActive ? 'bg-primary/20 text-primary' : 'bg-secondary text-muted-foreground',
|
||||
)}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{widget.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{widget.description}</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'size-6 rounded-full flex items-center justify-center shrink-0 transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border border-border text-muted-foreground/50',
|
||||
)}>
|
||||
{isActive ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, lazy, Suspense, memo } from 'react';
|
||||
import { useCallback, useMemo, lazy, Suspense, memo } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { WidgetCard } from '@/components/WidgetCard';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
@@ -28,8 +27,6 @@ import type { WidgetDefinition } from '@/lib/sidebarWidgets';
|
||||
|
||||
// ── Lazy-loaded widget components ────────────────────────────────────────────
|
||||
|
||||
const TrendingWidget = lazy(() => import('@/components/widgets/TrendingWidget').then((m) => ({ default: m.TrendingWidget })));
|
||||
const HotPostsWidget = lazy(() => import('@/components/widgets/HotPostsWidget').then((m) => ({ default: m.HotPostsWidget })));
|
||||
const StatusWidget = lazy(() => import('@/components/widgets/StatusWidget').then((m) => ({ default: m.StatusWidget })));
|
||||
const AIChatWidget = lazy(() => import('@/components/widgets/AIChatWidget').then((m) => ({ default: m.AIChatWidget })));
|
||||
const BlueskyWidget = lazy(() => import('@/components/widgets/BlueskyWidget').then((m) => ({ default: m.BlueskyWidget })));
|
||||
@@ -37,16 +34,10 @@ const PhotoWidget = lazy(() => import('@/components/widgets/PhotoWidget').then((
|
||||
const MusicWidget = lazy(() => import('@/components/widgets/MusicWidget').then((m) => ({ default: m.MusicWidget })));
|
||||
const FeedWidget = lazy(() => import('@/components/widgets/FeedWidget').then((m) => ({ default: m.FeedWidget })));
|
||||
|
||||
const WidgetPickerDialog = lazy(() => import('@/components/WidgetPickerDialog').then((m) => ({ default: m.WidgetPickerDialog })));
|
||||
|
||||
// ── Widget content resolver ──────────────────────────────────────────────────
|
||||
|
||||
function WidgetContent({ id }: { id: string }) {
|
||||
switch (id) {
|
||||
case 'trends':
|
||||
return <TrendingWidget />;
|
||||
case 'hot-posts':
|
||||
return <HotPostsWidget />;
|
||||
case 'status':
|
||||
return <StatusWidget />;
|
||||
case 'ai-chat':
|
||||
@@ -145,7 +136,6 @@ const EMPTY_WIDGETS: WidgetConfig[] = [];
|
||||
export function WidgetSidebar() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const widgets = config.sidebarWidgets ?? EMPTY_WIDGETS;
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Filter out widgets with unknown definitions
|
||||
const validWidgets = useMemo(
|
||||
@@ -168,13 +158,6 @@ export function WidgetSidebar() {
|
||||
updateWidgets((ws) => ws.map((w) => w.id === id ? { ...w, height } : w));
|
||||
}, [updateWidgets]);
|
||||
|
||||
const addWidget = useCallback((id: string) => {
|
||||
updateWidgets((ws) => {
|
||||
if (ws.some((w) => w.id === id)) return ws;
|
||||
return [...ws, { id }];
|
||||
});
|
||||
}, [updateWidgets]);
|
||||
|
||||
// Drag-and-drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
@@ -212,35 +195,13 @@ export function WidgetSidebar() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add widget button */}
|
||||
<button
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 w-full py-2.5 rounded-xl bg-background/85 text-muted-foreground hover:text-foreground hover:bg-background transition-colors text-xs"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add widget
|
||||
</button>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mt-auto pt-3">
|
||||
<LinkFooter />
|
||||
</div>
|
||||
|
||||
{/* Widget picker dialog */}
|
||||
<Suspense fallback={null}>
|
||||
{pickerOpen && (
|
||||
<WidgetPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
currentWidgets={widgets}
|
||||
onAdd={addWidget}
|
||||
onRemove={removeWidget}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu.tsx';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton.tsx';
|
||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -46,7 +45,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
||||
{isLoading ? (
|
||||
<Skeleton className='w-10 h-10 rounded-full shrink-0' />
|
||||
) : (
|
||||
<Avatar shape={getAvatarShape(currentUser.metadata)} className='w-10 h-10'>
|
||||
<Avatar className='w-10 h-10'>
|
||||
<AvatarImage src={currentUser.metadata.picture} alt={getDisplayName(currentUser)} />
|
||||
<AvatarFallback>{getDisplayName(currentUser).charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -69,7 +68,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
||||
onClick={() => setLogin(user.id)}
|
||||
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
|
||||
>
|
||||
<Avatar shape={getAvatarShape(user.metadata)} className='w-8 h-8'>
|
||||
<Avatar className='w-8 h-8'>
|
||||
<AvatarImage src={user.metadata.picture} alt={getDisplayName(user)} />
|
||||
<AvatarFallback>{getDisplayName(user)?.charAt(0) || <UserIcon />}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { saveNsec } from '@/lib/credentialManager';
|
||||
import { ProfileCard } from '@/components/ProfileCard';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
import { isValidAvatarShape } from '@/lib/avatarShape';
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
interface SignupDialogProps {
|
||||
@@ -31,7 +30,6 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
about: '',
|
||||
picture: '',
|
||||
banner: '',
|
||||
shape: '',
|
||||
});
|
||||
const [cropState, setCropState] = useState<{ imageSrc: string; aspect: number; field: 'picture' | 'banner' } | null>(null);
|
||||
const pickInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -110,12 +108,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
const finishSignup = async (skipProfile = false) => {
|
||||
try {
|
||||
if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) {
|
||||
// Build the outgoing metadata, stripping empty strings and validating shape.
|
||||
const { shape, ...rest } = profileData;
|
||||
const data: Record<string, unknown> = { ...rest };
|
||||
if (shape && isValidAvatarShape(shape)) {
|
||||
data.shape = shape;
|
||||
}
|
||||
const data: Record<string, unknown> = { ...profileData };
|
||||
for (const key in data) {
|
||||
if (data[key] === '') delete data[key];
|
||||
}
|
||||
@@ -148,7 +141,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
setStep('generate');
|
||||
setNsec('');
|
||||
setShowKey(false);
|
||||
setProfileData({ name: '', about: '', picture: '', banner: '', shape: '' });
|
||||
setProfileData({ name: '', about: '', picture: '', banner: '' });
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -246,7 +239,6 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
metadata={profileData}
|
||||
onChange={(patch) => setProfileData(prev => ({ ...prev, ...patch }))}
|
||||
onPickImage={handlePickImage}
|
||||
onAvatarShape={(shape) => setProfileData(prev => ({ ...prev, shape }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useId } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PlanetButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled planet-with-ring SVG shape used as the FAB background.
|
||||
*
|
||||
* Uses `useId()` to scope mask IDs so multiple instances can coexist
|
||||
* without ID collisions.
|
||||
*/
|
||||
export function PlanetButton({ className }: PlanetButtonProps) {
|
||||
const uid = useId();
|
||||
const maskId = `${uid}-planet-body-mask`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('absolute inset-0 w-full h-full', className)}
|
||||
>
|
||||
<defs>
|
||||
{/* Mask: white = visible, black = cut out.
|
||||
The middle arc (crossing through the circle) is stroked black
|
||||
so the ring appears to pass in front there. */}
|
||||
<mask id={maskId}>
|
||||
<circle cx="12" cy="12" r="8" fill="white" />
|
||||
<path
|
||||
d="M7.06 18.24 C9.1 17.82 11.57 16.88 14.05 15.5 C16.51 14.14 18.57 12.54 19.98 11.03"
|
||||
fill="none"
|
||||
stroke="black"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
{/* Planet body with solid fill, front-arc gap cut out */}
|
||||
<circle cx="12" cy="12" r="8" fill="hsl(var(--primary))" mask={`url(#${maskId})`} />
|
||||
{/* Full ring as one continuous path */}
|
||||
<path
|
||||
d="M4.05 13 C2.35 14.8 1.55 16.5 2.25 17.5 C2.84 18.53 4.66 18.74 7.06 18.24 C9.1 17.82 11.57 16.88 14.05 15.5 C16.51 14.14 18.57 12.54 19.98 11.03 C21.66 9.22 22.4 7.54 21.75 6.5 C21.15 5.5 19.35 5.3 17.05 5.8"
|
||||
fill="none"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type AvatarShape, isEmoji, getAvatarMaskUrl, isValidAvatarShape } from "@/lib/avatarShape"
|
||||
|
||||
/**
|
||||
* Shared ref so AvatarFallback can check if a sibling AvatarImage
|
||||
@@ -10,63 +9,26 @@ import { type AvatarShape, isEmoji, getAvatarMaskUrl, isValidAvatarShape } from
|
||||
*/
|
||||
const AvatarHasSrcContext = React.createContext<React.MutableRefObject<boolean>>({ current: false })
|
||||
|
||||
/** Context so children can inherit the shape for their own styling. */
|
||||
const AvatarShapeContext = React.createContext<AvatarShape | undefined>(undefined)
|
||||
|
||||
export interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Avatar mask shape. Defaults to "circle" (the standard rounded-full). */
|
||||
shape?: AvatarShape;
|
||||
}
|
||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
||||
({ className, children, shape, style, ...props }, ref) => {
|
||||
({ className, children, ...props }, ref) => {
|
||||
const hasSrcRef = React.useRef(false)
|
||||
// Reset per render so stale values don't persist
|
||||
hasSrcRef.current = false
|
||||
|
||||
// Check if shape is valid (emoji)
|
||||
const hasValidShape = !!shape && isValidAvatarShape(shape)
|
||||
const isEmojiShape = hasValidShape && isEmoji(shape)
|
||||
const hasCustomShape = isEmojiShape
|
||||
|
||||
// Compute mask URL synchronously — getAvatarMaskUrl renders the emoji
|
||||
// to a canvas and caches the data-URL, so subsequent calls are instant.
|
||||
// This avoids a flash of the unmasked square avatar on first paint.
|
||||
const maskUrl = hasCustomShape && shape ? getAvatarMaskUrl(shape) : ''
|
||||
|
||||
const mergedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
if (maskUrl) {
|
||||
return {
|
||||
...style,
|
||||
WebkitMaskImage: `url(${maskUrl})`,
|
||||
maskImage: `url(${maskUrl})`,
|
||||
WebkitMaskSize: 'contain',
|
||||
maskSize: 'contain' as string,
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskRepeat: 'no-repeat' as string,
|
||||
WebkitMaskPosition: 'center',
|
||||
maskPosition: 'center' as string,
|
||||
}
|
||||
}
|
||||
return style ?? {}
|
||||
}, [maskUrl, style])
|
||||
|
||||
return (
|
||||
<AvatarHasSrcContext.Provider value={hasSrcRef}>
|
||||
<AvatarShapeContext.Provider value={shape}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden bg-muted",
|
||||
!hasCustomShape && "rounded-full",
|
||||
className
|
||||
)}
|
||||
style={mergedStyle}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AvatarShapeContext.Provider>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden bg-muted rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AvatarHasSrcContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -127,9 +89,6 @@ const AvatarFallback = React.forwardRef<
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const hasSrcRef = React.useContext(AvatarHasSrcContext)
|
||||
const shape = React.useContext(AvatarShapeContext)
|
||||
|
||||
const hasCustomShape = !!shape && isValidAvatarShape(shape)
|
||||
|
||||
// AvatarImage renders before AvatarFallback (DOM order), so hasSrcRef
|
||||
// is already set by the time we read it here in the same render frame.
|
||||
@@ -139,8 +98,7 @@ const AvatarFallback = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center",
|
||||
!hasCustomShape && "rounded-full",
|
||||
"flex h-full w-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useMyCommunities, type MyCommunityEntry } from '@/hooks/useMyCommunities';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
|
||||
interface ActiveConversationsWidgetProps {
|
||||
/** Number of conversations to show. */
|
||||
limit?: number;
|
||||
/** When provided, only show comments scoped to this community a-tag. */
|
||||
scopedATag?: string;
|
||||
}
|
||||
|
||||
/** A grouped conversation thread (most-recent comment in a thread). */
|
||||
interface ConversationThread {
|
||||
/** Latest comment in the thread. */
|
||||
latest: NostrEvent;
|
||||
/** Root scope a-tag (the community). */
|
||||
communityATag: string;
|
||||
/** Number of comments observed for this thread. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Returns the root event id for a thread when present. */
|
||||
function getThreadRootId(event: NostrEvent): string | undefined {
|
||||
const upperRoot = event.tags.find(([n]) => n === 'E')?.[1];
|
||||
if (upperRoot) return upperRoot;
|
||||
|
||||
const lowerRoot = event.tags.find(([n, , , marker]) => n === 'e' && marker === 'root')?.[1];
|
||||
if (lowerRoot) return lowerRoot;
|
||||
|
||||
return event.tags.find(([n]) => n === 'e')?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar widget showing recent NIP-22 comment threads scoped to the user's
|
||||
* communities (or a specific community when `scopedATag` is provided).
|
||||
*
|
||||
* Threads are deduplicated by their root `E` tag — only the latest comment
|
||||
* per thread is shown so the list reads like a "what's active" snapshot.
|
||||
*/
|
||||
export function ActiveConversationsWidget({ limit = 5, scopedATag }: ActiveConversationsWidgetProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
|
||||
|
||||
const aTags = useMemo(() => {
|
||||
if (scopedATag) return [scopedATag];
|
||||
return (myCommunities ?? []).map((c) => c.community.aTag).filter(Boolean);
|
||||
}, [myCommunities, scopedATag]);
|
||||
|
||||
const aTagsKey = aTags.join(',');
|
||||
|
||||
const { data: comments, isLoading: commentsLoading, isError: commentsError } = useQuery({
|
||||
queryKey: ['widget-active-conversations', aTagsKey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (aTags.length === 0) return [];
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
|
||||
return nostr.query(
|
||||
[{ kinds: [1111], '#A': aTags, limit: 100 }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
},
|
||||
enabled: scopedATag ? true : !communitiesLoading && aTags.length > 0,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const threads = useMemo<ConversationThread[]>(() => {
|
||||
if (!comments || comments.length === 0) return [];
|
||||
const byThread = new Map<string, ConversationThread>();
|
||||
for (const event of comments) {
|
||||
const communityATag = event.tags.find(([n]) => n === 'A')?.[1];
|
||||
if (!communityATag) continue;
|
||||
// Thread key: root tag (prefer NIP-22 `E`, fallback to lowercase `e` variants).
|
||||
const key = getThreadRootId(event) ?? event.id;
|
||||
const existing = byThread.get(key);
|
||||
if (!existing) {
|
||||
byThread.set(key, { latest: event, communityATag, count: 1 });
|
||||
} else {
|
||||
existing.count += 1;
|
||||
if (event.created_at > existing.latest.created_at) {
|
||||
existing.latest = event;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(byThread.values())
|
||||
.sort((a, b) => b.latest.created_at - a.latest.created_at)
|
||||
.slice(0, limit);
|
||||
}, [comments, limit]);
|
||||
|
||||
const isLoading = scopedATag ? commentsLoading : communitiesLoading || commentsLoading;
|
||||
|
||||
if (!scopedATag && aTags.length === 0 && !isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground p-1">
|
||||
Join a community to see active conversations.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (commentsError) {
|
||||
return <p className="text-sm text-destructive p-1">Failed to load active conversations.</p>;
|
||||
}
|
||||
|
||||
if (threads.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground p-1">No recent conversations.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{threads.map((thread) => (
|
||||
<ConversationRow
|
||||
key={thread.latest.id}
|
||||
thread={thread}
|
||||
myCommunities={myCommunities}
|
||||
showCommunityName={!scopedATag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationRowProps {
|
||||
thread: ConversationThread;
|
||||
myCommunities: MyCommunityEntry[] | undefined;
|
||||
showCommunityName: boolean;
|
||||
}
|
||||
|
||||
function ConversationRow({ thread, myCommunities, showCommunityName }: ConversationRowProps) {
|
||||
const { latest, communityATag, count } = thread;
|
||||
const author = useAuthor(latest.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name || metadata?.display_name || genUserName(latest.pubkey);
|
||||
|
||||
const community = useMemo(
|
||||
() => myCommunities?.find((c) => c.community.aTag === communityATag)?.community,
|
||||
[myCommunities, communityATag],
|
||||
);
|
||||
|
||||
const eventId = useMemo(
|
||||
() => nip19.neventEncode({ id: latest.id, author: latest.pubkey }),
|
||||
[latest],
|
||||
);
|
||||
|
||||
const snippet = useMemo(() => {
|
||||
const clean = latest.content.replace(/https?:\/\/\S+/g, '').replace(/nostr:[a-z0-9]+/g, '').trim();
|
||||
if (clean.length > 80) return clean.slice(0, 80) + '...';
|
||||
return clean || '(no preview)';
|
||||
}, [latest.content]);
|
||||
|
||||
const communityNaddr = useMemo(() => {
|
||||
const [, pubkey, dTag] = communityATag.split(':');
|
||||
if (!pubkey || !dTag) return null;
|
||||
try {
|
||||
return nip19.naddrEncode({ kind: COMMUNITY_DEFINITION_KIND, pubkey, identifier: dTag });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [communityATag]);
|
||||
|
||||
return (
|
||||
<div className="px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors">
|
||||
<Link to={`/${eventId}`} className="block">
|
||||
<div className="flex items-center gap-1.5 mb-0.5 min-w-0">
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">{displayName}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(latest.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
|
||||
</Link>
|
||||
{(showCommunityName && community) || count > 1 ? (
|
||||
<div className="flex items-center gap-2 mt-1 text-[11px] text-muted-foreground">
|
||||
{showCommunityName && community && communityNaddr && (
|
||||
<Link
|
||||
to={`/${communityNaddr}`}
|
||||
className="hover:text-foreground hover:underline truncate"
|
||||
>
|
||||
{community.name}
|
||||
</Link>
|
||||
)}
|
||||
{count > 1 && (
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<MessageSquare className="size-2.5" />
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Target } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useMyCommunities } from '@/hooks/useMyCommunities';
|
||||
import { parseGoalEvent, isGoalExpired, formatSats, ZAP_GOAL_KIND } from '@/lib/goalUtils';
|
||||
|
||||
interface CommunityFundraisingWidgetProps {
|
||||
/** Number of active goals to render. */
|
||||
limit?: number;
|
||||
/** Optional single-community scope (`34550:<pubkey>:<d>`). */
|
||||
scopedATag?: string;
|
||||
}
|
||||
|
||||
interface GoalItem {
|
||||
event: NostrEvent;
|
||||
title: string;
|
||||
amountSats: number;
|
||||
communityATag?: string;
|
||||
closedAt?: number;
|
||||
}
|
||||
|
||||
/** Community fundraising widget powered by NIP-75 goals (kind 9041). */
|
||||
export function CommunityFundraisingWidget({ limit = 5, scopedATag }: CommunityFundraisingWidgetProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
|
||||
|
||||
const aTags = useMemo(() => {
|
||||
if (scopedATag) return [scopedATag];
|
||||
return (myCommunities ?? []).map((c) => c.community.aTag);
|
||||
}, [myCommunities, scopedATag]);
|
||||
|
||||
const communityNameByATag = useMemo(() => {
|
||||
const byATag = new Map<string, string>();
|
||||
for (const entry of myCommunities ?? []) {
|
||||
byATag.set(entry.community.aTag, entry.community.name);
|
||||
}
|
||||
return byATag;
|
||||
}, [myCommunities]);
|
||||
|
||||
const { data: events, isLoading: goalsLoading, isError } = useQuery({
|
||||
queryKey: ['widget-community-fundraising', aTags.join(',')],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (aTags.length === 0) return [];
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
|
||||
return nostr.query(
|
||||
[{ kinds: [ZAP_GOAL_KIND], '#a': aTags, limit: 100 }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
},
|
||||
enabled: scopedATag ? true : !communitiesLoading && aTags.length > 0,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const goals = useMemo<GoalItem[]>(() => {
|
||||
if (!events) return [];
|
||||
const parsedGoals: GoalItem[] = [];
|
||||
for (const event of events) {
|
||||
const parsed = parseGoalEvent(event);
|
||||
if (!parsed || isGoalExpired(parsed)) continue;
|
||||
parsedGoals.push({
|
||||
event,
|
||||
title: parsed.title,
|
||||
amountSats: parsed.amountSats,
|
||||
communityATag: parsed.communityATag,
|
||||
closedAt: parsed.closedAt,
|
||||
});
|
||||
}
|
||||
return parsedGoals
|
||||
.sort((a, b) => {
|
||||
const aDeadline = a.closedAt ?? Number.MAX_SAFE_INTEGER;
|
||||
const bDeadline = b.closedAt ?? Number.MAX_SAFE_INTEGER;
|
||||
return aDeadline - bDeadline;
|
||||
})
|
||||
.slice(0, limit);
|
||||
}, [events, limit]);
|
||||
|
||||
const isLoading = scopedATag ? goalsLoading : communitiesLoading || goalsLoading;
|
||||
|
||||
if (!scopedATag && aTags.length === 0 && !isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground p-1">
|
||||
Join communities to follow fundraising goals.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-2.5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <p className="text-sm text-destructive p-1">Failed to load community goals.</p>;
|
||||
}
|
||||
|
||||
if (goals.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No active fundraising goals.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{goals.map((goal) => {
|
||||
const encoded = nip19.neventEncode({ id: goal.event.id, author: goal.event.pubkey });
|
||||
return (
|
||||
<Link
|
||||
key={goal.event.id}
|
||||
to={`/${encoded}`}
|
||||
className="block px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<p className="text-xs font-semibold truncate">{goal.title}</p>
|
||||
<p className="text-[11px] text-muted-foreground flex items-center gap-1">
|
||||
<Target className="size-3 shrink-0" />
|
||||
{formatSats(goal.amountSats)} sats
|
||||
</p>
|
||||
{!scopedATag && goal.communityATag && communityNameByATag.get(goal.communityATag) && (
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{communityNameByATag.get(goal.communityATag)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
|
||||
interface FeedWidgetProps {
|
||||
@@ -95,7 +94,6 @@ export function FeedWidget({ kinds, feedPath, feedLabel, limit = 5, emptyMessage
|
||||
function CompactEventCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
|
||||
@@ -116,7 +114,7 @@ function CompactEventCard({ event }: { event: NostrEvent }) {
|
||||
className="block hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { useSortedPosts } from '@/hooks/useTrending';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
|
||||
/** Hot posts widget for the right sidebar. */
|
||||
export function HotPostsWidget() {
|
||||
const { data: rawPosts, isLoading } = useSortedPosts('hot', 5);
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
const posts = useMemo(() => {
|
||||
if (!rawPosts || muteItems.length === 0) return rawPosts;
|
||||
return rawPosts.filter((e) => !isEventMuted(e, muteItems));
|
||||
}, [rawPosts, muteItems]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!posts || posts.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No hot posts right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{posts.slice(0, 5).map((event) => (
|
||||
<HotPostCard key={event.id} event={event} />
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/trends" className="text-xs text-primary hover:underline">View all on Trends</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact hot post card for the sidebar widget. */
|
||||
function HotPostCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
const { onClick: openPost, onAuxClick } = useOpenPost(`/${encodedId}`);
|
||||
|
||||
const snippet = useMemo(() => {
|
||||
const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
|
||||
if (clean.length > 100) return clean.slice(0, 100) + '\u2026';
|
||||
return clean || '(media)';
|
||||
}, [event.content]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={openPost}
|
||||
onAuxClick={onAuxClick}
|
||||
className="block w-full text-left hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { parseMusicTrack, toAudioTrack } from '@/lib/musicHelpers';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -67,7 +66,6 @@ function MusicCard({ event }: { event: NostrEvent }) {
|
||||
const player = useAudioPlayer();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
|
||||
const parsed = useMemo(() => parseMusicTrack(event), [event]);
|
||||
@@ -150,7 +148,7 @@ function MusicCard({ event }: { event: NostrEvent }) {
|
||||
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Crown } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useMyCommunities } from '@/hooks/useMyCommunities';
|
||||
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
|
||||
interface MyCommunitiesWidgetProps {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Sidebar widget listing communities the current user founded or joined. */
|
||||
export function MyCommunitiesWidget({ limit = 6 }: MyCommunitiesWidgetProps) {
|
||||
const { data: communities, isLoading } = useMyCommunities();
|
||||
|
||||
const items = useMemo(
|
||||
() => (communities ?? []).slice(0, limit),
|
||||
[communities, limit],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<Skeleton className="h-2.5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!communities || communities.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground p-1">
|
||||
Join communities to build your Agora network.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{items.map((entry) => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
pubkey: entry.event.pubkey,
|
||||
identifier: entry.community.dTag,
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={entry.community.aTag}
|
||||
to={`/${naddr}`}
|
||||
className="block px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<p className="text-xs font-semibold truncate flex-1">{entry.community.name}</p>
|
||||
{entry.isFounded && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-500 shrink-0">
|
||||
<Crown className="size-2.5" />
|
||||
Founder
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.community.description && (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">
|
||||
{entry.community.description}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/communities" className="text-xs text-primary hover:underline">
|
||||
View all communities
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
@@ -76,7 +75,6 @@ export function PhotoWidget() {
|
||||
function PhotoCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
|
||||
@@ -100,7 +98,7 @@ function PhotoCard({ event }: { event: NostrEvent }) {
|
||||
{/* Author + caption */}
|
||||
<div className="mt-2 px-0.5 space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useMyCommunities } from '@/hooks/useMyCommunities';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
parseCommunityEvent,
|
||||
type ParsedCommunity,
|
||||
} from '@/lib/communityUtils';
|
||||
|
||||
interface SuggestedCommunitiesWidgetProps {
|
||||
/** Number of suggestions to show. */
|
||||
limit?: number;
|
||||
/** Optional community a-tag to exclude (e.g. the one currently being viewed). */
|
||||
excludeATag?: string;
|
||||
}
|
||||
|
||||
/** Sidebar widget that surfaces communities the user hasn't joined yet. */
|
||||
export function SuggestedCommunitiesWidget({ limit = 5, excludeATag }: SuggestedCommunitiesWidgetProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { data: myCommunities } = useMyCommunities();
|
||||
|
||||
const { data: events, isLoading, isError } = useQuery({
|
||||
queryKey: ['widget-suggested-communities'],
|
||||
queryFn: async ({ signal }) => {
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
|
||||
return nostr.query(
|
||||
[{ kinds: [COMMUNITY_DEFINITION_KIND], limit: 100 }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!events) return [];
|
||||
const myATags = new Set((myCommunities ?? []).map((c) => c.community.aTag));
|
||||
if (excludeATag) myATags.add(excludeATag);
|
||||
|
||||
const latestByATag = new Map<string, { community: ParsedCommunity; createdAt: number }>();
|
||||
for (const event of events) {
|
||||
const c = parseCommunityEvent(event);
|
||||
if (!c) continue;
|
||||
if (myATags.has(c.aTag)) continue;
|
||||
const existing = latestByATag.get(c.aTag);
|
||||
if (!existing || event.created_at > existing.createdAt) {
|
||||
latestByATag.set(c.aTag, { community: c, createdAt: event.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latestByATag.values())
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
.slice(0, limit)
|
||||
.map(({ community }) => community);
|
||||
}, [events, myCommunities, excludeATag, limit]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="size-9 rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-2.5 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-sm text-destructive p-1">
|
||||
Failed to load suggested communities.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No suggestions right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{suggestions.map((c) => {
|
||||
const [, pubkey] = c.aTag.split(':');
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
pubkey,
|
||||
identifier: c.dTag,
|
||||
});
|
||||
return (
|
||||
<Link
|
||||
key={c.aTag}
|
||||
to={`/${naddr}`}
|
||||
className="flex items-start gap-2 px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<div className="size-9 rounded-lg overflow-hidden bg-primary/10 shrink-0 flex items-center justify-center">
|
||||
{c.image ? (
|
||||
<img src={c.image} alt={c.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Users className="size-4 text-primary/60" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold truncate">{c.name}</p>
|
||||
{c.description && (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">
|
||||
{c.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/communities" className="text-xs text-primary hover:underline">
|
||||
Browse all communities
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TrendSparkline } from '@/components/TrendSparkline';
|
||||
import { useTrendingTags, useTagSparklines } from '@/hooks/useTrending';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
|
||||
/** Compact trending tags widget for the right sidebar. */
|
||||
export function TrendingWidget() {
|
||||
const { data: trendingTagsResult, isLoading: tagsLoading } = useTrendingTags(true);
|
||||
|
||||
const trendingTags = trendingTagsResult?.tags;
|
||||
const labelCreatedAt = trendingTagsResult?.labelCreatedAt ?? 0;
|
||||
|
||||
const visibleTags = useMemo(() => (trendingTags ?? []).slice(0, 5).map((t) => t.tag), [trendingTags]);
|
||||
const { data: sparklineData, isLoading: sparklinesLoading } = useTagSparklines(visibleTags, labelCreatedAt, visibleTags.length > 0);
|
||||
|
||||
if (tagsLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!trendingTags || trendingTags.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No trends available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{trendingTags.slice(0, 5).map((item) => (
|
||||
<Link
|
||||
key={item.tag}
|
||||
to={`/t/${item.tag}`}
|
||||
className="flex items-center justify-between group hover:bg-secondary/40 px-2 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-sm">#{item.tag}</div>
|
||||
{item.accounts > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="text-primary font-semibold">{formatNumber(item.accounts)}</span> people talking
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sparklinesLoading ? (
|
||||
<Skeleton className="h-[35px] w-[50px] rounded" />
|
||||
) : (
|
||||
<TrendSparkline data={sparklineData?.get(item.tag) ?? []} />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/trends" className="text-xs text-primary hover:underline">View all trends</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { CalendarDays, MapPin } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface UpcomingEventsWidgetProps {
|
||||
/** Number of events to show. */
|
||||
limit?: number;
|
||||
/** When provided, only show events tagged to this community a-tag. */
|
||||
scopedATag?: string;
|
||||
}
|
||||
|
||||
interface ParsedCalendarEvent {
|
||||
/** Raw event. */
|
||||
event: NostrEvent;
|
||||
/** Title (falls back to "Untitled event"). */
|
||||
title: string;
|
||||
/** Start time in seconds. */
|
||||
startSeconds: number;
|
||||
/** Whether this is a date-only (kind 31922) vs time-based (kind 31923) event. */
|
||||
dateOnly: boolean;
|
||||
/** Optional location string. */
|
||||
location?: string;
|
||||
}
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const DATETIME_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
/** Parse a calendar event's start tag into a unix timestamp (seconds). */
|
||||
function parseStart(event: NostrEvent): { seconds: number; dateOnly: boolean } | null {
|
||||
const start = event.tags.find(([n]) => n === 'start')?.[1];
|
||||
if (!start) return null;
|
||||
|
||||
if (event.kind === 31922) {
|
||||
// Date-based: YYYY-MM-DD. Treat as local-midnight start of day.
|
||||
const dateMatch = /^\d{4}-\d{2}-\d{2}$/.test(start);
|
||||
if (!dateMatch) return null;
|
||||
const seconds = Math.floor(new Date(`${start}T00:00:00`).getTime() / 1000);
|
||||
if (!Number.isFinite(seconds)) return null;
|
||||
return { seconds, dateOnly: true };
|
||||
}
|
||||
|
||||
if (event.kind === 31923) {
|
||||
const seconds = parseInt(start, 10);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return { seconds, dateOnly: false };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Sidebar widget listing the next upcoming calendar events. */
|
||||
export function UpcomingEventsWidget({ limit = 5, scopedATag }: UpcomingEventsWidgetProps) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const { data: events, isLoading, isError } = useQuery({
|
||||
queryKey: ['widget-upcoming-events', scopedATag ?? 'global'],
|
||||
queryFn: async ({ signal }) => {
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
|
||||
return nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [31922, 31923],
|
||||
limit: 100,
|
||||
...(scopedATag ? { '#a': [scopedATag] } : {}),
|
||||
},
|
||||
],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const upcoming = useMemo<ParsedCalendarEvent[]>(() => {
|
||||
if (!events) return [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const latestByAddress = new Map<string, NostrEvent>();
|
||||
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (!dTag) continue;
|
||||
const key = `${event.kind}:${event.pubkey}:${dTag}`;
|
||||
const existing = latestByAddress.get(key);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
latestByAddress.set(key, event);
|
||||
}
|
||||
}
|
||||
|
||||
const parsedEvents: ParsedCalendarEvent[] = [];
|
||||
for (const event of latestByAddress.values()) {
|
||||
const start = parseStart(event);
|
||||
if (!start) continue;
|
||||
if (start.seconds < now) continue;
|
||||
|
||||
const title =
|
||||
event.tags.find(([n]) => n === 'title')?.[1]
|
||||
|| event.tags.find(([n]) => n === 'name')?.[1]
|
||||
|| 'Untitled event';
|
||||
const location = event.tags.find(([n]) => n === 'location')?.[1];
|
||||
parsedEvents.push({
|
||||
event,
|
||||
title,
|
||||
startSeconds: start.seconds,
|
||||
dateOnly: start.dateOnly,
|
||||
location,
|
||||
});
|
||||
}
|
||||
|
||||
return parsedEvents
|
||||
.sort((a, b) => a.startSeconds - b.startSeconds)
|
||||
.slice(0, limit);
|
||||
}, [events, limit]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Skeleton className="size-10 rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-2.5 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <p className="text-sm text-destructive p-1">Failed to load upcoming events.</p>;
|
||||
}
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No upcoming events.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{upcoming.map(({ event, title, startSeconds, dateOnly, location }) => {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag,
|
||||
});
|
||||
const startDate = new Date(startSeconds * 1000);
|
||||
const formatted = dateOnly ? DATE_FORMAT.format(startDate) : DATETIME_FORMAT.format(startDate);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={event.id}
|
||||
to={`/${naddr}`}
|
||||
className="flex items-start gap-2 px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<DateBadge date={startDate} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold truncate">{title}</p>
|
||||
<p className="text-[11px] text-muted-foreground flex items-center gap-1">
|
||||
<CalendarDays className="size-3 shrink-0" />
|
||||
<span className="truncate">{formatted}</span>
|
||||
</p>
|
||||
{location && (
|
||||
<p className="text-[11px] text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="size-3 shrink-0" />
|
||||
<span className="truncate">{location}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/events" className="text-xs text-primary hover:underline">
|
||||
View all events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MONTH_SHORT = new Intl.DateTimeFormat(undefined, { month: 'short' });
|
||||
|
||||
/** Compact two-line date badge (Month + Day). */
|
||||
function DateBadge({ date }: { date: Date }) {
|
||||
return (
|
||||
<div className="size-10 rounded-lg bg-primary/10 border border-primary/15 flex flex-col items-center justify-center shrink-0 leading-none">
|
||||
<span className="text-[9px] uppercase tracking-wide text-primary/70 font-semibold">
|
||||
{MONTH_SHORT.format(date)}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{date.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { useCallback, useMemo } from "react";
|
||||
*/
|
||||
const DEFAULT_SIDEBAR_ORDER: string[] = [
|
||||
'wallet',
|
||||
'search',
|
||||
'verified',
|
||||
'actions',
|
||||
'polls',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { isGoalExpired, parseCommunityATag, type ParsedGoal } from '@/lib/goalUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useAddrEvent } from '@/hooks/useEvent';
|
||||
@@ -21,7 +20,6 @@ export interface GoalDisplayData {
|
||||
progressIsPartial: boolean;
|
||||
metadata: NostrMetadata | undefined;
|
||||
displayName: string;
|
||||
avatarShape: string | undefined;
|
||||
profileUrl: string;
|
||||
lightningAddress: string | undefined;
|
||||
deadlineLabel: string | null;
|
||||
@@ -46,7 +44,6 @@ export function useGoalDisplay(event: NostrEvent, goal: ParsedGoal): GoalDisplay
|
||||
const author = useAuthor(goal.beneficiary);
|
||||
const metadata: NostrMetadata | undefined = author.data?.metadata;
|
||||
const displayName = metadata?.display_name || metadata?.name || genUserName(goal.beneficiary);
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const profileUrl = useProfileUrl(goal.beneficiary, metadata);
|
||||
const lightningAddress = metadata?.lud16 || metadata?.lud06 || undefined;
|
||||
|
||||
@@ -93,7 +90,6 @@ export function useGoalDisplay(event: NostrEvent, goal: ParsedGoal): GoalDisplay
|
||||
progressIsPartial,
|
||||
metadata,
|
||||
displayName,
|
||||
avatarShape,
|
||||
profileUrl,
|
||||
lightningAddress,
|
||||
deadlineLabel,
|
||||
|
||||
+17
-123
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -18,26 +17,15 @@ 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, been recruited into,
|
||||
* or bookmarked via their NIP-51 Communities list (kind 10004).
|
||||
* Fetch communities the logged-in user has founded or been recruited into.
|
||||
*
|
||||
* Discovery:
|
||||
*
|
||||
* 1. Founded -- `{ kinds: [34550], authors: [user.pubkey] }`
|
||||
* 2. Member-of -- kind 8 awards targeting the user, extract badge `a` tags,
|
||||
* 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,
|
||||
* 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();
|
||||
@@ -51,27 +39,17 @@ 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 + 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 },
|
||||
),
|
||||
]);
|
||||
// Step 2: Badge awards targeting the user
|
||||
const awards = await nostr.query(
|
||||
[{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }],
|
||||
{ signal: combinedSignal },
|
||||
);
|
||||
|
||||
// Extract badge a-tag coordinates from awards
|
||||
const badgeATags = new Set<string>();
|
||||
@@ -92,110 +70,26 @@ export function useMyCommunities() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
// Merge and deduplicate (founded takes priority)
|
||||
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,
|
||||
isMember: false,
|
||||
isBookmarked: bookmarkedATagSet.has(community.aTag),
|
||||
});
|
||||
seen.set(community.aTag, { community, event, isFounded: true });
|
||||
}
|
||||
|
||||
for (const event of memberCommunityEvents) {
|
||||
const community = parseCommunityEvent(event);
|
||||
if (!community) continue;
|
||||
if (seen.has(community.aTag)) continue;
|
||||
seen.set(community.aTag, {
|
||||
community,
|
||||
event,
|
||||
isFounded: false,
|
||||
isMember: true,
|
||||
isBookmarked: bookmarkedATagSet.has(community.aTag),
|
||||
});
|
||||
if (!seen.has(community.aTag)) {
|
||||
seen.set(community.aTag, { community, event, isFounded: false });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// Sort: founded first, then by created_at descending
|
||||
return Array.from(seen.values()).sort((a, b) => {
|
||||
const rankDiff = sortRank(a) - sortRank(b);
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
if (a.isFounded !== b.isFounded) return a.isFounded ? -1 : 1;
|
||||
return b.event.created_at - a.event.created_at;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* An avatar shape is stored in kind-0 metadata as the `shape` property.
|
||||
* Supported formats:
|
||||
* - Emoji string (e.g., "🐱", "⭐") - uses emoji glyph as mask
|
||||
*
|
||||
* When absent or invalid, avatars render as circles (the default).
|
||||
*/
|
||||
export type AvatarShape = string;
|
||||
|
||||
// ── Emoji detection ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks whether a string could be an emoji shape value.
|
||||
*
|
||||
* Rather than trying to match specific Unicode emoji patterns (which is
|
||||
* fragile and excludes valid emoji like keycap sequences, flags, and
|
||||
* complex ZWJ families), we simply check that the value is a short
|
||||
* non-ASCII string.
|
||||
*/
|
||||
export function isEmoji(value: string): boolean {
|
||||
if (!value || value.length === 0) return false;
|
||||
// Emoji are short (even complex ZWJ sequences are under ~20 JS chars)
|
||||
// and contain non-ASCII characters. Reject long strings and pure ASCII
|
||||
// to avoid treating arbitrary text as emoji.
|
||||
if (value.length > 20) return false;
|
||||
// Must contain at least one non-ASCII character
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return /[^\x00-\x7F]/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for valid avatar shape values.
|
||||
* Valid shapes are:
|
||||
* - Emoji strings (non-ASCII, short)
|
||||
*/
|
||||
export function isValidAvatarShape(value: unknown): value is AvatarShape {
|
||||
if (typeof value !== 'string' || value.length === 0) return false;
|
||||
|
||||
// Must be an emoji
|
||||
return isEmoji(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a valid AvatarShape from a metadata object (or any object with a `shape` property).
|
||||
* Accepts `NostrMetadata` directly — no type cast needed at call sites.
|
||||
* Returns `undefined` if the shape is missing or invalid (which means "circle" / default).
|
||||
*/
|
||||
export function getAvatarShape(metadata: { [key: string]: unknown } | undefined): AvatarShape | undefined {
|
||||
const raw = metadata?.shape;
|
||||
return isValidAvatarShape(raw) ? raw : undefined;
|
||||
}
|
||||
|
||||
// ── Shaped avatar border style ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CSS filter that creates a crisp, solid outline around a shaped avatar
|
||||
* (emoji), mimicking the appearance of `border-4 border-background`
|
||||
* without clipping the mask shape. Apply this to a **wrapper** around the
|
||||
* masked `<Avatar>`.
|
||||
*/
|
||||
export const shapedAvatarBorderStyle: React.CSSProperties = {
|
||||
filter:
|
||||
'drop-shadow(3px 0 0 hsl(var(--background)))' +
|
||||
' drop-shadow(-3px 0 0 hsl(var(--background)))' +
|
||||
' drop-shadow(0 3px 0 hsl(var(--background)))' +
|
||||
' drop-shadow(0 -3px 0 hsl(var(--background)))',
|
||||
};
|
||||
|
||||
/** @deprecated Use shapedAvatarBorderStyle instead */
|
||||
export const emojiAvatarBorderStyle = shapedAvatarBorderStyle;
|
||||
|
||||
// ── Emoji mask generation ──────────────────────────────────────────────────
|
||||
|
||||
/** In-memory cache: emoji string → data-URL. */
|
||||
const emojiMaskCache = new Map<string, string>();
|
||||
|
||||
// ── Unified mask URL getter ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get mask URL for emoji avatar shapes.
|
||||
* Returns empty string if shape is invalid or mask generation fails.
|
||||
*/
|
||||
export function getAvatarMaskUrl(shape: string): string {
|
||||
if (isEmoji(shape)) {
|
||||
return getEmojiMaskUrl(shape);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of getAvatarMaskUrl.
|
||||
* For emoji, this is equivalent to the sync version.
|
||||
*/
|
||||
export async function getAvatarMaskUrlAsync(shape: string): Promise<string> {
|
||||
if (isEmoji(shape)) {
|
||||
return getEmojiMaskUrl(shape);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the user's native OS emoji onto a canvas and produces a PNG
|
||||
* data-URL alpha mask suitable for use as a CSS `mask-image`.
|
||||
*
|
||||
* ### Algorithm
|
||||
*
|
||||
* 1. **Draw large.** Render the emoji at 512 px via `fillText` on an
|
||||
* oversized (768 × 768) scratch canvas so the entire glyph is captured
|
||||
* even if the OS renders it off-centre or larger than the em-box.
|
||||
*
|
||||
* 2. **Measure.** Scan every pixel to find the tight axis-aligned bounding
|
||||
* box of non-transparent pixels.
|
||||
*
|
||||
* 3. **Square the crop.** Expand the shorter axis of the bounding box so the
|
||||
* crop region is square (centred). This prevents non-square emoji from
|
||||
* being stretched when applied to a square avatar.
|
||||
*
|
||||
* 4. **Redraw.** Draw the squared crop onto a 256 × 256 output canvas so the
|
||||
* emoji fills it edge-to-edge.
|
||||
*
|
||||
* 5. **Convert to alpha mask.** Set every pixel to white; keep the original
|
||||
* alpha channel. Export as PNG data-URL.
|
||||
*
|
||||
* If `mask-image` is unsupported the avatar renders as a plain square
|
||||
* (the emoji mask is simply ignored by the browser).
|
||||
*/
|
||||
export function getEmojiMaskUrl(emoji: string): string {
|
||||
const cached = emojiMaskCache.get(emoji);
|
||||
if (cached) return cached;
|
||||
|
||||
// ── Pass 1: draw emoji on oversized scratch canvas ──────────────────
|
||||
const fontSize = 512;
|
||||
const scratch = fontSize * 1.5; // 768 – generous room
|
||||
const c1 = document.createElement('canvas');
|
||||
c1.width = scratch;
|
||||
c1.height = scratch;
|
||||
const ctx1 = c1.getContext('2d');
|
||||
if (!ctx1) return '';
|
||||
|
||||
ctx1.textAlign = 'center';
|
||||
ctx1.textBaseline = 'middle';
|
||||
ctx1.font = `${fontSize}px serif`;
|
||||
ctx1.fillText(emoji, scratch / 2, scratch / 2);
|
||||
|
||||
// ── Pass 2: find tight bounding box ─────────────────────────────────
|
||||
// Use an alpha threshold to ignore semi-transparent shadows, glows, and
|
||||
// anti-aliasing fringes that many emoji renderers add. Without this,
|
||||
// faint pixels (e.g. a drop shadow) inflate the bounding box and push
|
||||
// the actual emoji shape off-centre when the crop is squared.
|
||||
const ALPHA_THRESHOLD = 25; // ~10% opacity
|
||||
const { data: px, width: sw, height: sh } = ctx1.getImageData(0, 0, scratch, scratch);
|
||||
let t = sh, b = 0, l = sw, r = 0;
|
||||
for (let y = 0; y < sh; y++) {
|
||||
for (let x = 0; x < sw; x++) {
|
||||
if (px[(y * sw + x) * 4 + 3] > ALPHA_THRESHOLD) {
|
||||
if (y < t) t = y;
|
||||
if (y > b) b = y;
|
||||
if (x < l) l = x;
|
||||
if (x > r) r = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r < l || b < t) return ''; // nothing drawn
|
||||
|
||||
// ── Pass 3: square the bounding box ─────────────────────────────────
|
||||
let cropW = r - l + 1;
|
||||
let cropH = b - t + 1;
|
||||
if (cropW > cropH) {
|
||||
const diff = cropW - cropH;
|
||||
t -= Math.floor(diff / 2);
|
||||
b = t + cropW - 1;
|
||||
cropH = cropW;
|
||||
} else if (cropH > cropW) {
|
||||
const diff = cropH - cropW;
|
||||
l -= Math.floor(diff / 2);
|
||||
r = l + cropH - 1;
|
||||
cropW = cropH;
|
||||
}
|
||||
// Clamp to canvas bounds (shouldn't be needed with oversized scratch,
|
||||
// but be safe).
|
||||
if (t < 0) t = 0;
|
||||
if (l < 0) l = 0;
|
||||
|
||||
// ── Pass 4: redraw cropped region onto output canvas ────────────────
|
||||
const out = 256;
|
||||
const c2 = document.createElement('canvas');
|
||||
c2.width = out;
|
||||
c2.height = out;
|
||||
const ctx2 = c2.getContext('2d');
|
||||
if (!ctx2) return '';
|
||||
|
||||
ctx2.drawImage(c1, l, t, cropW, cropH, 0, 0, out, out);
|
||||
|
||||
// ── Pass 5: convert to alpha mask (white + original alpha) ──────────
|
||||
const img = ctx2.getImageData(0, 0, out, out);
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = 255; // R
|
||||
d[i + 1] = 255; // G
|
||||
d[i + 2] = 255; // B
|
||||
// d[i+3] (alpha) kept as-is
|
||||
}
|
||||
ctx2.putImageData(img, 0, 0);
|
||||
|
||||
const url = c2.toDataURL('image/png');
|
||||
emojiMaskCache.set(emoji, url);
|
||||
return url;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
Flame,
|
||||
SmilePlus,
|
||||
Bot,
|
||||
Camera,
|
||||
@@ -44,28 +42,6 @@ export interface WidgetDefinition {
|
||||
/** All available widget definitions. */
|
||||
export const WIDGET_DEFINITIONS: WidgetDefinition[] = [
|
||||
// Discovery
|
||||
{
|
||||
id: 'trends',
|
||||
label: 'Trending',
|
||||
description: 'Top trending hashtags with sparkline charts',
|
||||
icon: TrendingUp,
|
||||
defaultHeight: 320,
|
||||
minHeight: 200,
|
||||
maxHeight: 600,
|
||||
category: 'discovery',
|
||||
href: '/trends',
|
||||
},
|
||||
{
|
||||
id: 'hot-posts',
|
||||
label: 'Hot Posts',
|
||||
description: 'Top posts from the Hot feed',
|
||||
icon: Flame,
|
||||
defaultHeight: 350,
|
||||
minHeight: 200,
|
||||
maxHeight: 600,
|
||||
category: 'discovery',
|
||||
href: '/trends',
|
||||
},
|
||||
{
|
||||
id: 'bluesky',
|
||||
label: 'Bluesky',
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Loader2, Search, Users } from 'lucide-react';
|
||||
import { Loader2, 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 { CommunityRightSidebar } from '@/components/CommunityRightSidebar';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
|
||||
@@ -16,7 +15,6 @@ 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';
|
||||
@@ -81,20 +79,17 @@ export function CommunitiesPage() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
useLayoutOptions({
|
||||
hasSubHeader: !!user,
|
||||
rightSidebar: <CommunityRightSidebar />,
|
||||
});
|
||||
|
||||
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',
|
||||
@@ -138,8 +133,6 @@ export function CommunitiesPage() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateCommunityDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -186,25 +179,7 @@ function MyCommunitiesContent() {
|
||||
|
||||
if (!myCommunities || myCommunities.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
<FeedEmptyState message="You haven't founded or joined any communities yet." />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,8 +190,6 @@ function MyCommunitiesContent() {
|
||||
key={entry.community.aTag}
|
||||
event={entry.event}
|
||||
isFounded={entry.isFounded}
|
||||
isMember={entry.isMember}
|
||||
isBookmarked={entry.isBookmarked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -323,25 +296,7 @@ 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." />
|
||||
) : (
|
||||
<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>
|
||||
<FeedEmptyState message="No activity from your communities yet." />
|
||||
)}
|
||||
{!isLoading && hasNextPage && (
|
||||
<div ref={scrollRef} className="py-4 flex justify-center">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
import { useSeoMeta } from "@unhead/react";
|
||||
import { CalendarDays, Loader2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CreateCommunityEventDialog } from "@/components/CreateCommunityEventDialog";
|
||||
import { useMemo } from "react";
|
||||
import { FeedEmptyState } from "@/components/FeedEmptyState";
|
||||
import { KindInfoButton } from "@/components/KindInfoButton";
|
||||
import { NoteCard } from "@/components/NoteCard";
|
||||
@@ -38,7 +37,6 @@ export function EventsFeedPage() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const { muteItems } = useMuteList();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useFeedTab<FeedTab>("events", [
|
||||
"follows",
|
||||
@@ -46,12 +44,7 @@ export function EventsFeedPage() {
|
||||
]);
|
||||
|
||||
useSeoMeta({ title: `Events | ${config.appName}` });
|
||||
useLayoutOptions({
|
||||
showFAB: true,
|
||||
onFabClick: () => setCreateOpen(true),
|
||||
fabIcon: <CalendarDays className="size-5" />,
|
||||
hasSubHeader: !!user,
|
||||
});
|
||||
useLayoutOptions({ showFAB: true, fabKind: 31923, hasSubHeader: !!user });
|
||||
|
||||
// Calendar events feed
|
||||
const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
|
||||
@@ -167,8 +160,6 @@ export function EventsFeedPage() {
|
||||
/>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
|
||||
<CreateCommunityEventDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
@@ -538,7 +537,6 @@ function BookContentTabs({ isbn, commentRoot, orderedReplies, commentsLoading }:
|
||||
function BookReviewCard({ event, review }: { event: NostrEvent; review: BookReview }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const [showSpoiler, setShowSpoiler] = useState(false);
|
||||
@@ -555,7 +553,7 @@ function BookReviewCard({ event, review }: { event: NostrEvent; review: BookRevi
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-10">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
+14
-31
@@ -9,8 +9,6 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -180,33 +178,19 @@ function FollowView({ pubkey }: { pubkey: string }) {
|
||||
<div className="bg-background/85">
|
||||
<div className="flex flex-col items-center px-4 -mt-12 md:-mt-16 relative z-10 max-w-2xl mx-auto w-full" style={{ paddingBottom: ARC_OVERHANG_PX + 16 }}>
|
||||
{/* Avatar — matches ProfilePage border treatment */}
|
||||
{(() => {
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const isEmojiShape = !!avatarShape && isEmoji(avatarShape);
|
||||
return (
|
||||
<div className="relative">
|
||||
<div style={isEmojiShape ? emojiAvatarBorderStyle : undefined}>
|
||||
<Avatar
|
||||
shape={avatarShape}
|
||||
className={cn(
|
||||
isEmojiShape ? 'size-[88px] md:size-[120px]' : 'size-24 md:size-32 border-4 border-background',
|
||||
'shadow-lg',
|
||||
)}
|
||||
>
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-2xl md:text-3xl">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
{(followDone || isAlreadyFollowing) && (
|
||||
<div className="absolute -bottom-1 -right-1 bg-background rounded-full p-0.5 shadow">
|
||||
<CheckCircle2 className="size-6 text-primary fill-primary/20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Avatar className="size-24 md:size-32 border-4 border-background shadow-lg">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-2xl md:text-3xl">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{(followDone || isAlreadyFollowing) && (
|
||||
<div className="absolute -bottom-1 -right-1 bg-background rounded-full p-0.5 shadow">
|
||||
<CheckCircle2 className="size-6 text-primary fill-primary/20" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + NIP-05 */}
|
||||
<div className="mt-3 text-center">
|
||||
@@ -430,9 +414,8 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[]
|
||||
{pubkeys.slice(0, 5).map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const name = member?.metadata?.name || genUserName(pk);
|
||||
const shape = getAvatarShape(member?.metadata);
|
||||
return (
|
||||
<Avatar key={pk} shape={shape} className="size-12 border-2 border-background shadow-md">
|
||||
<Avatar key={pk} className="size-12 border-2 border-background shadow-md">
|
||||
<AvatarImage src={member?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{name[0]?.toUpperCase()}
|
||||
@@ -452,7 +435,7 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[]
|
||||
|
||||
{/* Author attribution */}
|
||||
<Link to={`/${nip19.npubEncode(addr.pubkey)}`} className="flex items-center gap-1.5 mt-1.5 hover:underline">
|
||||
<Avatar shape={getAvatarShape(authorMeta)} className="size-5">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={authorMeta?.picture} alt={authorName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{authorName[0]?.toUpperCase()}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
@@ -72,7 +71,6 @@ function MemberCard({ pubkey, isOwner, listId, onRemoved }: {
|
||||
}) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
const { data: followData } = useFollowList();
|
||||
@@ -113,7 +111,7 @@ function MemberCard({ pubkey, isOwner, listId, onRemoved }: {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -369,7 +367,6 @@ export function ListDetailPage() {
|
||||
const listAuthor = useAuthor(decoded?.pubkey ?? '');
|
||||
const listAuthorMetadata = listAuthor.data?.metadata;
|
||||
const listAuthorName = listAuthorMetadata?.name || listAuthorMetadata?.display_name || (decoded ? genUserName(decoded.pubkey) : '');
|
||||
const listAuthorAvatarShape = getAvatarShape(listAuthorMetadata);
|
||||
const listAuthorProfileUrl = useProfileUrl(decoded?.pubkey ?? '', listAuthorMetadata);
|
||||
|
||||
// Fetch preview avatars for the member stack
|
||||
@@ -484,7 +481,7 @@ export function ListDetailPage() {
|
||||
<h1 className="text-lg font-bold truncate">{list.title}</h1>
|
||||
{decoded && (
|
||||
<Link to={listAuthorProfileUrl} className="flex items-center gap-1.5 mt-0.5 group">
|
||||
<Avatar shape={listAuthorAvatarShape} className="size-4">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={listAuthorMetadata?.picture} alt={listAuthorName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{listAuthorName[0]?.toUpperCase()}
|
||||
@@ -585,9 +582,8 @@ export function ListDetailPage() {
|
||||
{previewPubkeys.map((pk) => {
|
||||
const member = previewMembersMap?.get(pk);
|
||||
const name = member?.metadata?.name || genUserName(pk);
|
||||
const shape = getAvatarShape(member?.metadata);
|
||||
return (
|
||||
<Avatar key={pk} shape={shape} className="size-7 ring-2 ring-background">
|
||||
<Avatar key={pk} className="size-7 ring-2 ring-background">
|
||||
<AvatarImage src={member?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { isReplyEvent } from '@/lib/nostrEvents';
|
||||
import { getAvatarShape, emojiAvatarBorderStyle } from '@/lib/avatarShape';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -368,18 +367,11 @@ function ActorAvatar({ pubkey }: { pubkey: string }) {
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
const shape = getAvatarShape(metadata);
|
||||
const isEmojiShape = !!shape;
|
||||
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
title={name}
|
||||
className="shrink-0"
|
||||
style={isEmojiShape ? emojiAvatarBorderStyle : undefined}
|
||||
>
|
||||
<Avatar className={cn("size-7", !isEmojiShape && "ring-2 ring-background")} shape={shape}>
|
||||
<Link to={profileUrl} title={name} className="shrink-0">
|
||||
<Avatar className="size-7 ring-2 ring-background">
|
||||
{metadata?.picture && <AvatarImage src={metadata.picture} alt={name} />}
|
||||
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -70,7 +70,6 @@ import { ReplyComposeModal } from "@/components/ReplyComposeModal";
|
||||
import { RepostMenu } from "@/components/RepostMenu";
|
||||
import { ThreadedReplyList, FlatThreadedReplyList, type ReplyNode } from "@/components/ThreadedReplyList";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -527,7 +526,6 @@ function CopyableHex({ value }: { value: string }) {
|
||||
function AuthorHintRow({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
@@ -544,7 +542,7 @@ function AuthorHintRow({ pubkey }: { pubkey: string }) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar shape={avatarShape} className="size-6 shrink-0">
|
||||
<Avatar className="size-6 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -943,7 +941,6 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
const queryClient = useQueryClient();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
|
||||
// Refetch the author's profile whenever we navigate to a post by this author.
|
||||
@@ -957,7 +954,6 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
const zapSenderPubkeyRaw = useMemo(() => event.kind === 9735 ? extractZapSender(event) : '', [event]);
|
||||
const zapSenderAuthor = useAuthor(zapSenderPubkeyRaw || undefined);
|
||||
const zapSenderMeta = zapSenderAuthor.data?.metadata;
|
||||
const zapSenderShape = getAvatarShape(zapSenderMeta);
|
||||
const zapSenderDisplayName = getDisplayName(zapSenderMeta, zapSenderPubkeyRaw);
|
||||
const zapSenderProfileUrl = useProfileUrl(zapSenderPubkeyRaw, zapSenderMeta);
|
||||
|
||||
@@ -1479,7 +1475,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
<>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage
|
||||
src={metadata?.picture}
|
||||
alt={displayName}
|
||||
@@ -1597,7 +1593,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
<>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage
|
||||
src={metadata?.picture}
|
||||
alt={displayName}
|
||||
@@ -1719,7 +1715,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
{zapSenderPubkeyRaw && (
|
||||
<ProfileHoverCard pubkey={zapSenderPubkeyRaw} asChild>
|
||||
<Link to={zapSenderProfileUrl} className="shrink-0">
|
||||
<Avatar shape={zapSenderShape} className="size-6">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={zapSenderMeta?.picture} alt={zapSenderDisplayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{zapSenderDisplayName[0]?.toUpperCase()}
|
||||
@@ -1960,7 +1956,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
icon={
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-10">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -2026,7 +2022,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
<>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<Avatar className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { nip19 } from 'nostr-tools';
|
||||
import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Pin, X, QrCode, Check, Copy, Loader2, Download, Pencil, Trash2, RotateCcw, MessageSquare, Globe, Mail, Plus, GripVertical, ListPlus, Award, PanelLeft } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
@@ -392,7 +391,6 @@ function MenuRow({ icon, label, onClick, destructive }: { icon: React.ReactNode;
|
||||
function FollowingUserRow({ pubkey, onNavigate }: { pubkey: string; onNavigate?: () => void }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(pubkey);
|
||||
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
|
||||
@@ -412,7 +410,7 @@ function FollowingUserRow({ pubkey, onNavigate }: { pubkey: string; onNavigate?:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
@@ -1271,8 +1269,6 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
// Kind 0 — resolved from the author cache (seeded by the feed query above).
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const isEmojiShape = !!avatarShape && isEmoji(avatarShape);
|
||||
const profileStatus = useUserStatus(pubkey);
|
||||
|
||||
// Refetch the author's profile whenever we navigate to this profile page.
|
||||
@@ -1695,14 +1691,12 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
onClick={() => metadata?.picture && setLightboxImage(metadata.picture)}
|
||||
disabled={!metadata?.picture}
|
||||
>
|
||||
<div style={isEmojiShape ? emojiAvatarBorderStyle : undefined}>
|
||||
<Avatar shape={avatarShape} className={cn(isEmojiShape ? 'size-[88px] md:size-[120px]' : 'size-24 md:size-32 border-4 border-background', metadata?.picture && 'cursor-pointer')}>
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-2xl md:text-3xl">
|
||||
{displayName[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<Avatar className={cn('size-24 md:size-32 border-4 border-background', metadata?.picture && 'cursor-pointer')}>
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-2xl md:text-3xl">
|
||||
{displayName[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
|
||||
{/* NIP-38 thought bubble — floats beside the avatar over the banner */}
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { isValidAvatarShape } from '@/lib/avatarShape';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -264,7 +263,6 @@ const formSchema = n.metadata().extend({
|
||||
/** Client-side only — placeholder text for the value input (not persisted). */
|
||||
placeholder: z.string().optional(),
|
||||
})).optional(),
|
||||
shape: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -459,21 +457,11 @@ export function ProfileSettings() {
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseShape = (): string => {
|
||||
if (!event) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(event.content);
|
||||
if (isValidAvatarShape(parsed.shape)) return parsed.shape;
|
||||
} catch { /* ignore */ }
|
||||
return '';
|
||||
};
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '', about: '', picture: '', banner: '',
|
||||
website: '', nip05: '', lud16: '', bot: false, fields: [],
|
||||
shape: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -531,7 +519,6 @@ export function ProfileSettings() {
|
||||
lud16: metadata.lud16 ?? '',
|
||||
bot: metadata.bot ?? false,
|
||||
fields: parseFields(),
|
||||
shape: parseShape(),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -539,7 +526,7 @@ export function ProfileSettings() {
|
||||
|
||||
// Live values for the card preview
|
||||
const watched = form.watch();
|
||||
const cardMetadata: Partial<NostrMetadata> & { shape?: string } = {
|
||||
const cardMetadata: Partial<NostrMetadata> = {
|
||||
name: watched.name,
|
||||
about: watched.about,
|
||||
picture: watched.picture,
|
||||
@@ -548,7 +535,6 @@ export function ProfileSettings() {
|
||||
nip05: watched.nip05,
|
||||
lud16: watched.lud16,
|
||||
bot: watched.bot,
|
||||
shape: watched.shape,
|
||||
};
|
||||
|
||||
// Live sidebar preview fields — computed from watched form values
|
||||
@@ -632,15 +618,11 @@ export function ProfileSettings() {
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { fields: customFields, shape, ...standardMetadata } = values;
|
||||
const { fields: customFields, ...standardMetadata } = values;
|
||||
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
|
||||
|
||||
// Add shape only if set (an emoji string)
|
||||
if (shape && isValidAvatarShape(shape)) {
|
||||
data.shape = shape;
|
||||
} else {
|
||||
delete data.shape;
|
||||
}
|
||||
// Strip any legacy avatar shape from old Ditto-style profiles
|
||||
delete data.shape;
|
||||
|
||||
for (const key in data) {
|
||||
if (data[key] === '') delete data[key];
|
||||
@@ -735,7 +717,6 @@ export function ProfileSettings() {
|
||||
metadata={cardMetadata}
|
||||
onChange={handleCardChange}
|
||||
onPickImage={handlePickImage}
|
||||
onAvatarShape={(shape) => form.setValue('shape', shape, { shouldDirty: true })}
|
||||
onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })}
|
||||
/>
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
@@ -62,7 +61,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) : 'communities';
|
||||
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts';
|
||||
}
|
||||
|
||||
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
|
||||
@@ -199,7 +198,7 @@ export function SearchPage() {
|
||||
const setActiveTab = useCallback((tab: TabType) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (tab === 'communities') {
|
||||
if (tab === 'posts') {
|
||||
next.delete('tab');
|
||||
} else {
|
||||
next.set('tab', tab);
|
||||
@@ -867,7 +866,6 @@ function AccountItem({ profile, isFollowed }: { profile: { pubkey: string; metad
|
||||
const npub = useMemo(() => nip19.npubEncode(profile.pubkey), [profile.pubkey]);
|
||||
const metadata = profile.metadata as { name?: string; nip05?: string; picture?: string; about?: string; bot?: boolean };
|
||||
const displayName = metadata?.name || genUserName(profile.pubkey);
|
||||
const profileAvatarShape = getAvatarShape(metadata);
|
||||
const tags = profile.event?.tags ?? [];
|
||||
|
||||
return (
|
||||
@@ -876,7 +874,7 @@ function AccountItem({ profile, isFollowed }: { profile: { pubkey: string; metad
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar shape={profileAvatarShape} className="size-11">
|
||||
<Avatar className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
@@ -951,7 +949,6 @@ function FollowsList() {
|
||||
function FollowItem({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
const displayName = metadata?.name || genUserName(pubkey);
|
||||
const tags = author.data?.event?.tags ?? [];
|
||||
@@ -966,7 +963,7 @@ function FollowItem({ pubkey }: { pubkey: string }) {
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<Avatar className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
@@ -1208,3 +1205,4 @@ function SaveDestinationRow({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -193,7 +192,6 @@ function StreamCard({ event }: { event: NostrEvent }) {
|
||||
function StreamCardAuthor({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
@@ -203,7 +201,7 @@ function StreamCardAuthor({ pubkey }: { pubkey: string }) {
|
||||
|
||||
return (
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-9">
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Check, X,
|
||||
} from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -41,10 +40,9 @@ import type { UserList } from '@/hooks/useUserLists';
|
||||
function MiniAvatar({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
return (
|
||||
<Avatar shape={avatarShape} className="size-7 border-2 border-background shrink-0">
|
||||
<Avatar className="size-7 border-2 border-background shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -44,7 +44,6 @@ import { useOpenPost } from "@/hooks/useOpenPost";
|
||||
import { useProfileUrl } from "@/hooks/useProfileUrl";
|
||||
import { usePageRefresh } from "@/hooks/usePageRefresh";
|
||||
import { useInfiniteHotFeed } from "@/hooks/useTrending";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
import { getExtraKindDef } from "@/lib/extraKinds";
|
||||
import type { FeedItem } from "@/lib/feedUtils";
|
||||
import { getDisplayName } from "@/lib/getDisplayName";
|
||||
@@ -229,7 +228,6 @@ function VideoGridCard({ event }: { event: NostrEvent }) {
|
||||
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
@@ -288,7 +286,7 @@ function VideoGridCard({ event }: { event: NostrEvent }) {
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="size-8 rounded-full" />
|
||||
) : (
|
||||
<Avatar shape={avatarShape} className="size-8">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
@@ -40,7 +40,6 @@ import { useStreamKind } from "@/hooks/useStreamKind";
|
||||
import { type EventStats, useEventStats } from "@/hooks/useTrending";
|
||||
import { useUserReaction } from "@/hooks/useUserReaction";
|
||||
import { DITTO_RELAY } from "@/lib/appRelays";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
import { canZap } from "@/lib/canZap";
|
||||
import { EXTRA_KINDS } from "@/lib/extraKinds";
|
||||
import { getRepostKind } from "@/lib/feedUtils";
|
||||
@@ -334,7 +333,6 @@ export function VineCard({
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
@@ -555,10 +553,7 @@ export function VineCard({
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
) : (
|
||||
<Avatar
|
||||
shape={avatarShape}
|
||||
className="size-11 border-2 border-white shadow-lg"
|
||||
>
|
||||
<Avatar className="size-11 border-2 border-white shadow-lg">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/80 text-white text-sm font-bold">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
|
||||
Reference in New Issue
Block a user