diff --git a/.gitignore b/.gitignore index 1a123e9d39..489090c747 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /test-results/ /playwright-report/ /playwright/.cache/ +/.playwright-mcp/ /storageState.json /playwright/.auth/ storageState.json diff --git a/.playwright-mcp/page-2026-03-31T06-27-58-535Z.yml b/.playwright-mcp/page-2026-03-31T06-27-58-535Z.yml deleted file mode 100644 index 26c1b8c153..0000000000 --- a/.playwright-mcp/page-2026-03-31T06-27-58-535Z.yml +++ /dev/null @@ -1,9 +0,0 @@ -- generic [active]: - - button "Open Next.js Dev Tools" [ref=e6] [cursor=pointer]: - - generic [ref=e9]: - - text: Compiling - - generic [ref=e10]: - - generic [ref=e11]: . - - generic [ref=e12]: . - - generic [ref=e13]: . - - alert [ref=e14] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-03-31T06-28-02-990Z.png b/.playwright-mcp/page-2026-03-31T06-28-02-990Z.png deleted file mode 100644 index 2b62688cb1..0000000000 Binary files a/.playwright-mcp/page-2026-03-31T06-28-02-990Z.png and /dev/null differ diff --git a/.playwright-mcp/page-2026-03-31T13-04-45-965Z.yml b/.playwright-mcp/page-2026-03-31T13-04-45-965Z.yml deleted file mode 100644 index 8055a87948..0000000000 --- a/.playwright-mcp/page-2026-03-31T13-04-45-965Z.yml +++ /dev/null @@ -1,4 +0,0 @@ -- generic [active]: - - button "Open Next.js Dev Tools" [ref=e6] [cursor=pointer]: - - img [ref=e7] - - alert [ref=e10] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-03-31T13-15-19-347Z.yml b/.playwright-mcp/page-2026-03-31T13-15-19-347Z.yml deleted file mode 100644 index 8055a87948..0000000000 --- a/.playwright-mcp/page-2026-03-31T13-15-19-347Z.yml +++ /dev/null @@ -1,4 +0,0 @@ -- generic [active]: - - button "Open Next.js Dev Tools" [ref=e6] [cursor=pointer]: - - img [ref=e7] - - alert [ref=e10] \ No newline at end of file diff --git a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx index a4deeb2e76..ef65beafce 100644 --- a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx +++ b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx @@ -296,7 +296,7 @@ const MyStreamWaveLeaderboard: React.FC = ({ {/* Content section */} -
+
{showToggleableDropInput && ( { + const trimmedBio = bio?.trim(); + return trimmedBio && trimmedBio.length > 0 ? trimmedBio : null; +}; + +const getRepCategories = ( + categories: readonly ApiProfileRepCategorySummary[] | null | undefined +): ApiProfileRepCategorySummary[] => + (categories ?? []).filter((category) => category.category.trim().length > 0); + +const formatSignedRep = (rep: number): string => { + const formattedValue = formatNumberWithCommas(Math.abs(rep)); + + if (rep > 0) { + return `+${formattedValue}`; + } + + if (rep < 0) { + return `-${formattedValue}`; + } + + return formattedValue; +}; + +export default function IdentityProfileSupplement({ + profile, + variant, + maxRepCategories, +}: IdentityProfileSupplementProps) { + const bio = normalizeBio(profile.bio); + const repCategories = getRepCategories(profile.top_rep_categories); + const visibleRepCategories = + maxRepCategories === undefined + ? repCategories + : repCategories.slice(0, maxRepCategories); + + if (!bio && visibleRepCategories.length === 0) { + return null; + } + + return ( +
+ {bio && ( +

+ {bio} +

+ )} + + {visibleRepCategories.length > 0 && ( +
+ {visibleRepCategories.map((category) => ( + + {category.category} + + {formatSignedRep(category.rep)} + + + ))} +
+ )} +
+ ); +} diff --git a/components/waves/drops/identity/WaveDropIdentity.tsx b/components/waves/drops/identity/WaveDropIdentity.tsx index 127b4a1b5a..64c0d40428 100644 --- a/components/waves/drops/identity/WaveDropIdentity.tsx +++ b/components/waves/drops/identity/WaveDropIdentity.tsx @@ -1,6 +1,7 @@ import ParticipationIdentityProfileCard, { type ParticipationIdentityProfileCardVariant, } from "@/components/waves/drops/participation/ParticipationIdentityProfileCard"; +import IdentityProfileSupplement from "@/components/waves/drops/identity/IdentityProfileSupplement"; import type { ApiDropMetadataResponse } from "@/generated/models/ApiDropMetadataResponse"; import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; import Link from "next/link"; @@ -104,24 +105,34 @@ export function WaveDropIdentity({
- - Identity - - {identityProfile ? ( - event.stopPropagation()} - className="tw-min-w-0 tw-truncate tw-text-sm tw-font-semibold tw-text-iron-100 tw-no-underline tw-transition tw-duration-300 tw-ease-out desktop-hover:hover:tw-text-iron-300" - > - {displayLabel} - - ) : ( - - {displayLabel} +
+ + Identity + {identityProfile ? ( + event.stopPropagation()} + className="tw-min-w-0 tw-truncate tw-text-sm tw-font-semibold tw-text-iron-100 tw-no-underline tw-transition tw-duration-300 tw-ease-out desktop-hover:hover:tw-text-iron-300" + > + {displayLabel} + + ) : ( + + {displayLabel} + + )} +
+ + {identityProfile && ( + )}
diff --git a/components/waves/drops/identityDisplay.helpers.ts b/components/waves/drops/identityDisplay.helpers.ts index 41efa7d801..68be3c7bec 100644 --- a/components/waves/drops/identityDisplay.helpers.ts +++ b/components/waves/drops/identityDisplay.helpers.ts @@ -1,5 +1,5 @@ import type { ApiDropMetadataResponse } from "@/generated/models/ApiDropMetadataResponse"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiDropResolvedIdentityProfile } from "@/generated/models/ApiDropResolvedIdentityProfile"; import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; import { @@ -33,7 +33,7 @@ export const getDropIdentityProfile = ({ }: { readonly wave: Pick | null | undefined; readonly metadata: readonly ApiDropMetadataResponse[] | null | undefined; -}): ApiProfileMin | null => { +}): ApiDropResolvedIdentityProfile | null => { if (!isIdentitySubmissionWave(wave)) { return null; } diff --git a/components/waves/drops/participation/EndedParticipationDrop.tsx b/components/waves/drops/participation/EndedParticipationDrop.tsx index 71eb6b63d1..24f310ebf7 100644 --- a/components/waves/drops/participation/EndedParticipationDrop.tsx +++ b/components/waves/drops/participation/EndedParticipationDrop.tsx @@ -5,6 +5,7 @@ import UserCICAndLevel, { } from "@/components/user/utils/UserCICAndLevel"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { getTimeAgoShort } from "@/helpers/Helpers"; +import { areSameProfileIdentity } from "@/helpers/ProfileHelpers"; import { getWaveRoute } from "@/helpers/navigation.helpers"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import useIsMobileDevice from "@/hooks/isMobileDevice"; @@ -59,6 +60,12 @@ export default function EndedParticipationDrop({ wave: drop.wave, metadata: drop.metadata, }); + const isSelfNominee = identityProfile + ? areSameProfileIdentity({ + left: drop.author, + right: identityProfile, + }) + : false; const [activePartIndex, setActivePartIndex] = useState(0); const [longPressTriggered, setLongPressTriggered] = useState(false); @@ -200,11 +207,12 @@ export default function EndedParticipationDrop({
{identityProfile && ( -
+
)} diff --git a/components/waves/drops/participation/OngoingParticipationDrop.tsx b/components/waves/drops/participation/OngoingParticipationDrop.tsx index 0bfb5a0ed8..bd872e3893 100644 --- a/components/waves/drops/participation/OngoingParticipationDrop.tsx +++ b/components/waves/drops/participation/OngoingParticipationDrop.tsx @@ -3,6 +3,7 @@ import { MobileVotingModal, VotingModal } from "@/components/voting"; import VotingModalButton from "@/components/voting/VotingModalButton"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { areSameProfileIdentity } from "@/helpers/ProfileHelpers"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { useCallback, useState } from "react"; @@ -59,6 +60,12 @@ export default function OngoingParticipationDrop({ wave: drop.wave, metadata: drop.metadata, }); + const isSelfNominee = identityProfile + ? areSameProfileIdentity({ + left: drop.author, + right: identityProfile, + }) + : false; const [activePartIndex, setActivePartIndex] = useState(0); const [longPressTriggered, setLongPressTriggered] = useState(false); @@ -117,11 +124,12 @@ export default function OngoingParticipationDrop({
{identityProfile && ( -
+
)} diff --git a/components/waves/drops/participation/ParticipationIdentityProfileCard.tsx b/components/waves/drops/participation/ParticipationIdentityProfileCard.tsx index c55d55bbd5..10038ce1c3 100644 --- a/components/waves/drops/participation/ParticipationIdentityProfileCard.tsx +++ b/components/waves/drops/participation/ParticipationIdentityProfileCard.tsx @@ -1,20 +1,35 @@ +"use client"; + import DropPfp from "@/components/drops/create/utils/DropPfp"; import { DropAuthorBadges } from "@/components/waves/drops/DropAuthorBadges"; import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiProfileRepCategorySummary } from "@/generated/models/ApiProfileRepCategorySummary"; import { formatStatFloor } from "@/helpers/Helpers"; import { shortenAddress } from "@/helpers/address.helpers"; import { ProfileBadgeSize } from "@/components/common/profile/ProfileAvatar"; +import { useCompactMode } from "@/contexts/CompactModeContext"; import Link from "next/link"; import UserCICAndLevel, { UserCICAndLevelSize, } from "@/components/user/utils/UserCICAndLevel"; +import IdentityProfileSupplement from "@/components/waves/drops/identity/IdentityProfileSupplement"; export type ParticipationIdentityProfileCardVariant = "default" | "chat"; +type ParticipationIdentityProfileCardProfile = ApiProfileMin & { + readonly bio?: string | null | undefined; + readonly top_rep_categories?: + | readonly ApiProfileRepCategorySummary[] + | null + | undefined; +}; + interface ParticipationIdentityProfileCardProps { - readonly profile: ApiProfileMin; + readonly profile: ParticipationIdentityProfileCardProfile; readonly contextId?: string | number | undefined; readonly variant?: ParticipationIdentityProfileCardVariant | undefined; + readonly showIdentityHeader?: boolean | undefined; + readonly supplementFullWidth?: boolean | undefined; } interface IdentityStatLinkProps { @@ -25,6 +40,15 @@ interface IdentityStatLinkProps { readonly compact?: boolean | undefined; } +const IDENTITY_STAT_LINK_CLASS = + "tw-inline-flex tw-items-baseline tw-gap-1.5 tw-no-underline tw-transition-colors tw-duration-300 tw-ease-out desktop-hover:hover:tw-text-white"; +const IDENTITY_STAT_VALUE_CLASS = + "tw-font-semibold tw-leading-none tw-text-iron-100"; +const IDENTITY_STAT_LABEL_CLASS = + "tw-text-xs tw-font-medium tw-leading-none tw-tracking-wide tw-text-iron-500 tw-uppercase"; +const IDENTITY_STAT_RATE_CLASS = + "tw-text-xs tw-font-medium tw-leading-none tw-text-emerald-400"; + function IdentityStatLink({ href, label, @@ -32,30 +56,21 @@ function IdentityStatLink({ rate, compact = false, }: IdentityStatLinkProps) { - const valueClass = compact - ? "tw-text-[13px] tw-font-semibold tw-leading-none tw-text-iron-200" - : "tw-text-sm tw-font-semibold tw-text-iron-200"; - const labelClass = compact - ? "tw-text-[13px] tw-font-medium tw-leading-none tw-tracking-wide tw-text-iron-500 tw-uppercase" - : "tw-text-sm tw-font-medium tw-tracking-wide tw-text-iron-500 tw-uppercase"; - const rateClass = compact - ? "tw-text-[13px] tw-font-semibold tw-leading-none tw-text-emerald-500" - : "tw-text-xs tw-font-semibold tw-leading-4 tw-text-emerald-500"; + const valueClass = `${compact ? "tw-text-xs" : "tw-text-sm"} ${IDENTITY_STAT_VALUE_CLASS}`; return ( event.stopPropagation()} - className="tw-no-underline tw-transition tw-duration-300 tw-ease-out desktop-hover:hover:tw-underline" + className={IDENTITY_STAT_LINK_CLASS} > - {formatStatFloor(value)}{" "} - {label} + {formatStatFloor(value)} + {label} {typeof rate === "number" && rate > 0 && ( - <> - {" "} - +{formatStatFloor(rate)} - + + +{formatStatFloor(rate)} + )} ); @@ -65,8 +80,13 @@ export default function ParticipationIdentityProfileCard({ profile, contextId, variant = "default", + showIdentityHeader = true, + supplementFullWidth: _supplementFullWidth = false, }: ParticipationIdentityProfileCardProps) { + const compact = useCompactMode(); const isChat = variant === "chat"; + const avatarSize = + isChat && compact ? ProfileBadgeSize.COMPACT : ProfileBadgeSize.MEDIUM; const profileLabel = profile.handle ?? profile.primary_address; const routeIdentity = encodeURIComponent(profileLabel.toLowerCase()); const rootHref = `/${routeIdentity}`; @@ -76,105 +96,140 @@ export default function ParticipationIdentityProfileCard({ const displayAddress = shouldShowAddress ? shortenAddress(profile.primary_address) : null; - const containerClassName = isChat - ? "tw-mt-4 tw-relative tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-iron-800/80 tw-bg-iron-900/60 tw-px-3 tw-py-2.5" - : "tw-mt-3 tw-relative tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-iron-800/80 tw-bg-iron-900/60 tw-p-4"; - const headerRowClassName = isChat - ? "tw-flex tw-min-w-0 tw-items-center tw-gap-2.5" - : "tw-flex tw-min-w-0 tw-items-start tw-gap-3"; - const metaColumnClassName = isChat - ? "tw-flex tw-min-w-0 tw-flex-col tw-justify-center tw-gap-y-0.5" - : "tw-flex tw-min-w-0 tw-flex-col tw-gap-y-1"; - const nameClassName = "tw-text-sm tw-font-semibold tw-leading-none"; - const levelSize = isChat - ? UserCICAndLevelSize.COMPACT - : UserCICAndLevelSize.SMALL; - const badgeContainerClassName = isChat - ? "tw-inline-flex tw-items-center tw-gap-x-1" - : undefined; - const statsClassName = isChat - ? "tw-mt-3 tw-flex tw-flex-wrap tw-items-center tw-gap-x-4 tw-gap-y-1.5 tw-border-x-0 tw-border-b-0 tw-border-t tw-border-solid tw-border-white/5 tw-pt-1.5" - : "tw-mt-4 tw-flex tw-flex-wrap tw-items-center tw-gap-x-4 tw-gap-y-1.5 tw-border-x-0 tw-border-b-0 tw-border-t tw-border-solid tw-border-white/5 tw-pt-2"; + const hasSupplementContent = + !!profile.bio?.trim() || + (profile.top_rep_categories ?? []).some( + (category) => category.category.trim().length > 0 + ); + const shouldShowStatsDivider = showIdentityHeader || hasSupplementContent; + const showBelowHeaderSupplement = hasSupplementContent; + const belowHeaderSupplementClassName = (() => { + if (!showIdentityHeader) { + return undefined; + } + + return isChat ? "tw-mt-3" : "tw-mt-4"; + })(); return ( -
-
-
- event.stopPropagation()} - className="tw-flex-shrink-0 tw-no-underline" - aria-label={`View ${profileLabel}'s profile`} - > - - +
+
+ {/* Subtle top glare for premium feel */} +
+
-
-
+ {showIdentityHeader && ( +
+
event.stopPropagation()} - className="tw-mb-0 tw-text-iron-200 tw-no-underline tw-transition tw-duration-300 tw-ease-out desktop-hover:hover:tw-text-opacity-80 desktop-hover:hover:tw-underline" + className="tw-block tw-flex-shrink-0 tw-self-start tw-no-underline" + aria-label={`View ${profileLabel}'s profile`} > - {profileLabel} + - +
+
+ event.stopPropagation()} + className="tw-mb-0 tw-text-iron-200 tw-no-underline tw-transition tw-duration-300 tw-ease-out desktop-hover:hover:tw-text-opacity-80 desktop-hover:hover:tw-underline" + > + + {profileLabel} + + + + + + +
- + {displayAddress && ( +

+ {displayAddress} +

+ )} +
+
+ )} - {displayAddress && ( -

- {displayAddress} -

- )} + {showBelowHeaderSupplement && ( +
+
-
-
+ )} -
- - - - +
+ + + + +
); diff --git a/components/waves/drops/participation/participationIdentityProfile.helpers.ts b/components/waves/drops/participation/participationIdentityProfile.helpers.ts index 12b482f4f6..2ccb799f43 100644 --- a/components/waves/drops/participation/participationIdentityProfile.helpers.ts +++ b/components/waves/drops/participation/participationIdentityProfile.helpers.ts @@ -3,7 +3,7 @@ import { getDropVisibleMetadata, } from "@/components/waves/drops/identityDisplay.helpers"; import type { ApiDropMetadataResponse } from "@/generated/models/ApiDropMetadataResponse"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiDropResolvedIdentityProfile } from "@/generated/models/ApiDropResolvedIdentityProfile"; import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; export const getParticipationIdentityProfile = ({ @@ -12,7 +12,7 @@ export const getParticipationIdentityProfile = ({ }: { readonly wave: Pick | null | undefined; readonly metadata: readonly ApiDropMetadataResponse[] | null | undefined; -}): ApiProfileMin | null => { +}): ApiDropResolvedIdentityProfile | null => { return getDropIdentityProfile({ wave, metadata }); }; diff --git a/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx b/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx index 86d2674123..41326442f4 100644 --- a/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx +++ b/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx @@ -6,7 +6,11 @@ import WaveDropContent from "@/components/waves/drops/WaveDropContent"; import WaveDropMetadata from "@/components/waves/drops/WaveDropMetadata"; import { useRouter } from "next/navigation"; import WaveDropReactions from "@/components/waves/drops/WaveDropReactions"; -import { getDropVisibleMetadata } from "@/components/waves/drops/identityDisplay.helpers"; +import { + getDropIdentityProfile, + getDropVisibleMetadata, +} from "@/components/waves/drops/identityDisplay.helpers"; +import { areSameProfileIdentity } from "@/helpers/ProfileHelpers"; import { getWaveRoute } from "@/helpers/navigation.helpers"; import { WaveLeaderboardIdentity } from "../identity/WaveLeaderboardIdentity"; @@ -24,6 +28,16 @@ export const WaveLeaderboardDropContent: React.FC< wave: drop.wave, metadata: drop.metadata, }); + const identityProfile = getDropIdentityProfile({ + wave: drop.wave, + metadata: drop.metadata, + }); + const isSelfNominee = identityProfile + ? areSameProfileIdentity({ + left: drop.author, + right: identityProfile, + }) + : false; const onDropContentClick = (clickedDrop: ExtendedDrop) => { const href = getWaveRoute({ @@ -52,6 +66,7 @@ export const WaveLeaderboardDropContent: React.FC< variant="responsive" cardVariant="chat" className="tw-mt-2 lg:tw-mt-0" + showIdentityHeader={!isSelfNominee} /> {!!visibleMetadata.length && ( diff --git a/components/waves/leaderboard/drops/header/WaveleaderboardDropRaters.tsx b/components/waves/leaderboard/drops/header/WaveleaderboardDropRaters.tsx index 169a1544c1..56a6526a9f 100644 --- a/components/waves/leaderboard/drops/header/WaveleaderboardDropRaters.tsx +++ b/components/waves/leaderboard/drops/header/WaveleaderboardDropRaters.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import Image from "next/image"; import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; import DropVoteProgressing from "@/components/drops/view/utils/DropVoteProgressing"; +import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; import { WAVE_VOTING_LABELS, WAVE_VOTE_STATS_LABELS, @@ -81,18 +82,15 @@ export const WaveLeaderboardDropRaters: React.FC<
- - {voterLabel} • {formatNumberWithCommas(voter.rating)}{" "} - {votingLabel} - + {voterLabel} • {formatNumberWithCommas(voter.rating)}{" "} + {votingLabel} ); diff --git a/components/waves/leaderboard/grid/WaveLeaderboardGridItem.tsx b/components/waves/leaderboard/grid/WaveLeaderboardGridItem.tsx index 88d2023818..bccd0d60b5 100644 --- a/components/waves/leaderboard/grid/WaveLeaderboardGridItem.tsx +++ b/components/waves/leaderboard/grid/WaveLeaderboardGridItem.tsx @@ -41,56 +41,81 @@ interface WaveLeaderboardGridItemProps { readonly onDropClick: (drop: ExtendedDrop) => void; } -export const WaveLeaderboardGridItem: React.FC< - WaveLeaderboardGridItemProps -> = ({ drop, mode, onDropClick }) => { - const isCompactMode = mode === "compact"; - const isContentOnlyMode = mode === "content_only"; - const activePart = drop.parts[0]; - const author = drop.author; - const authorHandle = author.handle ?? null; - const primaryMedia = activePart?.media[0]; - const isCuratable = drop.context_profile_context?.curatable ?? false; - const isCurated = drop.context_profile_context?.curated ?? false; - const canOpenDrop = drop.drop_type !== ApiDropType.Chat; +const getVoteStyle = (userVote: number): string => + userVote <= 0 ? "tw-text-iron-400" : "tw-text-iron-300"; + +const canOpenGridItemFromClick = ({ + isMenuOpen, + target, +}: { + readonly isMenuOpen: boolean; + readonly target: HTMLElement; +}): boolean => { + if (isMenuOpen) { + return false; + } - const cardClassName = - "tw-cursor-pointer tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-iron-800 tw-bg-iron-950 tw-p-0 tw-transition desktop-hover:hover:tw-border-iron-700"; - const viewportClassName = isCompactMode + return !target.closest("a, button"); +}; + +const isGridItemOpenKey = (key: string): boolean => + key === "Enter" || key === " "; + +const getGridViewportClassName = (isCompactMode: boolean): string => + isCompactMode ? "tw-relative tw-overflow-hidden tw-bg-iron-950" - : "tw-relative tw-overflow-hidden tw-max-h-[20rem] tw-bg-iron-950"; - const contentSpacingClass = isCompactMode ? "tw-space-y-3" : "tw-space-y-1"; - const mediaWrapperClass = isCompactMode + : "tw-relative tw-max-h-[20rem] tw-overflow-hidden tw-bg-iron-950"; + +const getGridContentSpacingClassName = (isCompactMode: boolean): string => + isCompactMode ? "tw-space-y-3" : "tw-space-y-1"; + +const getGridMediaWrapperClassName = (isCompactMode: boolean): string => + isCompactMode ? "tw-overflow-hidden tw-rounded-lg tw-bg-iron-900" : "tw-overflow-hidden tw-bg-iron-950"; - const isMobileScreen = useIsMobileScreen(); - const { hasTouchScreen } = useDeviceInfo(); - const { toggleCuration, isPending: isCurating } = useDropCurationMutation(); - const { isActive, setIsActive, touchHandlers } = useLongPressInteraction({ - hasTouchScreen, - preventDefault: false, - }); - const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); - const { canShowVote } = useDropInteractionRules(drop); - const [viewportEl, setViewportEl] = useState(null); - const [innerEl, setInnerEl] = useState(null); - const hasContentOnlyActions = canOpenDrop || isCuratable || canShowVote; - const showDesktopContentOnlyActions = - isContentOnlyMode && !hasTouchScreen && hasContentOnlyActions; - const showMobileContentOnlyActions = - isContentOnlyMode && hasTouchScreen && hasContentOnlyActions; - const previewImageUrl = useMemo( - () => getDropPreviewImageUrl(drop.metadata), - [drop.metadata] - ); +const getGridTextWrapperClassName = ({ + hasMedia, + isCompactMode, +}: { + readonly hasMedia: boolean; + readonly isCompactMode: boolean; +}): string => + `tw-px-3 ${hasMedia ? "tw-pt-2" : "tw-pt-3"} ${ + isCompactMode ? "tw-pb-4" : "tw-pb-3" + }`; - const mediaUrl = primaryMedia?.url ?? previewImageUrl ?? null; - const mediaMimeType = primaryMedia?.mime_type ?? "image/jpeg"; - const contentTextWrapperClass = mediaUrl - ? "tw-px-3 tw-pb-3 tw-pt-2" - : "tw-p-3"; +const getCompactTextViewportClassName = ( + isCompactMode: boolean +): string | undefined => + isCompactMode + ? "tw-relative tw-max-h-56 tw-overflow-hidden [&_p]:tw-whitespace-normal" + : undefined; +function GridItemRankBadge({ drop }: { readonly drop: ExtendedDrop }) { + if (drop.rank === null) { + return ( +
+ - +
+ ); + } + + return ( + + ); +} + +function useOverflowGradient({ + viewportEl, + innerEl, +}: { + readonly viewportEl: HTMLElement | null; + readonly innerEl: HTMLElement | null; +}): boolean { const getOverflowSnapshot = useCallback(() => { if (!viewportEl || !innerEl) { return false; @@ -125,20 +150,66 @@ export const WaveLeaderboardGridItem: React.FC< [innerEl, viewportEl] ); - const showGradient = useSyncExternalStore( + return useSyncExternalStore( subscribeToOverflow, getOverflowSnapshot, () => false ); +} + +export const WaveLeaderboardGridItem: React.FC< + WaveLeaderboardGridItemProps +> = ({ drop, mode, onDropClick }) => { + const isCompactMode = mode === "compact"; + const isContentOnlyMode = mode === "content_only"; + const activePart = drop.parts[0]; + const author = drop.author; + const authorHandle = author.handle ?? null; + const primaryMedia = activePart?.media[0]; + const isCuratable = drop.context_profile_context?.curatable ?? false; + const isCurated = drop.context_profile_context?.curated ?? false; + const canOpenDrop = drop.drop_type !== ApiDropType.Chat; + const isMobileScreen = useIsMobileScreen(); + const { hasTouchScreen } = useDeviceInfo(); + const { toggleCuration, isPending: isCurating } = useDropCurationMutation(); + const { isActive, setIsActive, touchHandlers } = useLongPressInteraction({ + hasTouchScreen, + preventDefault: false, + }); + const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); + const { canShowVote } = useDropInteractionRules(drop); + const [viewportEl, setViewportEl] = useState(null); + const [innerEl, setInnerEl] = useState(null); + const [compactTextViewportEl, setCompactTextViewportEl] = + useState(null); + const [compactTextInnerEl, setCompactTextInnerEl] = + useState(null); + const hasContentOnlyActions = canOpenDrop || isCuratable || canShowVote; + const showDesktopContentOnlyActions = + isContentOnlyMode && !hasTouchScreen && hasContentOnlyActions; + const showMobileContentOnlyActions = + isContentOnlyMode && hasTouchScreen && hasContentOnlyActions; + + const previewImageUrl = useMemo( + () => getDropPreviewImageUrl(drop.metadata), + [drop.metadata] + ); + + const mediaUrl = primaryMedia?.url ?? previewImageUrl ?? null; + const mediaMimeType = primaryMedia?.mime_type ?? "image/jpeg"; + const showGradient = useOverflowGradient({ + viewportEl, + innerEl, + }); + const showCompactTextGradient = useOverflowGradient({ + viewportEl: compactTextViewportEl, + innerEl: compactTextInnerEl, + }); const hasUserVoted = drop.context_profile_context?.rating !== undefined; const userVote = drop.context_profile_context?.rating ?? 0; const isNegativeVote = userVote < 0; - const isZeroVote = userVote === 0; - let voteStyle = "tw-text-iron-300"; - if (isZeroVote || isNegativeVote) { - voteStyle = "tw-text-iron-400"; - } + const voteStyle = getVoteStyle(userVote); const votingCreditType = drop.wave.voting_credit_type; const votingCreditLabels = WAVE_VOTING_LABELS as Partial< Record @@ -187,21 +258,35 @@ export const WaveLeaderboardGridItem: React.FC< }; const onCardClick: React.MouseEventHandler = (event) => { - if (showMobileContentOnlyActions && isActive) { - return; - } const target = event.target as HTMLElement; - if (target.closest("a, button")) { + if ( + !canOpenGridItemFromClick({ + isMenuOpen: showMobileContentOnlyActions && isActive, + target, + }) + ) { return; } openDrop(); }; const onCardKeyDown: React.KeyboardEventHandler = (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - openDrop(); + if (!isGridItemOpenKey(event.key)) { + return; } + + const target = event.target as HTMLElement; + if ( + !canOpenGridItemFromClick({ + isMenuOpen: showMobileContentOnlyActions && isActive, + target, + }) + ) { + return; + } + + event.preventDefault(); + openDrop(); }; return ( @@ -211,13 +296,19 @@ export const WaveLeaderboardGridItem: React.FC< data-testid={`wave-leaderboard-grid-item-${drop.id}`} onClick={onCardClick} onKeyDown={onCardKeyDown} - className={`${cardClassName} tw-group`} + className="tw-group tw-cursor-pointer tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-iron-800 tw-bg-iron-950 tw-p-0 tw-transition desktop-hover:hover:tw-border-iron-700" {...(showMobileContentOnlyActions ? touchHandlers : {})} > -
-
+
+
{mediaUrl && ( -
+
)} {activePart && ( -
- - {}} - /> - +
+
+
+ + {}} + /> + +
+ {isCompactMode && showCompactTextGradient && ( +
+ )} +
)}
@@ -282,14 +388,16 @@ export const WaveLeaderboardGridItem: React.FC< )} {isCompactMode && (
{drop.title && ( @@ -311,22 +419,14 @@ export const WaveLeaderboardGridItem: React.FC< )}
- {drop.rank !== null ? ( - - ) : ( -
- - -
- )} +
diff --git a/components/waves/leaderboard/identity/WaveLeaderboardIdentity.tsx b/components/waves/leaderboard/identity/WaveLeaderboardIdentity.tsx index 7e21de4f4e..538f42ea5f 100644 --- a/components/waves/leaderboard/identity/WaveLeaderboardIdentity.tsx +++ b/components/waves/leaderboard/identity/WaveLeaderboardIdentity.tsx @@ -5,13 +5,14 @@ import UserCICAndLevel, { UserCICAndLevelSize, } from "@/components/user/utils/UserCICAndLevel"; import { DropAuthorBadges } from "@/components/waves/drops/DropAuthorBadges"; +import IdentityProfileSupplement from "@/components/waves/drops/identity/IdentityProfileSupplement"; import { getDropIdentityFallbackValue, getDropIdentityProfile, } from "@/components/waves/drops/identityDisplay.helpers"; import ParticipationIdentityProfileCard from "@/components/waves/drops/participation/ParticipationIdentityProfileCard"; import type { ParticipationIdentityProfileCardVariant } from "@/components/waves/drops/participation/ParticipationIdentityProfileCard"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiDropResolvedIdentityProfile } from "@/generated/models/ApiDropResolvedIdentityProfile"; import { shortenAddress } from "@/helpers/address.helpers"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import Link from "next/link"; @@ -23,18 +24,24 @@ interface WaveLeaderboardIdentityProps { readonly variant: WaveLeaderboardIdentityVariant; readonly cardVariant?: ParticipationIdentityProfileCardVariant | undefined; readonly className?: string | undefined; + readonly showIdentityHeader?: boolean | undefined; + readonly supplementFullWidth?: boolean | undefined; } interface WaveLeaderboardIdentitySummaryProps { - readonly profile: ApiProfileMin | null; + readonly profile: ApiDropResolvedIdentityProfile | null; readonly fallbackValue: string | null; readonly contextId?: string | number | undefined; + readonly showIdentityHeader?: boolean | undefined; + readonly supplementFullWidth?: boolean | undefined; } function WaveLeaderboardIdentitySummary({ profile, fallbackValue, contextId, + showIdentityHeader = true, + supplementFullWidth: _supplementFullWidth = false, }: WaveLeaderboardIdentitySummaryProps) { const displayLabel = profile?.handle ?? profile?.primary_address ?? fallbackValue; @@ -54,83 +61,105 @@ function WaveLeaderboardIdentitySummary({ const displayAddress = shouldShowAddress ? shortenAddress(primaryAddress) : null; + const hasSupplementContent = + !!profile?.bio?.trim() || + (profile?.top_rep_categories ?? []).some( + (category) => category.category.trim().length > 0 + ); + const showBelowHeaderSupplement = !!profile && hasSupplementContent; const avatarFallback = ( {displayLabel.slice(0, 1)} ); + if (profile && !showIdentityHeader && !hasSupplementContent) { + return null; + } + return (
-
- {rootHref ? ( - event.stopPropagation()} - className="tw-flex-shrink-0 tw-no-underline" - aria-label={`View ${displayLabel}'s profile`} - > + {showIdentityHeader && ( +
+ {rootHref ? ( + event.stopPropagation()} + className="tw-block tw-flex-shrink-0 tw-self-start tw-no-underline" + aria-label={`View ${displayLabel}'s profile`} + > + + + ) : ( - - ) : ( - - )} - -
-
- {rootHref ? ( - event.stopPropagation()} - className="tw-max-w-full tw-text-sm tw-font-semibold tw-leading-none tw-text-iron-50 tw-no-underline desktop-hover:hover:tw-text-iron-300" - > - {displayLabel} - - ) : ( - - {displayLabel} - - )} + )} - {profile && ( - <> - - - +
+
+ {rootHref ? ( + event.stopPropagation()} + className="tw-max-w-full tw-text-sm tw-font-semibold tw-leading-none tw-text-iron-50 tw-no-underline desktop-hover:hover:tw-text-iron-300" + > + {displayLabel} + + ) : ( + + {displayLabel} + + )} + + {profile && ( + <> + + + + )} +
+ + {displayAddress && ( +

+ {displayAddress} +

)}
+
+ )} - {displayAddress && ( -

- {displayAddress} -

- )} + {showBelowHeaderSupplement && ( +
+
-
+ )}
); } @@ -140,6 +169,8 @@ export function WaveLeaderboardIdentity({ variant, cardVariant, className, + showIdentityHeader = true, + supplementFullWidth = false, }: WaveLeaderboardIdentityProps) { const identityProfile = getDropIdentityProfile({ wave: drop.wave, @@ -167,6 +198,8 @@ export function WaveLeaderboardIdentity({ profile={identityProfile} contextId={drop.id} variant={cardVariant} + showIdentityHeader={showIdentityHeader} + supplementFullWidth={supplementFullWidth} />
@@ -174,6 +207,8 @@ export function WaveLeaderboardIdentity({ profile={identityProfile} fallbackValue={null} contextId={drop.id} + showIdentityHeader={showIdentityHeader} + supplementFullWidth={supplementFullWidth} />
@@ -186,6 +221,8 @@ export function WaveLeaderboardIdentity({ profile={identityProfile} fallbackValue={fallbackValue} contextId={drop.id} + showIdentityHeader={showIdentityHeader} + supplementFullWidth={supplementFullWidth} />
); diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.tsx index d88ef6eb81..0dca79cad5 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.tsx @@ -2,11 +2,9 @@ import React, { useState } from "react"; import { Tooltip } from "react-tooltip"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faAddressCard, faStar } from "@fortawesome/free-regular-svg-icons"; -import { faAward } from "@fortawesome/free-solid-svg-icons"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { useWaveRankReward } from "@/hooks/waves/useWaveRankReward"; +import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; interface WaveSmallLeaderboardItemOutcomesProps { readonly drop: ApiDrop; @@ -57,15 +55,9 @@ export const WaveSmallLeaderboardItemOutcomes: React.FC<
{!!nicTotal && (
-
- - - NIC - -
+ + NIC + {nicTotal} @@ -73,15 +65,9 @@ export const WaveSmallLeaderboardItemOutcomes: React.FC< )} {!!repTotal && (
-
- - - Rep - -
+ + Rep + {repTotal} @@ -92,15 +78,9 @@ export const WaveSmallLeaderboardItemOutcomes: React.FC< key={outcome} className="tw-flex tw-items-center tw-justify-between" > -
- - - {outcome} - -
+ + {outcome} +
))}
@@ -122,16 +102,10 @@ export const WaveSmallLeaderboardItemOutcomes: React.FC< { return colorMap[position]; }; +const getAuthorProfileLabel = (drop: ExtendedDrop): string => + drop.author.handle ?? drop.author.primary_address; + +const getAuthorTooltipUser = (drop: ExtendedDrop): string => + drop.author.handle ?? drop.author.primary_address; + +const getIdentityHref = (value: string) => + `/${encodeURIComponent(value.toLowerCase())}`; + +const getPodiumIdentityDisplay = (drop: ExtendedDrop) => { + const identityProfile = getDropIdentityProfile({ + wave: drop.wave, + metadata: drop.metadata, + }); + const fallbackValue = identityProfile + ? null + : getDropIdentityFallbackValue({ + wave: drop.wave, + metadata: drop.metadata, + }); + const label = + identityProfile?.handle ?? + identityProfile?.primary_address ?? + fallbackValue; + + if (!label) { + return null; + } + + return { + label, + pfp: identityProfile?.pfp ?? null, + profileUser: + identityProfile?.handle ?? identityProfile?.primary_address ?? null, + comparableIdentity: + identityProfile ?? { + handle: fallbackValue, + primary_address: fallbackValue, + }, + }; +}; + +const PodiumAvatar: React.FC = ({ + label, + pfp, + alt, + width, + height, + className, + ringClass, + ringWidthClass = "tw-ring-2", + shadowClass, + fallbackTextClass = "tw-text-xs tw-font-semibold tw-text-iron-100", +}) => { + const initial = label.trim().charAt(0).toUpperCase() || "?"; + + if (pfp) { + return ( + {alt} + ); + } + + return ( + + ); +}; + export const WavePodiumItem: React.FC = ({ winner, onDropClick, @@ -157,6 +251,18 @@ export const WavePodiumItem: React.FC = ({ const drop = winner.drop as ExtendedDrop; const animationIndex = customAnimationIndex ?? animationIndexMap[position]; + const authorProfileLabel = getAuthorProfileLabel(drop); + const authorTooltipUser = getAuthorTooltipUser(drop); + const authorProfileHref = `/${authorProfileLabel}`; + const identityDisplay = getPodiumIdentityDisplay(drop); + const primaryLabel = identityDisplay?.label ?? authorProfileLabel; + const primaryPfp = identityDisplay ? identityDisplay.pfp : drop.author.pfp; + const isSelfNominated = identityDisplay + ? areSameProfileIdentity({ + left: drop.author, + right: identityDisplay.comparableIdentity, + }) + : false; return ( = ({ className={`tw-flex tw-flex-col tw-items-center ${styles.marginBottom} tw-relative tw-z-10`} >
- e.stopPropagation()} - className="tw-transform tw-transition-all tw-duration-300 hover:tw-scale-105" - > - {drop.author.pfp ? ( - + - ) : ( -
+ ) : ( + e.stopPropagation()} + className="tw-transform tw-transition-all tw-duration-300 hover:tw-scale-105" + > + - )} - + + )}
@@ -239,42 +354,108 @@ export const WavePodiumItem: React.FC = ({
- - e.stopPropagation()} - className={`tw-relative tw-mb-2 tw-mt-2 tw-text-center tw-no-underline tw-transition-all sm:tw-mt-4 ${hoverTextColorClass} tw-group/link`} - > - - {drop.author.handle} - - - - + e.stopPropagation()} + className={`tw-relative tw-block tw-max-w-full tw-text-center tw-no-underline tw-transition-all ${hoverTextColorClass} tw-group/link`} + > + + {primaryLabel} + + + + + ) : ( + + {primaryLabel} + + )} - +
+ {isSelfNominated ? ( + + self-nominated + + ) : ( + <> + + nominated by + + + + e.stopPropagation()} + className="tw-inline-flex tw-max-w-full tw-items-center tw-text-iron-400 tw-no-underline tw-transition-colors desktop-hover:hover:tw-text-iron-200" + > + + {authorProfileLabel} + + + + + )} +
+
+ ) : ( + + e.stopPropagation()} + className={`tw-relative tw-mb-2 tw-mt-2 tw-text-center tw-no-underline tw-transition-all sm:tw-mt-4 ${hoverTextColorClass} tw-group/link`} + > + + {authorProfileLabel} + + + + + )}
diff --git a/components/waves/winners/podium/WavePodiumItemContentOutcomes.tsx b/components/waves/winners/podium/WavePodiumItemContentOutcomes.tsx index 8a6a09f0fa..704736c1ac 100644 --- a/components/waves/winners/podium/WavePodiumItemContentOutcomes.tsx +++ b/components/waves/winners/podium/WavePodiumItemContentOutcomes.tsx @@ -1,11 +1,12 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useMemo } from "react"; import { Tooltip } from "react-tooltip"; import type { ApiWaveDecisionWinner } from "@/generated/models/ApiWaveDecisionWinner"; import { ApiWaveOutcomeCredit } from "@/generated/models/ApiWaveOutcomeCredit"; import { ApiWaveOutcomeType } from "@/generated/models/ApiWaveOutcomeType"; import { formatNumberWithCommas } from "@/helpers/Helpers"; +import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; interface WavePodiumItemContentOutcomesProps { readonly winner: ApiWaveDecisionWinner; @@ -14,245 +15,122 @@ interface WavePodiumItemContentOutcomesProps { export const WavePodiumItemContentOutcomes: React.FC< WavePodiumItemContentOutcomesProps > = ({ winner }) => { - const [isTouch, setIsTouch] = useState(false); - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - setIsTouch("ontouchstart" in window); - }, []); - - const handleClick = (e: React.MouseEvent) => { - if (isTouch) { - e.stopPropagation(); - setIsOpen(!isOpen); - } - }; - // Transform awards into the same format that useDropOutcomes provided const { nicOutcomes, repOutcomes, manualOutcomes, haveOutcomes } = useMemo(() => { - const nicOutcomes = winner.awards - .filter( - (award) => - award.credit === ApiWaveOutcomeCredit.Cic && - award.amount && - award.amount > 0 - ) + const memoizedNicOutcomes = winner.awards + .filter((award) => { + const amount = award.amount ?? 0; + return award.credit === ApiWaveOutcomeCredit.Cic && amount > 0; + }) .map((award) => ({ type: ApiWaveOutcomeCredit.Cic, - value: award.amount || 0, + value: award.amount ?? 0, })); - const repOutcomes = winner.awards - .filter( - (award) => - award.credit === ApiWaveOutcomeCredit.Rep && - award.amount && - award.amount > 0 - ) + const memoizedRepOutcomes = winner.awards + .filter((award) => { + const amount = award.amount ?? 0; + return award.credit === ApiWaveOutcomeCredit.Rep && amount > 0; + }) .map((award) => ({ type: ApiWaveOutcomeCredit.Rep, value: award.amount ?? 0, category: award.rep_category ?? "", })); - const manualOutcomes = winner.awards - .filter( - (award) => - award.type === ApiWaveOutcomeType.Manual && award.description - ) + const memoizedManualOutcomes = winner.awards + .filter((award) => { + const description = award.description; + return ( + award.type === ApiWaveOutcomeType.Manual && description.length > 0 + ); + }) .map((award) => ({ type: ApiWaveOutcomeType.Manual, description: award.description, })); - const haveOutcomes = - !!nicOutcomes.length || !!repOutcomes.length || !!manualOutcomes.length; + const memoizedHaveOutcomes = + memoizedNicOutcomes.length > 0 || + memoizedRepOutcomes.length > 0 || + memoizedManualOutcomes.length > 0; - return { nicOutcomes, repOutcomes, manualOutcomes, haveOutcomes }; + return { + nicOutcomes: memoizedNicOutcomes, + repOutcomes: memoizedRepOutcomes, + manualOutcomes: memoizedManualOutcomes, + haveOutcomes: memoizedHaveOutcomes, + }; }, [winner.awards]); if (!haveOutcomes) { return null; } + const tooltipId = `outcome-${winner.place}-${winner.drop.id}`; + const tooltipContent = ( -
-
- - Outcome Details - -
- {nicOutcomes.map((nicOutcome) => ( -
-
- - - NIC - -
- - {formatNumberWithCommas(nicOutcome.value)} - -
- ))} - {repOutcomes.map((repOutcome) => ( -
-
-
- - - Rep - -
- - {formatNumberWithCommas(repOutcome.value)} - -
- +
+ {nicOutcomes.map((nicOutcome) => ( +
+ NIC + + {formatNumberWithCommas(nicOutcome.value)} + +
+ ))} + {repOutcomes.map((repOutcome) => ( +
+ Rep + + {formatNumberWithCommas(repOutcome.value)} + + {repOutcome.category.length > 0 && ( + <> + + {repOutcome.category} -
- ))} - {manualOutcomes.map((outcome) => ( -
-
- - - {outcome.description} - -
-
- ))} + + )}
-
+ ))} + {manualOutcomes.map((outcome) => ( +
+ {outcome.description} +
+ ))}
); return ( <> {tooltipContent} diff --git a/helpers/ProfileHelpers.ts b/helpers/ProfileHelpers.ts index 374e6d936e..612808afcb 100644 --- a/helpers/ProfileHelpers.ts +++ b/helpers/ProfileHelpers.ts @@ -8,6 +8,12 @@ type ProfileIdentityForOwnership = | null | undefined; +type ComparableProfileIdentity = { + readonly id?: string | null | undefined; + readonly handle?: string | null | undefined; + readonly primary_address?: string | null | undefined; +}; + type ProfileViewerContext = "self" | "other" | "anonymous"; const normalizeProfileTarget = ( @@ -68,6 +74,38 @@ export const getProfileViewerContext = ({ : "other"; }; +export const areSameProfileIdentity = ({ + left, + right, +}: { + readonly left: ComparableProfileIdentity | null | undefined; + readonly right: ComparableProfileIdentity | null | undefined; +}): boolean => { + if (!left || !right) { + return false; + } + + const leftId = normalizeProfileTarget(left.id); + const rightId = normalizeProfileTarget(right.id); + if (leftId && rightId && leftId === rightId) { + return true; + } + + const leftHandle = normalizeProfileTarget(left.handle); + const rightHandle = normalizeProfileTarget(right.handle); + if (leftHandle && rightHandle && leftHandle === rightHandle) { + return true; + } + + const leftPrimaryAddress = normalizeProfileTarget(left.primary_address); + const rightPrimaryAddress = normalizeProfileTarget(right.primary_address); + return !!( + leftPrimaryAddress && + rightPrimaryAddress && + leftPrimaryAddress === rightPrimaryAddress + ); +}; + export const getProfileConnectedStatus = ({ profile, isProxy,