Add follow URI system with QR sharing and immersive follow page

Introduce a /follow/:npub deep link that auto-follows a user when
visited by a logged-in user, or presents an immersive business card
with a 'Follow on Ditto' CTA for logged-out visitors. The page applies
the target user's profile theme, renders their feed with infinite
scroll, and uses the same banner/avatar/arc styling as the main profile.

Add a FollowQRDialog that generates a themed QR code for the follow
URL. The QR colors are derived from the active theme: primary color
for modules (with contrast-safe darkening/lightening), and background
color for the QR background. Foreground text color is used when it is
colorful and offers significantly better contrast.

Surface the QR dialog from: own profile page (top-level button),
profile more menu, desktop sidebar account popover, and mobile drawer.
This commit is contained in:
Chad Curtis
2026-04-05 06:01:48 -05:00
parent 614634789c
commit a12d5db560
6 changed files with 637 additions and 6 deletions
+4
View File
@@ -77,6 +77,7 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
@@ -151,6 +152,9 @@ export function AppRouter() {
</Suspense>
</BlobbiActionsProvider>
<Routes>
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
+219
View File
@@ -0,0 +1,219 @@
import { useEffect, useState } from 'react';
import { nip19 } from 'nostr-tools';
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';
import { genUserName } from '@/lib/genUserName';
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
const MIN_QR_CONTRAST = 3;
/** Saturation threshold (%) above which a color is considered "colorful". */
const COLORFUL_SAT_MIN = 15;
/** Lightness range within which a color appears visually colorful. */
const COLORFUL_L_MIN = 20;
const COLORFUL_L_MAX = 80;
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
if (typeof document === 'undefined') return null;
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
if (!raw) return null;
const { h, s, l } = parseHsl(raw);
if ([h, s, l].some(isNaN)) return null;
return { h, s, l };
}
/**
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function darkenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l > 0 && ratio < MIN_QR_CONTRAST) {
l = Math.max(0, l - 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function lightenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l < 100 && ratio < MIN_QR_CONTRAST) {
l = Math.min(100, l + 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Choose the best module color from primary and foreground.
*
* Strongly prefers primary since it carries the theme's brand identity.
* Only picks foreground if it is colorful (saturation > threshold) AND
* has significantly better contrast (> 1.5x) against the QR background.
*/
function pickModuleColor(
primary: { h: number; s: number; l: number },
foreground: { h: number; s: number; l: number } | null,
bgRgb: [number, number, number],
): { h: number; s: number; l: number } {
const fgIsColorful = foreground
&& foreground.s >= COLORFUL_SAT_MIN
&& foreground.l >= COLORFUL_L_MIN
&& foreground.l <= COLORFUL_L_MAX;
if (!fgIsColorful) return primary;
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
const fgContrast = getContrastRatio(fgRgb, bgRgb);
// Foreground must be significantly better to override primary
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
}
/**
* Derive QR module and background hex colors from the active theme.
*
* Light themes: white background, best themed color as modules (darkened if needed).
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
*
* "Best themed color" is --primary by default. If --foreground is colorful
* (saturation > 15%) and offers better contrast, it wins instead.
*/
function getThemedQRColors(): { dark: string; light: string } {
const primary = readCssHsl('--primary');
const foreground = readCssHsl('--foreground');
const background = readCssHsl('--background');
if (!primary) return { dark: '#000000', light: '#ffffff' };
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
if (!isDark) {
const white: [number, number, number] = [255, 255, 255];
const module = pickModuleColor(primary, foreground, white);
return { dark: darkenToContrast(module, white), light: '#ffffff' };
}
if (!background) return { dark: '#ffffff', light: '#000000' };
const bgRgb = hslToRgb(background.h, background.s, background.l);
const module = pickModuleColor(primary, foreground, bgRgb);
return {
dark: lightenToContrast(module, bgRgb),
light: rgbToHex(...bgRgb),
};
}
interface FollowQRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) {
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey ?? '');
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [copied, setCopied] = useState(false);
const metadata = author.data?.metadata;
const displayName = user ? metadata?.name || genUserName(user.pubkey) : '';
const npub = user ? nip19.npubEncode(user.pubkey) : '';
const followUrl = npub ? `${window.location.origin}/follow/${npub}` : '';
useEffect(() => {
if (!followUrl || !open) return;
const { dark, light } = getThemedQRColors();
QRCode.toDataURL(followUrl, {
width: 400,
margin: 2,
color: { dark, light },
errorCorrectionLevel: 'M',
})
.then(setQrDataUrl)
.catch(console.error);
}, [followUrl, open]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(followUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm p-6 flex flex-col items-center gap-5 rounded-2xl">
<DialogTitle className="sr-only">Share follow link</DialogTitle>
{/* Avatar + name */}
<div className="flex flex-col items-center gap-2">
<Avatar shape={getAvatarShape(metadata)} className="size-16 ring-2 ring-secondary">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="text-xl font-semibold">
{displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="text-sm text-muted-foreground text-center">
Scan to follow <span className="text-foreground font-medium">{displayName}</span>
</p>
</div>
{/* QR code */}
{qrDataUrl ? (
<img
src={qrDataUrl}
alt="Follow QR code"
className="w-full rounded-xl border border-border"
style={{ imageRendering: 'pixelated' }}
/>
) : (
<div className="w-full aspect-square rounded-xl border border-border bg-muted animate-pulse" />
)}
{/* Copy link */}
<button
onClick={handleCopy}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{copied
? <Check className="size-3.5 text-primary flex-shrink-0" />
: <Copy className="size-3.5 flex-shrink-0" />}
<span className="truncate max-w-64">{followUrl}</span>
</button>
</DialogContent>
</Dialog>
);
}
+8 -1
View File
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
UserPlus, LogOut,
Loader2,
Loader2, QrCode,
} from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -15,6 +15,7 @@ import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import LoginDialog from '@/components/auth/LoginDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
@@ -55,6 +56,7 @@ export function LeftSidebar() {
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const { startSignup } = useOnboarding();
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
@@ -293,6 +295,10 @@ export function LeftSidebar() {
{/* Actions */}
<div className="py-1">
<button onClick={() => { setAccountPopoverOpen(false); setFollowQROpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<QrCode className="size-4 text-muted-foreground" />
<span>Share profile</span>
</button>
<button onClick={() => { setAccountPopoverOpen(false); setLoginDialogOpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<UserPlus className="size-4 text-muted-foreground" />
<span>Add another account</span>
@@ -308,6 +314,7 @@ export function LeftSidebar() {
)}
<LoginDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} onLogin={() => setLoginDialogOpen(false)} onSignupClick={startSignup} />
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</aside>
);
}
+11 -1
View File
@@ -1,6 +1,6 @@
import { useState, useId, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2 } from 'lucide-react';
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';
@@ -11,6 +11,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { LinkFooter } from '@/components/LinkFooter';
import { EmojifiedText } from '@/components/CustomEmoji';
import LoginDialog from '@/components/auth/LoginDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
@@ -60,6 +61,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [accountExpanded, setAccountExpanded] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const { startSignup } = useOnboarding();
const { theme, customTheme, themes } = useTheme();
@@ -269,6 +271,13 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</div>
</button>
))}
<button
onClick={() => { handleClose(); setFollowQROpen(true); }}
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
>
<QrCode className="size-5 shrink-0" />
<span>Share profile</span>
</button>
<button
onClick={() => { handleClose(); setLoginDialogOpen(true); }}
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
@@ -376,6 +385,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
onLogin={() => setLoginDialogOpen(false)}
onSignupClick={startSignup}
/>
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</>
);
}
+359
View File
@@ -0,0 +1,359 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useInView } from 'react-intersection-observer';
import { nip19 } from 'nostr-tools';
import { UserPlus, Loader2, CheckCircle2 } from 'lucide-react';
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 { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useToast } from '@/hooks/useToast';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useProfileFeed, filterByTab } from '@/hooks/useProfileFeed';
import { genUserName } from '@/lib/genUserName';
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { DittoLogo } from '@/components/DittoLogo';
import { Nip05Badge } from '@/components/Nip05Badge';
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
import { useOnboarding } from '@/hooks/useOnboarding';
import { buildThemeCssFromCore } from '@/themes';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import LoginDialog from '@/components/auth/LoginDialog';
import type { FeedItem } from '@/lib/feedUtils';
import NotFound from './NotFound';
// ---------------------------------------------------------------------------
// Theme application
// ---------------------------------------------------------------------------
function useApplyProfileTheme(pubkey: string | undefined) {
const themeQuery = useActiveProfileTheme(pubkey);
const theme = themeQuery.data;
useLayoutEffect(() => {
if (!theme?.colors) return;
const css = buildThemeCssFromCore(theme.colors);
let el = document.getElementById('theme-vars') as HTMLStyleElement | null;
const previousCss = el?.textContent ?? null;
if (!el) {
el = document.createElement('style');
el.id = 'theme-vars';
document.head.appendChild(el);
}
el.textContent = css;
if (theme.font) loadAndApplyFont(theme.font);
if (theme.titleFont) loadAndApplyTitleFont(theme.titleFont);
const bgStyleId = 'theme-background';
const prevBgEl = document.getElementById(bgStyleId) as HTMLStyleElement | null;
if (theme.background?.url) {
let bgEl = prevBgEl;
if (!bgEl) {
bgEl = document.createElement('style');
bgEl.id = bgStyleId;
document.head.appendChild(bgEl);
}
const mode = theme.background.mode ?? 'cover';
bgEl.textContent = mode === 'tile'
? `body { background-image: url("${theme.background.url}"); background-repeat: repeat; background-size: auto; }`
: `body { background-image: url("${theme.background.url}"); background-size: cover; background-repeat: no-repeat; background-position: center; background-attachment: fixed; }`;
} else {
prevBgEl?.remove();
}
return () => {
const styleEl = document.getElementById('theme-vars') as HTMLStyleElement | null;
if (styleEl && previousCss !== null) {
styleEl.textContent = previousCss;
} else if (styleEl) {
styleEl.remove();
}
document.getElementById(bgStyleId)?.remove();
};
}, [theme]);
}
// ---------------------------------------------------------------------------
// Profile feed (reuses useProfileFeed + NoteCard)
// ---------------------------------------------------------------------------
function ProfileFeed({ pubkey }: { pubkey: string }) {
const {
data: feedData,
isPending,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProfileFeed(pubkey, 'posts');
const feedItems = useMemo(() => {
if (!feedData?.pages) return [];
const seen = new Set<string>();
const items: FeedItem[] = [];
for (const page of feedData.pages) {
for (const item of page.items) {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!seen.has(key)) {
seen.add(key);
items.push(item);
}
}
}
return filterByTab(items, 'posts');
}, [feedData?.pages]);
const { ref: scrollRef, inView } = useInView({ threshold: 0 });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isPending) {
return (
<div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-4 py-3 border-b border-border">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (feedItems.length === 0) return null;
return (
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
compact
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="flex justify-center py-6">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main follow view
// ---------------------------------------------------------------------------
function FollowView({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const { isPending, follow } = useFollowActions();
const { toast } = useToast();
const navigate = useNavigate();
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
const bannerUrl = metadata?.banner;
const { startSignup } = useOnboarding();
const isOwnProfile = user && user.pubkey === pubkey;
const isAlreadyFollowing = followData?.pubkeys.includes(pubkey) ?? false;
const isLoggedOut = !user;
const [loginOpen, setLoginOpen] = useState(false);
useApplyProfileTheme(pubkey);
const hasAutoFollowed = useRef(false);
const [followDone, setFollowDone] = useState(false);
useEffect(() => {
if (!user || isOwnProfile || isAlreadyFollowing || hasAutoFollowed.current || isPending) return;
if (!followData) return;
hasAutoFollowed.current = true;
follow(pubkey)
.then(() => {
setFollowDone(true);
toast({ title: 'Followed!', description: `You are now following ${displayName}` });
})
.catch((err) => {
console.error('Auto-follow failed:', err);
hasAutoFollowed.current = false;
toast({ title: 'Something went wrong', variant: 'destructive' });
});
}, [user, isOwnProfile, isAlreadyFollowing, followData, isPending, pubkey, follow, displayName, toast]);
return (
<div className="h-dvh flex flex-col bg-background/85">
{/* Profile header (not scrollable) */}
<div className="shrink-0">
{/* Banner — matches ProfilePage: clean edge, no gradient */}
<div className="h-36 md:h-48 bg-secondary relative">
{author.isLoading ? (
<Skeleton className="w-full h-full rounded-none" />
) : bannerUrl ? (
<img src={bannerUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />
)}
<Link to="/" className="absolute top-3 left-3">
<div className="bg-background/85 rounded-full">
<DittoLogo size={48} />
</div>
</Link>
</div>
{/* Profile card */}
<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>
);
})()}
{/* Name + NIP-05 */}
<div className="mt-3 text-center">
<h1 className="text-xl font-bold text-foreground">{displayName}</h1>
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey} className="justify-center mt-1" />
)}
</div>
{/* CTA — right under the name */}
<div className="mt-4 w-full max-w-xs">
{isLoggedOut ? (
<Button
onClick={() => setLoginOpen(true)}
className="w-full rounded-full py-3 text-base font-semibold"
size="lg"
>
Follow {displayName} on Ditto
</Button>
) : isOwnProfile ? (
<div className="text-center space-y-2">
<p className="text-muted-foreground text-sm">
This is your follow link. Share it with others.
</p>
<Link to={profileUrl}>
<Button variant="outline" className="rounded-full">View your profile</Button>
</Link>
</div>
) : isPending ? (
<div className="flex flex-col items-center space-y-2">
<Loader2 className="size-6 text-primary animate-spin" />
<p className="text-sm text-muted-foreground">Following...</p>
</div>
) : followDone || isAlreadyFollowing ? (
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-primary">
<UserPlus className="size-5" />
<p className="font-semibold">
{isAlreadyFollowing && !followDone ? 'Already following!' : 'Now following!'}
</p>
</div>
<div className="flex flex-col gap-2">
<Link to={profileUrl}>
<Button className="rounded-full w-full">View profile</Button>
</Link>
<Button variant="secondary" className="rounded-full" onClick={() => navigate('/feed')}>
Go to feed
</Button>
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
{/* Feed scrollbox */}
<div className="flex-1 min-h-0 overflow-y-auto relative" style={{ marginTop: -ARC_OVERHANG_PX }}>
{/* Arc with bg — sits at the top of the scroll area, overlapping the gap */}
<div className="sticky top-0 z-10 pointer-events-none" style={{ height: ARC_OVERHANG_PX }}>
<ArcBackground variant="down" />
</div>
<div className="max-w-2xl mx-auto w-full bg-background/85" style={{ paddingTop: ARC_OVERHANG_PX }}>
<ProfileFeed pubkey={pubkey} />
</div>
</div>
<LoginDialog
isOpen={loginOpen}
onClose={() => setLoginOpen(false)}
onLogin={() => setLoginOpen(false)}
onSignupClick={startSignup}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Route component
// ---------------------------------------------------------------------------
export function FollowPage() {
const { npub } = useParams<{ npub: string }>();
if (!npub) return <NotFound />;
let pubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
pubkey = decoded.data;
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey;
} else {
return <NotFound />;
}
} catch {
return <NotFound />;
}
return <FollowView pubkey={pubkey} />;
}
+36 -4
View File
@@ -69,6 +69,7 @@ import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useProfileTabs } from '@/hooks/useProfileTabs';
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { ProfileRecoveryDialog } from '@/components/ProfileRecoveryDialog';
import { GiveBadgeDialog } from '@/components/GiveBadgeDialog';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
@@ -173,6 +174,7 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
const [addToListOpen, setAddToListOpen] = useState(false);
const [recoveryOpen, setRecoveryOpen] = useState(false);
const [giveBadgeOpen, setGiveBadgeOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const zapTriggerRef = useRef<HTMLSpanElement>(null);
const author = useAuthor(pubkey);
const showZap = !isOwnProfile && authorEvent && canZap(author.data?.metadata);
@@ -182,6 +184,7 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
close();
setTimeout(() => setter(true), 150);
};
const handleFollowQR = () => openAfterClose(setFollowQROpen);
const handleCopyPubkey = () => {
navigator.clipboard.writeText(npubEncoded);
@@ -265,6 +268,11 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
<Separator />
<div className="py-1">
<MenuRow
icon={<QrCode className="size-5" />}
label="Share follow link"
onClick={handleFollowQR}
/>
<MenuRow
icon={<RotateCcw className="size-5" />}
label="Profile recovery"
@@ -332,10 +340,16 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
/>
{isOwnProfile && (
<ProfileRecoveryDialog
open={recoveryOpen}
onOpenChange={setRecoveryOpen}
/>
<>
<ProfileRecoveryDialog
open={recoveryOpen}
onOpenChange={setRecoveryOpen}
/>
<FollowQRDialog
open={followQROpen}
onOpenChange={setFollowQROpen}
/>
</>
)}
{!isOwnProfile && (
@@ -912,6 +926,7 @@ export function ProfilePage() {
const [activeTab, setActiveTab] = useState<CoreProfileTab | string>('posts');
const [sidebarMediaUrl, setSidebarMediaUrl] = useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [followingModalOpen, setFollowingModalOpen] = useState(false);
const [followersModalOpen, setFollowersModalOpen] = useState(false);
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
@@ -2106,6 +2121,18 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
<Share2 className="size-5" />
</Button>
)}
{/* Follow QR code button (own profile only) */}
{isOwnProfile && (
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
title="Share follow link"
onClick={() => setFollowQROpen(true)}
>
<QrCode className="size-5" />
</Button>
)}
{/* Profile reaction button */}
{!isOwnProfile && authorEvent && (
<ProfileReactionButton profileEvent={authorEvent} />
@@ -2610,6 +2637,11 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
/>
)}
{/* Follow QR dialog (own profile action bar button) */}
{isOwnProfile && (
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
)}
{/* Following List Modal */}
{profileFollowing && profileFollowing.count > 0 && (
<FollowingListModal