0
2
<script lang="ts">
import { profilePath } from '$lib/utils'
import { createQuery } from '@tanstack/svelte-query'
import type { GrainActorDefsProfileViewDetailed, GetKnownFollowersFollowerItem } from '$hatk/client'
import { actorProfileQuery, knownFollowersQuery, storyAuthorsQuery } from '$lib/queries'
import { isCaughtUp } from '$lib/stories'
import { viewer } from '$lib/stores'
import { compactCount } from '$lib/utils'
import Avatar from '../atoms/Avatar.svelte'
import Facepile from '../atoms/Facepile.svelte'
import FollowButton from './FollowButton.svelte'
let {
did,
children,
}: {
did: string
children: import('svelte').Snippet
} = $props()
let hovering = $state(false)
let hoverTimer: ReturnType<typeof setTimeout> | null = null
let leaveTimer: ReturnType<typeof setTimeout> | null = null
let shouldFetch = $state(false)
const isOwnProfile = $derived($viewer?.did === did)
const profile = createQuery(() => ({
...actorProfileQuery(did, $viewer?.did),
enabled: shouldFetch,
}))
const knownFollowers = createQuery(() => ({
...knownFollowersQuery(did, $viewer?.did ?? ''),
enabled: shouldFetch && !!$viewer?.did && !isOwnProfile,
}))
const storyAuthors = createQuery(() => storyAuthorsQuery())
const storyAuthor = $derived(storyAuthors.data?.find((a) => a.profile.did === did))
const hasStory = $derived(!!storyAuthor)
const storyViewed = $derived(!isOwnProfile && !!storyAuthor && isCaughtUp(storyAuthor))
const p = $derived(profile.data as GrainActorDefsProfileViewDetailed | undefined)
const knownList = $derived(
((knownFollowers.data as { items?: GetKnownFollowersFollowerItem[] } | undefined)?.items) ?? []
)
const followedBy = $derived(p?.viewer?.followedBy)
const viewerFollow = $derived(p?.viewer?.following ?? null)
function handleEnter() {
if (leaveTimer) { clearTimeout(leaveTimer); leaveTimer = null }
hoverTimer = setTimeout(() => {
shouldFetch = true
hovering = true
}, 300)
}
function handleLeave() {
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null }
leaveTimer = setTimeout(() => {
hovering = false
}, 200)
}
const formatCount = compactCount
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="popover-trigger"
onmouseenter={handleEnter}
onmouseleave={handleLeave}
onfocusin={handleEnter}
onfocusout={handleLeave}
>
{@render children()}
{#if hovering && p}
<div class="popover" onmouseenter={handleEnter} onmouseleave={handleLeave}>
<div class="popover-header">
<a href={profilePath(p)} class="popover-avatar-link">
<Avatar did={p.did} src={p.avatar ?? null} name={p.displayName ?? p.handle} size={48} {hasStory} {storyViewed} />
</a>
{#if !isOwnProfile && $viewer}
<FollowButton did={p.did} {viewerFollow} />
{/if}
</div>
<a href={profilePath(p)} class="popover-name-link">
<span class="popover-name">{p.displayName || p.handle || p.did}</span>
</a>
<div class="popover-meta">
{#if p.handle}
<span class="popover-handle">@{p.handle}</span>
{/if}
{#if followedBy}
<span class="follows-you">Follows you</span>
{/if}
</div>
{#if p.description}
<p class="popover-bio">{p.description}</p>
{/if}
<div class="popover-stats">
<a href="{profilePath(p)}/followers" class="stat-link">
<strong>{formatCount(p.followersCount ?? 0)}</strong> <span>followers</span>
</a>
<a href="{profilePath(p)}/following" class="stat-link">
<strong>{formatCount(p.followsCount ?? 0)}</strong> <span>following</span>
</a>
{#if (p.galleryCount ?? 0) > 0}
<a href={profilePath(p)} class="stat-link">
<strong>{formatCount(p.galleryCount ?? 0)}</strong> <span>galleries</span>
</a>
{/if}
</div>
{#if knownList.length > 0}
<div class="known-followers">
<Facepile people={knownList} size={18} overlap={4} />
<span class="known-text">
Followed by {knownList.slice(0, 2).map((kf) => kf.displayName || kf.handle).join(', ')}
{#if knownList.length > 2}
and {knownList.length - 2} other{knownList.length - 2 === 1 ? '' : 's'} you follow
{/if}
</span>
</div>
{/if}
</div>
{/if}
</span>
<style>
.popover-trigger {
position: relative;
display: inline-flex;
min-width: 0;
}
.popover {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
margin-top: 8px;
width: 300px;
background: var(--bg-root);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
cursor: default;
}
@media (max-width: 600px) {
.popover { display: none; }
}
.popover-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 8px;
}
.popover-avatar-link {
text-decoration: none;
color: inherit;
}
.popover-name-link {
text-decoration: none;
color: inherit;
}
.popover-name-link:hover .popover-name {
text-decoration: underline;
}
.popover-name {
font-weight: 700;
font-size: 15px;
color: var(--text-primary);
}
.popover-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 1px;
}
.popover-handle {
font-size: 13px;
color: var(--text-muted);
}
.follows-you {
font-size: 11px;
color: var(--text-muted);
background: var(--bg-elevated);
padding: 1px 6px;
border-radius: 4px;
}
.popover-bio {
margin: 8px 0 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.popover-stats {
display: flex;
gap: 12px;
margin-top: 10px;
font-size: 13px;
}
.stat-link {
text-decoration: none;
color: var(--text-secondary);
}
.stat-link:hover {
text-decoration: underline;
}
.stat-link strong {
color: var(--text-primary);
font-weight: 700;
}
.known-followers {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.known-text {
font-size: 12px;
color: var(--text-muted);
line-height: 1.3;
}
</style>