Skip to content
2 changes: 1 addition & 1 deletion components/Activity/cards/ActivityAuthorSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function AuthorName({ author }: Readonly<{ author: AuthorProfile }>) {
<span className="font-semibold text-gray-900">{name}</span>
)}
{isVerifiedAuthor(author) && (
<VerifiedBadge size="sm" showTooltip className="ml-1 shrink-0" />
<VerifiedBadge size="sm" showTooltip authorId={author.id} className="ml-1 shrink-0" />
)}
</span>
);
Expand Down
2 changes: 1 addition & 1 deletion components/Activity/cards/ActivityHeaderActionText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function AuthorName({
const badges = (
<>
{isVerifiedAuthor(author) && (
<VerifiedBadge size="sm" showTooltip className="ml-1 shrink-0" />
<VerifiedBadge size="sm" showTooltip authorId={id} className="ml-1 shrink-0" />
)}
{showAuthorBadge && <AuthorBadge size="sm" className="ml-1 shrink-0" />}
</>
Expand Down
2 changes: 1 addition & 1 deletion components/Feed/FeedItemHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const FeedItemHeader: FC<FeedItemHeaderProps> = ({
)}
{/* Show verified badge if user is verified - check both direct user prop and author */}
{(user?.isVerified || author?.isVerified || author?.user?.isVerified) && (
<VerifiedBadge size="sm" showTooltip={true} />
<VerifiedBadge size="sm" showTooltip={true} authorId={authorId} />
)}
{/* Show AuthorBadge with priority over EditorBadge - only if not hidden */}
{!hideAuthorBadge && isAuthorOfWork && (
Expand Down
7 changes: 6 additions & 1 deletion components/profile/ProfileHeroBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ export function ProfileHeroBanner({ author, refetchAuthorInfo, tabBar }: Profile
<h1 className="text-2xl sm:text-4xl font-semibold tracking-tight leading-snug text-gray-900">
{author.fullName}
</h1>
{author.isVerified && <VerifiedBadge showTooltip={true} />}
{author.isVerified && (
<VerifiedBadge
showTooltip={true}
verificationMethods={author.verificationMethods}
/>
)}
</div>
{author.headline && (
<p className="text-sm sm:text-base text-gray-500 mt-1">{author.headline}</p>
Expand Down
71 changes: 40 additions & 31 deletions components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,41 +82,50 @@ const TooltipContent = ({
if (!tooltipRef.current || !triggerRect) return;

const tooltip = tooltipRef.current;
const tooltipRect = tooltip.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;

let top = 0;
let left = 0;
const updatePosition = () => {
const tooltipRect = tooltip.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;

// Calculate position based on specified position
switch (position) {
case 'top':
top = triggerRect.top - tooltipRect.height - 8;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + 8;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - 8;
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + 8;
break;
}
let top = 0;
let left = 0;

// Calculate position based on specified position
switch (position) {
case 'top':
top = triggerRect.top - tooltipRect.height - 8;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + 8;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - 8;
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + 8;
break;
}

// Adjust if tooltip would go off screen
if (left < 10) left = 10;
if (left + tooltipRect.width > windowWidth - 10) left = windowWidth - tooltipRect.width - 10;
if (top < 10) top = 10;
if (top + tooltipRect.height > windowHeight - 10) top = windowHeight - tooltipRect.height - 10;
// Adjust if tooltip would go off screen
if (left < 10) left = 10;
if (left + tooltipRect.width > windowWidth - 10) left = windowWidth - tooltipRect.width - 10;
if (top < 10) top = 10;
if (top + tooltipRect.height > windowHeight - 10)
top = windowHeight - tooltipRect.height - 10;

setTooltipPosition({ top, left });
setMounted(true);
};

setTooltipPosition({ top, left });
setMounted(true);
// Content that loads after opening resizes the tooltip and moves its anchor point.
const resizeObserver = new ResizeObserver(updatePosition);
resizeObserver.observe(tooltip);
return () => resizeObserver.disconnect();
}, [triggerRect, position]);

if (!triggerRect) return null;
Expand Down
54 changes: 53 additions & 1 deletion components/ui/VerifiedBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,58 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBadgeCheck } from '@fortawesome/pro-solid-svg-icons';
import { faBadgeCheck as faBadgeCheckLight } from '@fortawesome/pro-light-svg-icons';
import { faXTwitter } from '@fortawesome/free-brands-svg-icons';
import { GraduationCap, IdCard, ShieldCheck } from 'lucide-react';
import clsx from 'clsx';
import { Tooltip } from '@/components/ui/Tooltip';
import { useVerification } from '@/contexts/VerificationContext';
import { useAuthorVerificationMethods } from '@/hooks/useAuthor';
import type { VerificationMethod } from '@/types/authorProfile';

interface VerifiedBadgeProps {
size?: 'xs' | 'sm' | 'md' | 'lg';
isOrganization?: boolean;
className?: string;
showTooltip?: boolean;
variant?: 'solid' | 'outlined';
verificationMethods?: VerificationMethod[];
/** Loads the verification methods on hover when they aren't passed. */
authorId?: number;
}

const METHOD_DETAILS: Record<VerificationMethod, { label: string; icon: React.ReactNode }> = {
PERSONA: {
label: 'Identity verified by Persona',
icon: <IdCard className="w-4 h-4 text-gray-900" />,
},
MANUAL: {
label: 'Identity verified by ResearchHub',
icon: <ShieldCheck className="w-4 h-4 text-gray-900" />,
},
X: {
label: 'Identity verified by X',
icon: <FontAwesomeIcon icon={faXTwitter} className="w-4 h-4 text-gray-900" />,
},
EDU_EMAIL: {
label: 'Verified academic email',
icon: <GraduationCap className="w-4 h-4 text-gray-900" />,
},
};

export function VerifiedBadge({
size = 'md',
isOrganization = false,
className,
showTooltip = false,
variant = 'solid',
verificationMethods,
authorId,
}: VerifiedBadgeProps) {
const { openVerificationModal } = useVerification();
const [{ methods, isLoading }, loadVerificationMethods] = useAuthorVerificationMethods(
authorId,
verificationMethods
);

// Map sizes to pixel values
const sizeMap = {
Expand All @@ -40,6 +72,7 @@ export function VerifiedBadge({
showTooltip && 'cursor-pointer',
className
)}
onPointerEnter={showTooltip ? loadVerificationMethods : undefined}
Comment thread
michaelcanova marked this conversation as resolved.
Resolved
>
<FontAwesomeIcon
icon={icon}
Expand All @@ -58,6 +91,25 @@ export function VerifiedBadge({
openVerificationModal();
};

const renderVerificationMethods = () => {
if (isLoading) {
return <div className="text-sm text-gray-500 animate-pulse">Loading verifications...</div>;
}
if (!methods?.length) {
return <div className="text-sm text-gray-600">This user has verified their identity</div>;
}
return (
<ul className="mt-1 space-y-1.5">
{methods.map((method) => (
<li key={method} className="flex items-center gap-2 text-sm text-gray-700">
<span className="flex w-4 justify-center shrink-0">{METHOD_DETAILS[method].icon}</span>
{METHOD_DETAILS[method].label}
</li>
))}
</ul>
);
};

const tooltipContent = (
<div className="flex gap-4">
<div className="flex-shrink-0">
Expand All @@ -67,7 +119,7 @@ export function VerifiedBadge({
</div>
<div className="text-left flex-1">
<div className="font-semibold text-gray-900 text-base">Verified Profile</div>
<div className="text-sm text-gray-600 mb-0">This user has verified their identity</div>
{renderVerificationMethods()}
<a
href="#"
onClick={handleLearnMoreClick}
Expand Down
40 changes: 40 additions & 0 deletions hooks/useAuthor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { AuthorService } from '@/services/author.service';
import type { AuthorUpdatePayload } from '@/services/author.service';
import type { VerificationMethod } from '@/types/authorProfile';
import type { User, RiskScoreEvent, Insight, RiskScoreEventsFilters } from '@/types/user';
import { UserService } from '@/services/user.service';
import { UserDetailsForModerator } from '@/types/user';
Expand Down Expand Up @@ -149,6 +150,45 @@ export function useAuthorInfo(authorId: number | null): UseFetchAuthorInfoReturn
return [{ author, isLoading, error }, fetchAuthorInfo];
}

interface UseAuthorVerificationMethodsState {
methods: VerificationMethod[] | undefined;
isLoading: boolean;
}

type LoadVerificationMethodsFn = () => void;

type UseAuthorVerificationMethodsReturn = [
UseAuthorVerificationMethodsState,
LoadVerificationMethodsFn,
];

/**
* List payloads only carry `is_verified`, so unless `knownMethods` is passed,
* the methods load from the author's overview on demand.
*/
export function useAuthorVerificationMethods(
authorId: number | undefined,
knownMethods: VerificationMethod[] | undefined
): UseAuthorVerificationMethodsReturn {
const [loaded, setLoaded] = useState<{ authorId: number; methods: VerificationMethod[] }>();
const [isLoading, setIsLoading] = useState(false);
const methods = knownMethods ?? (loaded?.authorId === authorId ? loaded?.methods : undefined);

const loadVerificationMethods = () => {
if (!authorId || methods || isLoading) return;
setIsLoading(true);
AuthorService.getAuthorInfo(authorId)
.then((user) =>
setLoaded({ authorId, methods: user.authorProfile?.verificationMethods ?? [] })
)
// Leave the methods unloaded so the next hover retries.
.catch(() => {})
.finally(() => setIsLoading(false));
};

return [{ methods, isLoading }, loadVerificationMethods];
}

interface UseUserDetailsForModeratorState {
userDetails: UserDetailsForModerator | null;
isLoading: boolean;
Expand Down
5 changes: 5 additions & 0 deletions types/authorProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface Education {
is_public: boolean;
}

export type VerificationMethod = 'PERSONA' | 'MANUAL' | 'X' | 'EDU_EMAIL';

export interface AuthorProfile {
id: number;
fullName: string;
Expand All @@ -41,6 +43,8 @@ export interface AuthorProfile {
isOrcidConnected?: boolean;
isClaimed: boolean;
isVerified: boolean;
/** Only sent for a single author; undefined means not loaded. */
verificationMethods?: VerificationMethod[];
userId?: number;
editorOfHubs?: Topic[];
isHubEditor?: boolean;
Expand Down Expand Up @@ -99,6 +103,7 @@ export const transformAuthorProfile = createTransformer<any, AuthorProfile>((raw
isClaimed: isClaimed,
userId: raw.user_id || undefined,
isVerified: raw.is_verified || false,
verificationMethods: raw.verification_methods,
isHubEditor: raw.is_hub_editor || false,
editorOfHubs: (raw.is_hub_editor_of || []).map((topic: any) => transformTopic(topic)),
};
Expand Down
Loading