Compare commits

...

9 Commits

Author SHA1 Message Date
sam 2e144832b0 change for more relevant widgets 2026-05-12 18:35:52 +07:00
sam 32bf4bdab4 pull in more events by default 2026-05-12 17:27:07 +07:00
sam a5adbf2fed dedupe and stale data issues 2026-05-12 17:01:00 +07:00
sam 8120162960 handle edge cases 2026-05-12 16:52:36 +07:00
sam 94a26d3da1 replace sidebar widgets in communities with more relevant things 2026-05-12 14:56:16 +07:00
Sam Thomson d939934b7b Merge branch 'ui/gut-shapes' into 'main'
ui/gut-shapes

See merge request soapbox-pub/agora-3!20
2026-05-11 07:51:03 +00:00
sam e12716722a remove shape stuff 2026-05-11 14:49:11 +07:00
sam aa96c0089c blobbi-- 2026-05-08 11:59:00 +07:00
Sam Thomson da4116a1d1 Merge branch 'feat/fundraising' into 'main'
Add NIP-75 community fundraising goals

Closes #9

See merge request soapbox-pub/agora-3!12
2026-05-08 04:48:37 +00:00
87 changed files with 1116 additions and 1170 deletions
+1 -4
View File
@@ -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 -2
View File
@@ -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 -5
View File
@@ -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}
+2 -5
View File
@@ -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()}
+1 -3
View File
@@ -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 -3
View File
@@ -18,7 +18,6 @@ import {
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';
@@ -122,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';
@@ -130,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()}
+1 -2
View File
@@ -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,
};
+1 -3
View File
@@ -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 -3
View File
@@ -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';
@@ -30,7 +29,6 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
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);
@@ -97,7 +95,7 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
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 -3
View File
@@ -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 { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
@@ -52,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);
@@ -139,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()}
+12 -3
View File
@@ -14,11 +14,11 @@ import {
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 { 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';
@@ -47,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';
@@ -56,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()}
@@ -304,6 +303,16 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
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(
+139
View File
@@ -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>
);
}
+1 -3
View File
@@ -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() ?? '?'}
+1 -3
View File
@@ -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() ?? '?'}
+4 -24
View File
@@ -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 -3
View File
@@ -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()}
+1 -3
View File
@@ -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()}
+1 -3
View File
@@ -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()}
+2 -5
View File
@@ -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()}
+2 -5
View File
@@ -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()}
+1 -3
View File
@@ -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" />
+2 -29
View File
@@ -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>
-2
View File
@@ -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>
+2 -4
View File
@@ -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 -3
View File
@@ -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()}
+2 -5
View File
@@ -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()}
+1 -2
View File
@@ -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()}
+1 -1
View File
@@ -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()}
-3
View File
@@ -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>
+3 -12
View File
@@ -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()}
+4 -9
View File
@@ -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()}
+3 -5
View File
@@ -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>
+1 -3
View File
@@ -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()}
+2 -5
View File
@@ -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()}
+1 -3
View File
@@ -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>
+1 -2
View File
@@ -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() || '?'}
+1 -2
View File
@@ -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 -4
View File
@@ -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 -4
View File
@@ -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() || '?'}
+2 -6
View File
@@ -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>
+1 -4
View File
@@ -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()}
+7 -11
View File
@@ -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>
-2
View File
@@ -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)}
/>
);
}
+1 -3
View File
@@ -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';
@@ -392,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);
@@ -506,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()}
+1 -3
View File
@@ -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()}
+2 -6
View File
@@ -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>
+2 -5
View File
@@ -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
View File
@@ -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}
+1 -3
View File
@@ -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()}
+1 -4
View File
@@ -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}
+3 -4
View File
@@ -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 -4
View File
@@ -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}
+2 -5
View File
@@ -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()}
+1 -3
View File
@@ -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()}
-3
View File
@@ -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}
/>
);
-100
View File
@@ -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>
);
}
+2 -41
View File
@@ -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>
);
}
+2 -3
View File
@@ -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>
+2 -10
View File
@@ -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>
-52
View File
@@ -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>
);
}
+13 -55
View File
@@ -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">&middot; {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>
);
}
+1 -3
View File
@@ -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()}
-99
View File
@@ -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">&middot; {timeAgo(event.created_at)}</span>
</div>
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
</button>
);
}
+1 -3
View File
@@ -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>
);
}
+1 -3
View File
@@ -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>
);
}
-66
View File
@@ -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>
);
}
-4
View File
@@ -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,
-212
View File
@@ -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;
}
-24
View File
@@ -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',
+2
View File
@@ -6,6 +6,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useInView } from 'react-intersection-observer';
import { CommunityCard } from '@/components/CommunityCard';
import { CommunityRightSidebar } from '@/components/CommunityRightSidebar';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { LoginArea } from '@/components/auth/LoginArea';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
@@ -81,6 +82,7 @@ export function CommunitiesPage() {
useLayoutOptions({
hasSubHeader: !!user,
rightSidebar: <CommunityRightSidebar />,
});
const [activeTab, setActiveTab] = useFeedTab<CommunitiesTab>('communities', [
+1 -3
View File
@@ -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
View File
@@ -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()}
+3 -7
View File
@@ -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()}
+2 -10
View File
@@ -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>
+6 -10
View File
@@ -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()}
+7 -13
View File
@@ -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 */}
+4 -23
View File
@@ -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 })}
/>
+2 -5
View File
@@ -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';
@@ -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() || '?'}
+1 -3
View File
@@ -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()}
+1 -3
View File
@@ -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()}
+1 -3
View File
@@ -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()}
+1 -6
View File
@@ -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()}