<script lang="ts">
import type { Did, Handle } from "@atcute/lexicons/syntax";
import { profileIdentifier } from "#lib/api/identity.ts";
import type { Snippet } from "svelte";
import { prefersReducedMotion } from "svelte/motion";
import Camera from "$icon/camera";
import Check from "$icon/check";
import Pencil from "$icon/pencil";
import X from "$icon/x";
import { createAction } from "#lib/action.svelte.ts";
import type { Moderation } from "#lib/api/moderationLabels.ts";
import type { ProfileRecord } from "#lib/api/records.ts";
import { getAuth } from "#lib/auth.svelte.ts";
import ModerationNotice from "#lib/components/moderation/ModerationNotice.svelte";
import { createAuthorGate } from "#lib/components/moderation/gate.svelte.ts";
import FollowButton from "#lib/components/profile/FollowButton.svelte";
import NetworkVouches from "#lib/components/profile/NetworkVouches.svelte";
import ProfileEditForm from "#lib/components/profile/ProfileEditForm.svelte";
import ProfileBio from "#lib/components/profile/ProfileBio.svelte";
import ProfileMoreMenu from "#lib/components/profile/ProfileMoreMenu.svelte";
import { PinnedEdit, setPinnedEdit } from "#lib/components/profile/pinnedEdit.svelte.ts";
import ProfileSocialLinks from "#lib/components/profile/ProfileSocialLinks.svelte";
import ProfileTabs from "#lib/components/profile/ProfileTabs.svelte";
import VouchButton from "#lib/components/profile/VouchButton.svelte";
import OnboardingNudge from "#lib/components/welcome/OnboardingNudge.svelte";
import type {
DirectVouch,
NetworkVouch,
ProfileCounts
} from "#lib/components/profile/types.ts";
import Avatar from "#lib/components/ui/Avatar.svelte";
import { avatarUrl } from "#lib/avatar.ts";
import { avatarProblem } from "#lib/api/profile.ts";
import Button from "#lib/components/ui/Button.svelte";
import Card from "#lib/components/ui/Card.svelte";
import LayerCard from "#lib/components/ui/LayerCard.svelte";
import LayerCardPrimary from "#lib/components/ui/LayerCardPrimary.svelte";
import { lightbox } from "#lib/lightbox.svelte.ts";
import { revealHeight } from "#lib/motion/reveal.ts";
import { RAIL_DURATION, railEasing } from "#lib/motion/spring.ts";
interface Props {
identity: { did: Did; handle: Handle };
profile: ProfileRecord | null;
counts: ProfileCounts;
viewerFollowRkey?: string | null;
viewerVouch: DirectVouch | null;
networkVouches: NetworkVouch[];
activeTab: string;
moderation?: Moderation;
content: Snippet;
}
let {
identity,
profile,
counts,
viewerFollowRkey,
viewerVouch,
networkVouches,
activeTab,
moderation,
content
}: Props = $props();
const auth = getAuth();
const isSelf = $derived(auth.currentDid === identity.did);
const pins = setPinnedEdit(
new PinnedEdit(
() => identity,
() => isSelf
)
);
const editing = $derived(pins.editing);
let saved = $state<ProfileRecord | null>(null);
const shown = $derived(saved ?? profile);
const formId = $props.id();
let form = $state<ReturnType<typeof ProfileEditForm>>();
let avatarInput = $state<HTMLInputElement>();
let avatarFile = $state<File | null>(null);
let avatarError = $state<string>();
const avatarPreview = $derived(avatarFile ? URL.createObjectURL(avatarFile) : undefined);
$effect(() => {
const url = avatarPreview;
return () => {
if (url) URL.revokeObjectURL(url);
};
});
const pickAvatar = (event: Event & { currentTarget: HTMLInputElement }) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (!file) return;
avatarError = avatarProblem(file);
if (!avatarError) avatarFile = file;
};
const saveAction = () =>
createAction(async () => {
const record = await form?.save();
if (!record) return;
saved = record;
pins.saved = record.pinnedRepositories ?? [];
stopEditing();
});
// a fresh one per edit, so a failed save's error doesn't greet the next edit
let save = $state.raw(saveAction());
const startEditing = () => {
save = saveAction();
pins.keys = [...new Set(pins.saved ?? shown?.pinnedRepositories)];
pins.editing = true;
};
const stopEditing = () => {
pins.editing = false;
avatarFile = null;
avatarError = undefined;
};
const actor = $derived(profileIdentifier(identity.handle, identity.did));
const gate = createAuthorGate(
() => moderation,
() => identity.did
);
</script>
{#snippet gatedContent()}
{#if gate.masked}
<ModerationNotice {gate} />
{:else}
{@render content()}
{/if}
{/snippet}
<ProfileTabs {actor} active={activeTab} {counts} layered={activeTab === "overview"} />
{#if activeTab === "overview"}
<LayerCard as="section" class="relative text-foreground-default">
<div class="h-[calc(5.5625rem-var(--layer-gap)-1px)]"></div>
<LayerCardPrimary padding="none" class="static overflow-visible">
<div
class="absolute top-5.25 left-1/2 size-32 -translate-x-1/2 rounded-full bg-background-default sm:left-6 sm:translate-x-0"
>
{#if editing}
<Avatar
did={identity.did}
handle={identity.handle}
src={avatarPreview}
size="full"
pixels={128}
loading="eager"
moderation={gate.moderation}
/>
<Button
icon={Camera}
size="large"
aria-label="Change avatar"
class="absolute right-0 bottom-0 [--btn-radius:9999px]"
onclick={() => avatarInput?.click()}
/>
{:else}
<button
type="button"
class={["size-full rounded-full", !gate.masked && "cursor-zoom-in"]}
aria-label="View {identity.handle}'s avatar"
disabled={gate.masked}
onclick={(event) =>
lightbox.show(
[
{
src: avatarUrl(identity.did),
alt: `${identity.handle}'s avatar`,
origin: event.currentTarget,
class: "aspect-square object-cover rounded-full!"
}
],
0
)}
>
<Avatar
did={identity.did}
handle={identity.handle}
size="full"
pixels={128}
loading="eager"
moderation={gate.moderation}
/>
</button>
{/if}
</div>
<div
class="sm:absolute sm:top-8.5 sm:right-4 sm:left-43 sm:flex sm:items-center sm:gap-md"
>
<h1
title={identity.handle}
class="max-w-full truncate px-xl pt-30.75 typography-heading-4 sm:min-w-0 sm:flex-1 sm:px-none sm:pt-none"
>
{identity.handle}
{#if shown?.pronouns && !gate.masked}
<span
data-testid="profilePronouns"
data-pronouns={shown.pronouns}
class="ml-xs typography-paragraph-regular font-normal text-foreground-muted"
>
{shown.pronouns}
</span>
{/if}
</h1>
<div
class="absolute inset-x-4 top-39.25 flex items-center justify-between gap-2xs max-sm:@container/actions sm:static sm:shrink-0 sm:justify-end"
>
<div
class={[
"flex items-center",
isSelf ? "ml-auto gap-2xs" : "gap-xs max-sm:contents"
]}
>
{#if isSelf && editing}
<Button icon={X} disabled={save.loading} onclick={stopEditing}
>Cancel</Button
>
<Button
type="submit"
form={formId}
variant="primary"
icon={Check}
loading={save.loading}>Save</Button
>
{:else if isSelf}
<Button variant="default" icon={Pencil} onclick={startEditing}
>Edit profile</Button
>
{:else}
<div class="min-w-23 shrink-0 max-sm:flex-1">
<FollowButton
profileDid={identity.did}
initialRkey={viewerFollowRkey}
/>
</div>
<div class="max-sm:flex-1">
<VouchButton
profileDid={identity.did}
profileLabel={identity.handle}
initialVouch={viewerVouch}
{networkVouches}
class="@max-[18rem]/actions:w-8 @max-[18rem]/actions:min-w-0 @max-[18rem]/actions:px-0"
labelClass="@max-[18rem]/actions:sr-only"
/>
</div>
{/if}
</div>
<ProfileMoreMenu {identity} />
</div>
</div>
<div
class="flex min-h-16 flex-col gap-md px-xl pt-xs sm:ml-39 sm:flex-row sm:justify-between sm:gap-xl sm:px-md sm:py-sm"
>
{#if editing}
<div class="w-full">
<input
data-testid="profileAvatarInput"
bind:this={avatarInput}
type="file"
accept="image/png,image/jpeg"
class="hidden"
onchange={pickAvatar}
/>
<ProfileEditForm
bind:this={form}
id={formId}
profile={shown}
pinned={pins.keys}
{avatarFile}
{avatarError}
error={save.error}
onsubmit={() => save.run()}
/>
</div>
{:else if gate.masked}
<ModerationNotice {gate} class="w-full sm:py-2xs" />
{:else}
{#if shown?.description}
<ProfileBio
text={shown.description}
class="min-w-0 typography-paragraph-regular wrap-break-word sm:-mt-px sm:flex-1 sm:py-2xs"
/>
{/if}
{#if shown}
<ProfileSocialLinks
{identity}
profile={shown}
followers={counts.followers}
following={counts.following}
class="max-sm:-ml-2.5 max-sm:flex-col max-sm:items-start max-sm:gap-xs sm:ml-auto sm:max-w-1/2 sm:shrink-0 sm:self-start"
/>
{/if}
{/if}
</div>
{#if networkVouches.length > 0 && !gate.masked}
<div
in:revealHeight={{
duration: prefersReducedMotion.current ? 0 : RAIL_DURATION,
easing: railEasing
}}
>
<div class="px-xl py-md sm:ml-39 sm:px-md sm:pt-none">
<NetworkVouches subject={actor} vouches={networkVouches} />
</div>
</div>
{/if}
{#if isSelf}
<OnboardingNudge class="px-md pt-md" />
{/if}
{#if !gate.masked}
<div class="p-md">{@render content()}</div>
{/if}
</LayerCardPrimary>
</LayerCard>
{:else}
<Card padding="none" class="overflow-hidden">
<div class="p-md">{@render gatedContent()}</div>
</Card>
{/if}