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:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user