import { html, render, keyed } from "/js/lib/lit-html.js"; import { Signal, effect } from "/js/signals.js"; import { classnames, formatLargeNumber } from "/js/utils.js"; import { cdnImageUrl, getDisplayDomain, getDisplayName, isLabelerProfile, hasValidHandle, } from "/js/dataHelpers.js"; import { linkToProfile, linkToProfileFollowers, linkToProfileFollowing, } from "/js/navigation.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; import { knownFollowersSummaryTemplate } from "/js/templates/knownFollowersSummary.template.js"; import { Component } from "/js/components/component.js"; import "/js/components/floating-card.js"; import "/js/components/detected-rich-text.js"; import "/js/components/app-icon.js"; // A hover card that renders profile data for a given DID inside an inner // . Set `dataLayer` and `interactionHandlers` once, then drive // with `card.did = "..."` + `card.open(rect)` / `card.reposition(rect)` / // `card.close()`. class ProfileHoverCard extends Component { #$did = new Signal.State(null); #dataLayer = null; #interactionHandlers = null; #disposeRender = null; #floatingCard = null; connectedCallback() { if (this._initialized) return; this._initialized = true; this.#floatingCard = document.createElement("floating-card"); this.#floatingCard.setAttribute("data-testid", "profile-hover-card"); this.appendChild(this.#floatingCard); this.#disposeRender = effect(() => { const did = this.#$did.get(); if (!did || !this.#dataLayer) return; const derived = this.#dataLayer.derived; const profile = derived.$hydratedDetailedProfiles.get(did) ?? derived.$hydratedProfiles.get(did) ?? null; const currentUser = derived.$currentUser.get(); const isDetailed = !!derived.$hydratedDetailedProfiles.get(did); const liveStatus = derived.$actorLiveStatus.get(did); const isLive = liveStatus.state === "active" && !!profile; // Match social-app: the live card is wider than the profile card this.#floatingCard.style.width = isLive ? "350px" : "300px"; render( keyed( did, isLive ? liveHoverCardTemplate({ profile, liveStatus, onOpenProfile: () => { this.close(); window.router.go(linkToProfile(profile)); }, }) : hoverCardTemplate({ profile, isDetailed, currentUser, interactionHandlers: this.#interactionHandlers, dataLayer: this.#dataLayer, }), ), this.#floatingCard, ); }); } disconnectedCallback() { this.#disposeRender?.(); this.#disposeRender = null; } open(anchorRect) { this.#floatingCard?.open(anchorRect); } close() { this.#floatingCard?.close(); } reposition(anchorRect) { this.#floatingCard?.reposition(anchorRect); } get isOpen() { return !!this.#floatingCard?.isOpen; } set did(value) { this.#$did.set(value ?? null); } get did() { return this.#$did.get(); } set dataLayer(value) { this.#dataLayer = value; } set interactionHandlers(value) { this.#interactionHandlers = value; } } ProfileHoverCard.register(); function liveHoverCardTemplate({ profile, liveStatus, onOpenProfile }) { const external = liveStatus?.embed?.external; if (!external) return null; const title = external.title || external.uri; const thumb = external.thumb ? cdnImageUrl(external.thumb) : null; return html`
${thumb ? html`
LIVE
` : null}
${title}
${getDisplayDomain(external.uri)}
Watch now
${avatarTemplate({ author: profile, clickAction: "none", showLiveBadge: false, })}
${getDisplayName(profile)}
@${profile.handle}
`; } function loadingBodyTemplate() { return html`
`; } function followButtonTemplate({ profile, isCurrentUser, isLabeler, isBlocking, isBlockedBy, isBlockingByList, isAuthenticated, isFollowPending, onFollow, }) { if (isCurrentUser) return null; if (isLabeler) return null; if (!isAuthenticated) return null; const isBlocked = isBlocking || isBlockedBy || isBlockingByList; if (isBlocked) { return html`View profile`; } const isFollowing = !!profile.viewer?.following; const isFollowedBy = !!profile.viewer?.followedBy; const state = isFollowing ? "following" : isFollowedBy ? "follow-back" : "follow"; return html``; } function statsTemplate({ profile }) { return html`
${formatLargeNumber(profile.followersCount)} followers ${formatLargeNumber(profile.followsCount)} following
`; } function hoverCardTemplate({ profile, isDetailed, currentUser, interactionHandlers, dataLayer, }) { if (!profile) { return html`
${loadingBodyTemplate()}
`; } const isCurrentUser = currentUser?.did === profile.did; const isLabeler = isLabelerProfile(profile); const isBlocking = !!profile.viewer?.blocking; const isBlockedBy = !!profile.viewer?.blockedBy; const isBlockingByList = !!profile.viewer?.blockingByList; const isBlocked = isBlocking || isBlockedBy || isBlockingByList; const isAuthenticated = !!currentUser; const followedBy = !!profile.viewer?.followedBy; const description = profile.description?.trim(); const canShowCounts = isDetailed && typeof profile.followersCount === "number" && typeof profile.followsCount === "number"; const isFollowPending = dataLayer.derived.$isFollowPending.get(profile.did); const displayName = getDisplayName(profile); return html`
${avatarTemplate({ author: profile, clickAction: "link" })}
${followButtonTemplate({ profile, isCurrentUser, isLabeler, isBlocking, isBlockedBy, isBlockingByList, isAuthenticated, isFollowPending, onFollow: (p, doFollow) => interactionHandlers.profileInteractionHandler.handleFollow( p, doFollow, ), })}
${displayName}${verificationBadgeTemplate({ profile, })}${automatedAccountBadgeTemplate({ profile })}
${followedBy && !isBlocking && !isBlockedBy ? html`Follows you` : null} ${hasValidHandle(profile) ? `@${profile.handle}` : ""}
${isBlocked ? null : html` ${canShowCounts ? statsTemplate({ profile }) : null} ${description ? html`
` : null} ${!isCurrentUser ? knownFollowersSummaryTemplate({ profile }) : null} `}
`; }