Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/components/BottomNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUnreadNotificationCount } from '@/hooks/useNotifications';
import { useSubdomainNavigate } from '@/hooks/useSubdomainNavigate';
import { buildProfileLinkPath } from '@/lib/profileLinks';
import { cn } from '@/lib/utils';
import { nip19 } from 'nostr-tools';

export function BottomNav() {
const navigate = useSubdomainNavigate();
Expand All @@ -17,8 +17,10 @@ export function BottomNav() {

const getUserProfilePath = () => {
if (!user?.pubkey) return '/';
const npub = nip19.npubEncode(user.pubkey);
return `/profile/${npub}`;
return buildProfileLinkPath({
pubkey: user.pubkey,
fallbackRoute: 'profile',
});
};

const isActive = (path: string) => location.pathname === path;
Expand Down
11 changes: 2 additions & 9 deletions src/components/ClassicVinersRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Star } from '@phosphor-icons/react';
import { CLASSIC_VINERS, CLASSIC_VINER_AVATARS, type StaticViner } from '@/data/classicViners';
import { getSafeProfileImage } from '@/lib/imageUtils';
import { nip19 } from 'nostr-tools';
import { buildProfileLinkPath } from '@/lib/profileLinks';

/**
* Single viner avatar item
Expand All @@ -17,14 +17,7 @@ function VinerItem({ viner }: { viner: StaticViner }) {
const displayName = viner.name;
const picture = getSafeProfileImage(viner.picture) || '/user-avatar.png';

// Use npub for URL
let profilePath = `/profile/${viner.pubkey}`;
try {
const npub = nip19.npubEncode(viner.pubkey);
profilePath = `/${npub}`;
} catch {
// Fall back to hex pubkey
}
const profilePath = buildProfileLinkPath({ pubkey: viner.pubkey });

return (
<SmartLink
Expand Down
8 changes: 5 additions & 3 deletions src/components/FullscreenVideoItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Heart, ChatCircle as MessageCircle, Repeat as Repeat2, Share, SpeakerHigh as Volume2, SpeakerX as VolumeX, DownloadSimple as Download, ListPlus, Users, DotsThreeVertical as MoreVertical, Flag, UserMinus as UserX, Code, Trash as Trash2, Eye, ClosedCaptioning as Captions } from '@phosphor-icons/react';
import { nip19 } from 'nostr-tools';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
Expand Down Expand Up @@ -42,6 +41,7 @@ import { useSubtitles } from '@/hooks/useSubtitles';
import { VideoVerificationBadgeRow } from '@/components/VideoVerificationBadgeRow';
import type { ParsedVideoData } from '@/types/video';
import { buildDmSharePayloadFromVideo, buildDmShareQueryString } from '@/lib/dm';
import { buildProfileLinkPath } from '@/lib/profileLinks';

interface FullscreenVideoItemProps {
video: ParsedVideoData;
Expand Down Expand Up @@ -134,7 +134,6 @@ export function FullscreenVideoItem({
const badgesQuery = useBadges(video.pubkey);
const metadata = author.metadata;

const npub = nip19.npubEncode(video.pubkey);
// Use raw author data to detect real vs generated names
const rawMetadata = authorData.data?.metadata;
const hasRealName = rawMetadata?.display_name || rawMetadata?.name;
Expand All @@ -144,7 +143,10 @@ export function FullscreenVideoItem({
const profileImage = getSafeProfileImage(
rawMetadata?.picture || video.authorAvatar || metadata.picture
);
const profileUrl = `/${npub}`;
const profileUrl = buildProfileLinkPath({
pubkey: video.pubkey,
nip05: rawMetadata?.nip05,
});

// Format timestamp
const timestamp = video.originalVineTimestamp || video.createdAt;
Expand Down
8 changes: 6 additions & 2 deletions src/components/NoteContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SmartLink } from '@/components/SmartLink';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { buildProfileLinkPath } from '@/lib/profileLinks';
import { cn } from '@/lib/utils';

interface NoteContentProps {
Expand Down Expand Up @@ -118,13 +119,16 @@ export function NoteContent({
// Helper component to display user mentions
function NostrMention({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const npub = nip19.npubEncode(pubkey);
const hasRealName = !!author.data?.metadata?.name;
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
const profilePath = buildProfileLinkPath({
pubkey,
nip05: author.data?.metadata?.nip05,
});

return (
<SmartLink
to={`/${npub}`}
to={profilePath}
ownerPubkey={pubkey}
className={cn(
"font-medium hover:underline",
Expand Down
9 changes: 6 additions & 3 deletions src/components/NotificationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
// ABOUTME: Displays avatar with type icon overlay, message text, and relative time

import { Heart, ChatCircle as MessageCircle, UserPlus, Repeat as Repeat2, Lightning as Zap } from '@phosphor-icons/react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { useSubdomainNavigate } from '@/hooks/useSubdomainNavigate';
import { genUserName } from '@/lib/genUserName';
import { getSafeProfileImage } from '@/lib/imageUtils';
import { generateNotificationMessage, formatRelativeTime } from '@/lib/notificationTransform';
import { buildProfileLinkPath } from '@/lib/profileLinks';
import { cn } from '@/lib/utils';
import type { Notification, NotificationType } from '@/types/notification';

Expand Down Expand Up @@ -40,8 +40,11 @@ export function NotificationItem({ notification }: NotificationItemProps) {

const handleClick = () => {
if (notification.type === 'follow') {
const npub = nip19.npubEncode(notification.actorPubkey);
navigate(`/profile/${npub}`);
navigate(buildProfileLinkPath({
pubkey: notification.actorPubkey,
nip05: metadata?.nip05,
fallbackRoute: 'profile',
}));
} else if (notification.targetEventId) {
navigate(`/video/${notification.targetEventId}`);
}
Expand Down
15 changes: 9 additions & 6 deletions src/components/UserListDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// ABOUTME: Uses virtual scrolling for performance with large lists (500+ users)

import { memo, useCallback, useRef, useEffect, useMemo } from 'react';
import { nip19 } from 'nostr-tools';
import type { NostrMetadata } from '@nostrify/nostrify';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
Expand All @@ -19,6 +18,7 @@ import { useSubdomainNavigate } from '@/hooks/useSubdomainNavigate';
import { getSafeProfileImage } from '@/lib/imageUtils';
import { genUserName } from '@/lib/genUserName';
import { Sentry } from '@/lib/sentry';
import { buildProfileLinkPath } from '@/lib/profileLinks';

const ESTIMATED_ROW_HEIGHT = 56;

Expand All @@ -35,7 +35,7 @@ interface UserListDialogProps {
interface UserRowProps {
pubkey: string;
metadata?: NostrMetadata;
onNavigate: (pubkey: string) => void;
onNavigate: (pubkey: string, nip05?: string) => void;
}

const UserRow = memo(function UserRow({ pubkey, metadata, onNavigate }: UserRowProps) {
Expand All @@ -45,7 +45,7 @@ const UserRow = memo(function UserRow({ pubkey, metadata, onNavigate }: UserRowP
return (
<button
className="flex items-center gap-3 w-full p-2 rounded-lg hover:bg-muted transition-colors text-left"
onClick={() => onNavigate(pubkey)}
onClick={() => onNavigate(pubkey, metadata?.nip05)}
>
<Avatar size="md" className="shrink-0">
<AvatarImage src={profileImage} alt={displayName} />
Expand Down Expand Up @@ -150,10 +150,13 @@ export function UserListDialog({
const { data: authorsData } = useBatchedAuthors(open ? visiblePubkeys : []);

const handleNavigate = useCallback(
(pubkey: string) => {
const npub = nip19.npubEncode(pubkey);
(pubkey: string, nip05?: string) => {
onOpenChange(false);
navigate(`/profile/${npub}`, { ownerPubkey: pubkey });
navigate(buildProfileLinkPath({
pubkey,
nip05,
fallbackRoute: 'profile',
}), { ownerPubkey: pubkey });
},
[navigate, onOpenChange],
);
Expand Down
9 changes: 5 additions & 4 deletions src/components/VideoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Heart, Repeat as Repeat2, ChatCircle as MessageCircle, Share, Eye, DotsThreeVertical as MoreVertical, Flag, UserMinus as UserX, Trash as Trash2, SpeakerHigh as Volume2, SpeakerX as VolumeX, Code, Users, ListPlus, DownloadSimple as Download, ArrowsOutSimple as Maximize2, ClosedCaptioning as Captions, PushPin as Pin, PushPinSlash as PinOff, ArrowClockwise } from '@phosphor-icons/react';
import { nip19 } from 'nostr-tools';
import { Card, CardContent, type CardAccent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
Expand Down Expand Up @@ -55,6 +54,7 @@ import { useSubtitles } from '@/hooks/useSubtitles';
import { debugLog } from '@/lib/debug';
import { useLoginDialog } from '@/contexts/LoginDialogContext';
import { AgeRestrictedMediaPlaceholder } from '@/components/AgeRestrictedMediaPlaceholder';
import { buildProfileLinkPath } from '@/lib/profileLinks';

interface VideoCardProps {
video: ParsedVideoData;
Expand Down Expand Up @@ -283,7 +283,6 @@ export function VideoCard({
const metadata: NostrMetadata = author.metadata;
const reposterMetadata: NostrMetadata | undefined = reposter?.metadata;

const npub = nip19.npubEncode(video.pubkey);
// Use raw author data (pre-enhancement) to detect real vs generated names
// enhanceAuthorData fills in generated names like "ElectricVine742" — we don't want those
const rawMetadata = authorData.data?.metadata;
Expand All @@ -296,8 +295,10 @@ export function VideoCard({
const profileImage = getSafeProfileImage(
rawMetadata?.picture || video.authorAvatar || metadata.picture
);
// Just use npub for now, we'll deal with NIP-05 later
const profileUrl = `/${npub}`;
const profileUrl = buildProfileLinkPath({
pubkey: video.pubkey,
nip05: rawMetadata?.nip05,
});

const reposterName = reposterData.isLoading
? t('videoCard.loadingProfile')
Expand Down
8 changes: 5 additions & 3 deletions src/components/VideoReactionsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { enhanceAuthorData } from '@/lib/generateProfile';
import { genUserName } from '@/lib/genUserName';
import { getSafeProfileImage } from '@/lib/imageUtils';
import { formatDistanceToNow } from 'date-fns';
import { nip19 } from 'nostr-tools';
import { SmartLink } from '@/components/SmartLink';
import { buildProfileLinkPath } from '@/lib/profileLinks';
import { cn } from '@/lib/utils';
import type { VideoReactions } from '@/hooks/useVideoReactions';

Expand All @@ -25,12 +25,14 @@ function ReactionUserItem({ pubkey, timestamp }: { pubkey: string; timestamp: nu
const author = enhanceAuthorData(authorData.data, pubkey);
const metadata = author.metadata;

const npub = nip19.npubEncode(pubkey);
const displayName = authorData.isLoading
? "Loading..."
: (metadata?.display_name || metadata?.name || genUserName(pubkey));
const profileImage = getSafeProfileImage(metadata?.picture);
const profileUrl = `/${npub}`;
const profileUrl = buildProfileLinkPath({
pubkey,
nip05: metadata?.nip05,
});

const date = new Date(timestamp * 1000);
const timeAgo = formatDistanceToNow(date, { addSuffix: true });
Expand Down
6 changes: 5 additions & 1 deletion src/lib/eventRouting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { VIDEO_KINDS } from '@/types/video';
import { buildProfileLinkPath } from '@/lib/profileLinks';

const LIST_EVENT_KINDS = new Set([
3,
Expand All @@ -21,7 +22,10 @@ export function buildVideoPath(identifier: string): string {
}

export function buildProfilePath(identifier: string): string {
return `/profile/${identifier}`;
return buildProfileLinkPath({
pubkey: identifier,
fallbackRoute: 'profile',
});
}

export function buildListPath(pubkey: string, listId: string): string {
Expand Down
43 changes: 43 additions & 0 deletions src/lib/profileLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { buildProfileLinkPath, normalizeNip05Identifier } from './profileLinks';

describe('normalizeNip05Identifier', () => {
it('normalizes valid NIP-05 to lowercase', () => {
expect(normalizeNip05Identifier(' Alice@Divine.Video ')).toBe('alice@divine.video');
});

it('returns null for invalid NIP-05 values', () => {
expect(normalizeNip05Identifier('')).toBeNull();
expect(normalizeNip05Identifier('alice')).toBeNull();
expect(normalizeNip05Identifier('alice@')).toBeNull();
expect(normalizeNip05Identifier('@divine.video')).toBeNull();
expect(normalizeNip05Identifier('alice@divine@video')).toBeNull();
});
});

describe('buildProfileLinkPath', () => {
const pubkey = 'f'.repeat(64);
const npub = 'npub1lllllllllllllllllllllllllllllllllllllllllllllllllllsq7lrjw';

it('builds a NIP-05 route when metadata is available', () => {
expect(buildProfileLinkPath({
pubkey,
nip05: '_@alice.divine.video',
})).toBe('/u/_%40alice.divine.video');
});

it('falls back to root npub route by default', () => {
expect(buildProfileLinkPath({ pubkey })).toBe(`/${npub}`);
});

it('uses /profile fallback route when requested', () => {
expect(buildProfileLinkPath({
pubkey,
fallbackRoute: 'profile',
})).toBe(`/profile/${npub}`);
});

it('preserves existing npub values without re-encoding', () => {
expect(buildProfileLinkPath({ pubkey: npub })).toBe(`/${npub}`);
});
});
60 changes: 60 additions & 0 deletions src/lib/profileLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { nip19 } from 'nostr-tools';

const HEX_PUBKEY_PATTERN = /^[0-9a-f]{64}$/i;

export type ProfileFallbackRoute = 'root' | 'profile';

interface BuildProfileLinkPathInput {
pubkey: string;
nip05?: string | null;
fallbackRoute?: ProfileFallbackRoute;
}

export function buildProfileLinkPath({
pubkey,
nip05,
fallbackRoute = 'root',
}: BuildProfileLinkPathInput): string {
const normalizedNip05 = normalizeNip05Identifier(nip05);
if (normalizedNip05) {
return `/u/${encodeURIComponent(normalizedNip05)}`;
}

const npub = toNpub(pubkey);
return fallbackRoute === 'profile' ? `/profile/${npub}` : `/${npub}`;
}

export function normalizeNip05Identifier(nip05?: string | null): string | null {
if (!nip05) return null;

const normalized = nip05.trim().toLowerCase();
if (!normalized) return null;

const atIndex = normalized.lastIndexOf('@');
if (atIndex <= 0 || atIndex === normalized.length - 1) {
return null;
}

if (normalized.indexOf('@') !== atIndex) {
return null;
}

return normalized;
}

function toNpub(pubkey: string): string {
const normalized = pubkey.trim();
if (normalized.startsWith('npub1')) {
return normalized;
}

if (!HEX_PUBKEY_PATTERN.test(normalized)) {
return normalized;
}

try {
return nip19.npubEncode(normalized.toLowerCase());
} catch {
return normalized;
}
}
Loading
Loading