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
1 change: 1 addition & 0 deletions src/components/EditProfileForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onSuccess }) =
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['logins'] });
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
queryClient.invalidateQueries({ queryKey: ['funnelcake-profile', user.pubkey] });
queryClient.invalidateQueries({ queryKey: ['follow-list-safety-check'] });

toast({
Expand Down
95 changes: 95 additions & 0 deletions src/components/LinkedAccounts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LinkedAccounts } from './LinkedAccounts';

const mockUseExternalIdentities = vi.fn();
const mockVerifyIdentityClaim = vi.fn();

vi.mock('@/hooks/useExternalIdentities', () => ({
useExternalIdentities: (...args: unknown[]) => mockUseExternalIdentities(...args),
verifyIdentityClaim: (...args: unknown[]) => mockVerifyIdentityClaim(...args),
SUPPORTED_PLATFORMS: {
github: {
label: 'GitHub',
profileUrl: (id: string) => `https://github.com/${id}`,
proofUrl: (id: string, proof: string) => `https://gist.github.com/${id}/${proof}`,
verificationText: () => [],
canVerifyInBrowser: false,
},
},
}));

vi.mock('@/lib/verificationCache', () => ({
getCachedVerification: () => null,
}));

function withQueryClient(children: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

describe('LinkedAccounts', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders verified identity badge on profile', async () => {
mockUseExternalIdentities.mockReturnValue({
data: [
{
platform: 'github',
identity: 'alice',
proof: 'abc123',
profileUrl: 'https://github.com/alice',
proofUrl: 'https://gist.github.com/alice/abc123',
},
],
isLoading: false,
});
mockVerifyIdentityClaim.mockResolvedValue({ verified: true });

render(withQueryClient(<LinkedAccounts pubkey={'a'.repeat(64)} />));

await waitFor(() => {
expect(screen.getByTestId('identity-badge-github')).toBeInTheDocument();
});
});

it('hides unverified identity badge on profile', async () => {
mockUseExternalIdentities.mockReturnValue({
data: [
{
platform: 'github',
identity: 'alice',
proof: 'abc123',
profileUrl: 'https://github.com/alice',
proofUrl: 'https://gist.github.com/alice/abc123',
},
],
isLoading: false,
});
mockVerifyIdentityClaim.mockResolvedValue({ verified: false, error: 'manual' });

render(withQueryClient(<LinkedAccounts pubkey={'a'.repeat(64)} />));

await waitFor(() => {
expect(mockVerifyIdentityClaim).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByTestId('identity-badge-github')).not.toBeInTheDocument();
});
});
});
19 changes: 14 additions & 5 deletions src/components/LinkedAccounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ function PlatformIcon({ platform, className }: { platform: string; className?: s
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
case 'youtube':
return (
<svg className={cn} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31.7 31.7 0 0 0 0 12a31.7 31.7 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31.7 31.7 0 0 0 24 12a31.7 31.7 0 0 0-.5-5.8zM9.6 15.6V8.4l6.2 3.6-6.2 3.6z" />
</svg>
);
case 'tiktok':
return (
<svg className={cn} viewBox="0 0 24 24" fill="currentColor">
<path d="M16.7 3.3c.8 2 2.4 3.3 4.5 3.6v3.1a7.7 7.7 0 0 1-4.2-1.3v7.1a6.3 6.3 0 1 1-5.4-6.2v3.2a3.2 3.2 0 1 0 2.2 3V.8h2.9v2.5z" />
</svg>
);
default:
return <Link2 className={cn} />;
}
Expand Down Expand Up @@ -84,12 +96,9 @@ function IdentityBadge({ identity, pubkey }: { identity: ExternalIdentity; pubke
initialData: cachedResult ?? undefined,
});

// Don't show badge on public profile if verification failed (prevents impersonation display)
// Show while loading, when verified, or when manual check is needed
// Only show externally-verifiable claims that are currently verified.
const isVerified = verification.data?.verified;
const isManual = verification.data?.error === 'manual';
const isStillLoading = verification.isLoading || verification.fetchStatus === 'idle';
if (!isVerified && !isManual && !isStillLoading) return null;
if (!isVerified) return null;

return (
<Popover open={open} onOpenChange={setOpen}>
Expand Down
42 changes: 42 additions & 0 deletions src/components/ProfileHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { nip19 } from 'nostr-tools';
import { ProfileHeader } from './ProfileHeader';
import type { ProfileStats } from '@/lib/profileStats';

Expand Down Expand Up @@ -133,4 +134,45 @@ describe('ProfileHeader', () => {

expect(screen.queryByRole('link', { name: /twitter \/ x/i })).not.toBeInTheDocument();
});

it('linkifies urls, hashtags, and nostr mentions in bio', () => {
const mentionPubkey = 'b'.repeat(64);
const npub = nip19.npubEncode(mentionPubkey);
const about = `Loops forever: https://divine.video #Divine nostr:${npub}`;

render(
<MemoryRouter>
<ProfileHeader
pubkey={'a'.repeat(64)}
metadata={{ display_name: 'Modern Creator', about }}
stats={baseStats}
isOwnProfile={false}
isFollowing={false}
onFollowToggle={vi.fn()}
/>
</MemoryRouter>
);

expect(screen.getByRole('link', { name: 'https://divine.video' })).toHaveAttribute('href', 'https://divine.video/');
expect(screen.getByRole('link', { name: '#Divine' })).toHaveAttribute('href', '/t/divine');
expect(screen.getByRole('link', { name: `nostr:${npub}` })).toHaveAttribute('href', `/${npub}`);
});

it('normalizes website link and keeps row visible when non-empty', () => {
render(
<MemoryRouter>
<ProfileHeader
pubkey={'a'.repeat(64)}
metadata={{ display_name: 'Modern Creator', website: 'divine.video/profile/alice' }}
stats={baseStats}
isOwnProfile={false}
isFollowing={false}
onFollowToggle={vi.fn()}
/>
</MemoryRouter>
);

expect(screen.getByRole('link', { name: /divine.video\/profile\/alice/i }))
.toHaveAttribute('href', 'https://divine.video/profile/alice');
});
});
60 changes: 12 additions & 48 deletions src/components/ProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { getDivineNip05Info } from '@/lib/nip05Utils';
import { linkifyProfileBioText, normalizeExternalUrl } from '@/lib/profileBioLinkify';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
Expand Down Expand Up @@ -37,46 +38,6 @@ import { getDmConversationPath } from '@/lib/dm';
import type { LegacySocialLink } from '@/lib/legacySocials';
import type { ProfileStats } from '@/lib/profileStats';

/** Linkify URLs and domain-like words in text */
function linkifyText(text: string): React.ReactNode[] {
// Match URLs (https?://...) and bare domain names (word.tld patterns)
const urlRegex = /(https?:\/\/[^\s]+)|(\b[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?(?:\/[^\s]*)?)/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;

while ((match = urlRegex.exec(text)) !== null) {
// Add text before the match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}

const matchedText = match[0];
const href = matchedText.startsWith('http') ? matchedText : `https://${matchedText}`;

parts.push(
<a
key={match.index}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{matchedText}
</a>
);

lastIndex = match.index + matchedText.length;
}

// Add remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}

return parts;
}

interface ProfileMetadata extends NostrMetadata {
_stillLoadingName?: boolean; // Flag to indicate name is still being fetched
}
Expand Down Expand Up @@ -161,6 +122,8 @@ export function ProfileHeader({
const profileImage = getSafeProfileImage(metadata?.picture) || '/user-avatar.png';
const about = metadata?.about;
const website = metadata?.website;
const websiteHref = normalizeExternalUrl(website);
const websiteLabel = website?.trim();

const handleFollowClick = () => {
onFollowToggle(!isFollowing);
Expand Down Expand Up @@ -275,22 +238,23 @@ export function ProfileHeader({
) : null}
</div>

{/* Website - hide if it's just a divine.video profile URL */}
{website && !website.includes('divine.video/profile/') && (
{/* Website */}
{websiteLabel && websiteHref && (
<div className="flex flex-wrap gap-2 justify-center sm:justify-start">
<Badge variant="outline" className="text-xs">
<a href={website} target="_blank" rel="noopener noreferrer" className="hover:underline">
{website}
<Badge variant="outline" className="text-xs max-w-full">
<a href={websiteHref} target="_blank" rel="noopener noreferrer" className="hover:underline inline-flex items-center gap-1 break-all">
<Link2 className="h-3 w-3 shrink-0" />
{websiteLabel}
</a>
</Badge>
</div>
)}

{/* Bio */}
{about && (
<p className="text-muted-foreground text-sm leading-relaxed max-w-md">
{linkifyText(about)}
</p>
<div className="text-muted-foreground text-sm leading-relaxed max-w-md whitespace-pre-wrap break-words">
{linkifyProfileBioText(about)}
</div>
)}
</div>

Expand Down
57 changes: 56 additions & 1 deletion src/hooks/useExternalIdentities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,45 @@ describe('parseIdentityTag', () => {
proofUrl: '',
});
});

it('maps x platform to twitter config', () => {
const tag = ['i', 'x:alice', '12345'];
const result = parseIdentityTag(tag);

expect(result).toEqual({
platform: 'twitter',
identity: 'alice',
proof: '12345',
profileUrl: 'https://twitter.com/alice',
proofUrl: 'https://twitter.com/alice/status/12345',
});
});

it('parses a YouTube identity tag', () => {
const tag = ['i', 'youtube:alice', 'dQw4w9WgXcQ'];
const result = parseIdentityTag(tag);

expect(result).toEqual({
platform: 'youtube',
identity: 'alice',
proof: 'dQw4w9WgXcQ',
profileUrl: 'https://www.youtube.com/@alice',
proofUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
});
});

it('parses a TikTok identity tag', () => {
const tag = ['i', 'tiktok:alice', '7399477729245037867'];
const result = parseIdentityTag(tag);

expect(result).toEqual({
platform: 'tiktok',
identity: 'alice',
proof: '7399477729245037867',
profileUrl: 'https://www.tiktok.com/@alice',
proofUrl: 'https://www.tiktok.com/@alice/video/7399477729245037867',
});
});
});

// -- SUPPORTED_PLATFORMS config --
Expand All @@ -189,6 +228,8 @@ describe('SUPPORTED_PLATFORMS', () => {
expect(SUPPORTED_PLATFORMS.telegram.canVerifyInBrowser).toBe(false);
expect(SUPPORTED_PLATFORMS.bluesky.canVerifyInBrowser).toBe(false);
expect(SUPPORTED_PLATFORMS.discord.canVerifyInBrowser).toBe(false);
expect(SUPPORTED_PLATFORMS.youtube.canVerifyInBrowser).toBe(false);
expect(SUPPORTED_PLATFORMS.tiktok.canVerifyInBrowser).toBe(false);
});

it('generates correct Mastodon URLs for NIP-39 format', () => {
Expand Down Expand Up @@ -279,6 +320,20 @@ describe('SUPPORTED_PLATFORMS', () => {
expect(SUPPORTED_PLATFORMS.discord.createProofUrl).toBeUndefined();
});

it('generates correct YouTube URLs', () => {
const config = SUPPORTED_PLATFORMS.youtube;
expect(config.profileUrl('alice')).toBe('https://www.youtube.com/@alice');
expect(config.proofUrl('alice', 'dQw4w9WgXcQ'))
.toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
});

it('generates correct TikTok URLs', () => {
const config = SUPPORTED_PLATFORMS.tiktok;
expect(config.profileUrl('alice')).toBe('https://www.tiktok.com/@alice');
expect(config.proofUrl('alice', '7399477729245037867'))
.toBe('https://www.tiktok.com/@alice/video/7399477729245037867');
});

it('generates correct Discord URLs', () => {
const config = SUPPORTED_PLATFORMS.discord;
expect(config.profileUrl('alice'))
Expand Down Expand Up @@ -320,7 +375,7 @@ describe('verifyIdentityClaim', () => {
});

it('returns manual for non-CORS platforms without fetching', async () => {
const platforms = ['twitter', 'mastodon', 'telegram', 'bluesky', 'discord'] as const;
const platforms = ['twitter', 'mastodon', 'telegram', 'bluesky', 'discord', 'youtube', 'tiktok'] as const;

for (const platform of platforms) {
const identity: ExternalIdentity = {
Expand Down
Loading
Loading