diff --git a/components/Activity/cards/ActivityAuthorSummary.tsx b/components/Activity/cards/ActivityAuthorSummary.tsx index dcd1f3d9f..840e25d60 100644 --- a/components/Activity/cards/ActivityAuthorSummary.tsx +++ b/components/Activity/cards/ActivityAuthorSummary.tsx @@ -26,7 +26,7 @@ function AuthorName({ author }: Readonly<{ author: AuthorProfile }>) { {name} )} {isVerifiedAuthor(author) && ( - + )} ); diff --git a/components/Activity/cards/ActivityHeaderActionText.tsx b/components/Activity/cards/ActivityHeaderActionText.tsx index d6e6a67d7..1d7bfeb3a 100644 --- a/components/Activity/cards/ActivityHeaderActionText.tsx +++ b/components/Activity/cards/ActivityHeaderActionText.tsx @@ -44,7 +44,7 @@ function AuthorName({ const badges = ( <> {isVerifiedAuthor(author) && ( - + )} {showAuthorBadge && } diff --git a/components/Feed/FeedItemHeader.tsx b/components/Feed/FeedItemHeader.tsx index af03433a9..0fcab9005 100644 --- a/components/Feed/FeedItemHeader.tsx +++ b/components/Feed/FeedItemHeader.tsx @@ -104,7 +104,7 @@ export const FeedItemHeader: FC = ({ )} {/* Show verified badge if user is verified - check both direct user prop and author */} {(user?.isVerified || author?.isVerified || author?.user?.isVerified) && ( - + )} {/* Show AuthorBadge with priority over EditorBadge - only if not hidden */} {!hideAuthorBadge && isAuthorOfWork && ( diff --git a/components/profile/ProfileHeroBanner.tsx b/components/profile/ProfileHeroBanner.tsx index f8304b864..1152649aa 100644 --- a/components/profile/ProfileHeroBanner.tsx +++ b/components/profile/ProfileHeroBanner.tsx @@ -83,7 +83,12 @@ export function ProfileHeroBanner({ author, refetchAuthorInfo, tabBar }: Profile

{author.fullName}

- {author.isVerified && } + {author.isVerified && ( + + )} {author.headline && (

{author.headline}

diff --git a/components/ui/Tooltip.tsx b/components/ui/Tooltip.tsx index a687e7079..30ea5971d 100644 --- a/components/ui/Tooltip.tsx +++ b/components/ui/Tooltip.tsx @@ -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; diff --git a/components/ui/VerifiedBadge.tsx b/components/ui/VerifiedBadge.tsx index 69762e3fa..bbc78972d 100644 --- a/components/ui/VerifiedBadge.tsx +++ b/components/ui/VerifiedBadge.tsx @@ -1,9 +1,13 @@ 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'; @@ -11,16 +15,44 @@ interface VerifiedBadgeProps { 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 = { + PERSONA: { + label: 'Identity verified by Persona', + icon: , + }, + MANUAL: { + label: 'Identity verified by ResearchHub', + icon: , + }, + X: { + label: 'Identity verified by X', + icon: , + }, + EDU_EMAIL: { + label: 'Verified academic email', + icon: , + }, +}; + 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 = { @@ -40,6 +72,7 @@ export function VerifiedBadge({ showTooltip && 'cursor-pointer', className )} + onPointerEnter={showTooltip ? loadVerificationMethods : undefined} > { + if (isLoading) { + return
Loading verifications...
; + } + if (!methods?.length) { + return
This user has verified their identity
; + } + return ( +
    + {methods.map((method) => ( +
  • + {METHOD_DETAILS[method].icon} + {METHOD_DETAILS[method].label} +
  • + ))} +
+ ); + }; + const tooltipContent = (
@@ -67,7 +119,7 @@ export function VerifiedBadge({
Verified Profile
-
This user has verified their identity
+ {renderVerificationMethods()} 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; diff --git a/types/authorProfile.ts b/types/authorProfile.ts index 12a3ea6e8..4f091e616 100644 --- a/types/authorProfile.ts +++ b/types/authorProfile.ts @@ -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; @@ -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; @@ -99,6 +103,7 @@ export const transformAuthorProfile = createTransformer((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)), };