From 2be586c8a398304f4c09922af485d5d4231280f0 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Thu, 22 Oct 2026 10:39:14 -0400 Subject: [PATCH 1/7] Adding verification label update for X --- components/profile/ModerationTab.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/components/profile/ModerationTab.tsx b/components/profile/ModerationTab.tsx index 1bd4efd51..ccf8d5a56 100644 --- a/components/profile/ModerationTab.tsx +++ b/components/profile/ModerationTab.tsx @@ -57,6 +57,8 @@ function buildInsights( ): InsightItem[] { const items: InsightItem[] = []; const verificationTooltip = buildVerificationTooltip(userDetails); + const verifiedVia = userDetails.verification?.verifiedVia; + const verificationLabel = `Verified via ${snakeCaseToTitleCase(verifiedVia ?? '')}`; const hasPersonaInsight = backendInsights.some((insight) => isPersonaVerificationEvent(insight.eventType) ); @@ -64,7 +66,7 @@ function buildInsights( if (!hasPersonaInsight) { items.push( verificationTooltip - ? { label: 'Identity Verified', tone: 'good', tooltip: verificationTooltip } + ? { label: verificationLabel, tone: 'good', tooltip: verificationTooltip } : { label: 'Not Verified', tone: 'bad', tooltip: 'No identity verification on file' } ); } @@ -86,12 +88,16 @@ function buildInsights( } for (const insight of backendInsights) { - const tooltip = - isPersonaVerificationEvent(insight.eventType) && verificationTooltip - ? verificationTooltip - : getInsightTooltip(insight); + const isVerificationInsight = + isPersonaVerificationEvent(insight.eventType) && !!verificationTooltip; + const tooltip = isVerificationInsight ? verificationTooltip : getInsightTooltip(insight); + // The backend records a Persona event for every approved verification, X and manual included. + const label = + isVerificationInsight && verifiedVia !== 'PERSONA' + ? verificationLabel + : formatInsightLabel(insight.eventType, insight.count); items.push({ - label: formatInsightLabel(insight.eventType, insight.count), + label, tone: getInsightTone(insight), tooltip, }); From db6451097c88ccfa9eb9e08fcc10febba54f9744 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Thu, 22 Oct 2026 12:30:04 -0400 Subject: [PATCH 2/7] Adding Small Verification Signal for Moderators for now --- components/profile/ModerationTab.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/profile/ModerationTab.tsx b/components/profile/ModerationTab.tsx index ccf8d5a56..a73a5d15e 100644 --- a/components/profile/ModerationTab.tsx +++ b/components/profile/ModerationTab.tsx @@ -219,12 +219,14 @@ export function ModerationTab({ userId, authorId, refetchAuthorInfo }: Moderatio const userIdDisplay = String(userDetails.id ?? ''); const verificationId = userDetails.verification?.externalId || ''; + const verifiedVia = userDetails.verification?.verifiedVia; const detailItems: { label: string; value: string; copyable?: boolean }[] = [ { label: 'Email', value: userDetails.email || 'N/A' }, { label: 'User ID', value: userIdDisplay || 'N/A', copyable: !!userIdDisplay }, { label: 'Verified name', value: verifiedName }, { label: 'Verification ID', value: verificationId || 'N/A', copyable: !!verificationId }, + { label: 'Verified by', value: verifiedVia ? snakeCaseToTitleCase(verifiedVia) : 'N/A' }, { label: 'ORCID Email', value: userDetails.orcidVerifiedEduEmail || 'N/A' }, { label: 'ResearchHub AI tier', From 0f4ef48825ad8d434850b950cffe87f5ef7cdbd7 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Mon, 26 Oct 2026 19:11:40 -0400 Subject: [PATCH 3/7] Small Visual Updates for UX --- .../Activity/cards/ActivityAuthorSummary.tsx | 2 +- .../cards/ActivityHeaderActionText.tsx | 2 +- components/Feed/FeedItemHeader.tsx | 2 +- components/profile/ModerationTab.tsx | 26 ++++--- components/profile/ProfileHeroBanner.tsx | 7 +- components/ui/Tooltip.tsx | 71 +++++++++++-------- components/ui/VerifiedBadge.tsx | 54 +++++++++++++- hooks/useAuthor.ts | 37 ++++++++++ types/authorProfile.ts | 5 ++ 9 files changed, 156 insertions(+), 50 deletions(-) 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/ModerationTab.tsx b/components/profile/ModerationTab.tsx index a73a5d15e..4f664ee0a 100644 --- a/components/profile/ModerationTab.tsx +++ b/components/profile/ModerationTab.tsx @@ -57,8 +57,6 @@ function buildInsights( ): InsightItem[] { const items: InsightItem[] = []; const verificationTooltip = buildVerificationTooltip(userDetails); - const verifiedVia = userDetails.verification?.verifiedVia; - const verificationLabel = `Verified via ${snakeCaseToTitleCase(verifiedVia ?? '')}`; const hasPersonaInsight = backendInsights.some((insight) => isPersonaVerificationEvent(insight.eventType) ); @@ -66,7 +64,7 @@ function buildInsights( if (!hasPersonaInsight) { items.push( verificationTooltip - ? { label: verificationLabel, tone: 'good', tooltip: verificationTooltip } + ? { label: 'Identity Verified', tone: 'good', tooltip: verificationTooltip } : { label: 'Not Verified', tone: 'bad', tooltip: 'No identity verification on file' } ); } @@ -88,16 +86,12 @@ function buildInsights( } for (const insight of backendInsights) { - const isVerificationInsight = - isPersonaVerificationEvent(insight.eventType) && !!verificationTooltip; - const tooltip = isVerificationInsight ? verificationTooltip : getInsightTooltip(insight); - // The backend records a Persona event for every approved verification, X and manual included. - const label = - isVerificationInsight && verifiedVia !== 'PERSONA' - ? verificationLabel - : formatInsightLabel(insight.eventType, insight.count); + const tooltip = + isPersonaVerificationEvent(insight.eventType) && verificationTooltip + ? verificationTooltip + : getInsightTooltip(insight); items.push({ - label, + label: formatInsightLabel(insight.eventType, insight.count), tone: getInsightTone(insight), tooltip, }); @@ -219,14 +213,18 @@ export function ModerationTab({ userId, authorId, refetchAuthorInfo }: Moderatio const userIdDisplay = String(userDetails.id ?? ''); const verificationId = userDetails.verification?.externalId || ''; - const verifiedVia = userDetails.verification?.verifiedVia; const detailItems: { label: string; value: string; copyable?: boolean }[] = [ { label: 'Email', value: userDetails.email || 'N/A' }, { label: 'User ID', value: userIdDisplay || 'N/A', copyable: !!userIdDisplay }, { label: 'Verified name', value: verifiedName }, { label: 'Verification ID', value: verificationId || 'N/A', copyable: !!verificationId }, - { label: 'Verified by', value: verifiedVia ? snakeCaseToTitleCase(verifiedVia) : 'N/A' }, + { + label: 'Verified via', + value: userDetails.verification + ? snakeCaseToTitleCase(userDetails.verification.verifiedVia) + : 'N/A', + }, { label: 'ORCID Email', value: userDetails.orcidVerifiedEduEmail || 'N/A' }, { label: 'ResearchHub AI tier', 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..4f011e698 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: 'Government ID verified', + 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 [loadedMethods, setLoadedMethods] = useState(); + const [isLoading, setIsLoading] = useState(false); + const methods = knownMethods ?? loadedMethods; + + const loadVerificationMethods = () => { + if (!authorId || methods || isLoading) return; + setIsLoading(true); + AuthorService.getAuthorInfo(authorId) + .then((user) => setLoadedMethods(user.authorProfile?.verificationMethods ?? [])) + .catch(() => setLoadedMethods([])) + .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)), }; From 0cce26567b5f5b4e290e5531082761944f42f4c5 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Tue, 27 Oct 2026 11:18:42 -0400 Subject: [PATCH 4/7] Removing verified via addition for moderation tab --- components/profile/ModerationTab.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/components/profile/ModerationTab.tsx b/components/profile/ModerationTab.tsx index 4f664ee0a..1bd4efd51 100644 --- a/components/profile/ModerationTab.tsx +++ b/components/profile/ModerationTab.tsx @@ -219,12 +219,6 @@ export function ModerationTab({ userId, authorId, refetchAuthorInfo }: Moderatio { label: 'User ID', value: userIdDisplay || 'N/A', copyable: !!userIdDisplay }, { label: 'Verified name', value: verifiedName }, { label: 'Verification ID', value: verificationId || 'N/A', copyable: !!verificationId }, - { - label: 'Verified via', - value: userDetails.verification - ? snakeCaseToTitleCase(userDetails.verification.verifiedVia) - : 'N/A', - }, { label: 'ORCID Email', value: userDetails.orcidVerifiedEduEmail || 'N/A' }, { label: 'ResearchHub AI tier', From ca641ded7af90581f4b9837d95453f35d4106fb1 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Tue, 27 Oct 2026 11:46:28 -0400 Subject: [PATCH 5/7] Feedback Updates --- hooks/useAuthor.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/hooks/useAuthor.ts b/hooks/useAuthor.ts index 1be7058f2..66d3a7905 100644 --- a/hooks/useAuthor.ts +++ b/hooks/useAuthor.ts @@ -170,16 +170,19 @@ export function useAuthorVerificationMethods( authorId: number | undefined, knownMethods: VerificationMethod[] | undefined ): UseAuthorVerificationMethodsReturn { - const [loadedMethods, setLoadedMethods] = useState(); + const [loaded, setLoaded] = useState<{ authorId: number; methods: VerificationMethod[] }>(); const [isLoading, setIsLoading] = useState(false); - const methods = knownMethods ?? loadedMethods; + const methods = knownMethods ?? (loaded?.authorId === authorId ? loaded?.methods : undefined); const loadVerificationMethods = () => { if (!authorId || methods || isLoading) return; setIsLoading(true); AuthorService.getAuthorInfo(authorId) - .then((user) => setLoadedMethods(user.authorProfile?.verificationMethods ?? [])) - .catch(() => setLoadedMethods([])) + .then((user) => + setLoaded({ authorId, methods: user.authorProfile?.verificationMethods ?? [] }) + ) + // Leave the methods unloaded so the next hover retries. + .catch(() => {}) .finally(() => setIsLoading(false)); }; From f4de83b40fe7928ac8a5828ec6afe3daaafb3dc2 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Wed, 28 Oct 2026 10:17:49 -0400 Subject: [PATCH 6/7] Updating Persona verified text --- components/ui/VerifiedBadge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ui/VerifiedBadge.tsx b/components/ui/VerifiedBadge.tsx index 4f011e698..7329e66f0 100644 --- a/components/ui/VerifiedBadge.tsx +++ b/components/ui/VerifiedBadge.tsx @@ -22,7 +22,7 @@ interface VerifiedBadgeProps { const METHOD_DETAILS: Record = { PERSONA: { - label: 'Government ID verified', + label: 'Identity verified by Persona', icon: , }, MANUAL: { From ee44d684819813b85079129374ccc8298b9a5870 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Wed, 28 Oct 2026 16:45:05 -0400 Subject: [PATCH 7/7] Updating icon colors to match X color --- components/ui/VerifiedBadge.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ui/VerifiedBadge.tsx b/components/ui/VerifiedBadge.tsx index 7329e66f0..bbc78972d 100644 --- a/components/ui/VerifiedBadge.tsx +++ b/components/ui/VerifiedBadge.tsx @@ -23,11 +23,11 @@ interface VerifiedBadgeProps { const METHOD_DETAILS: Record = { PERSONA: { label: 'Identity verified by Persona', - icon: , + icon: , }, MANUAL: { label: 'Identity verified by ResearchHub', - icon: , + icon: , }, X: { label: 'Identity verified by X', @@ -35,7 +35,7 @@ const METHOD_DETAILS: Record, + icon: , }, };