1<script lang="ts">
2 import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte';
3 import { isDid, isHandle, type ActorIdentifier, type Did } from '@atcute/lexicons/syntax';
4 import TimelineView from './TimelineView.svelte';
5 import ProfileInfo from './ProfileInfo.svelte';
6 import type { State as PostComposerState } from './PostComposer.svelte';
7 import Icon from '@iconify/svelte';
8 import { accounts, generateColorForDid } from '$lib/accounts';
9 import { img } from '$lib/cdn';
10 import { isBlob } from '@atcute/lexicons/interfaces';
11 import {
12 handles,
13 profiles,
14 getBlockRelationship,
15 fetchBlocked,
16 blockFlags
17 } from '$lib/state.svelte';
18 import BlockedUserIndicator from './BlockedUserIndicator.svelte';
19 import ProfileActions from './ProfileActions.svelte';
20
21 interface Props {
22 client: AtpClient;
23 actor: string;
24 onBack: () => void;
25 postComposerState: PostComposerState;
26 }
27
28 let { client, actor, onBack, postComposerState = $bindable() }: Props = $props();
29
30 const profile = $derived(profiles.get(actor as Did));
31 const displayName = $derived(profile?.displayName ?? '');
32 const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did));
33 let loading = $state(true);
34 let error = $state<string | null>(null);
35 let did = $state(isDid(actor) ? actor : null);
36
37 let userBlocked = $state(false);
38 let blockedByTarget = $state(false);
39
40 const loadProfile = async (identifier: ActorIdentifier) => {
41 loading = true;
42 error = null;
43
44 const docRes = await resolveDidDoc(identifier);
45 if (docRes.ok) {
46 did = docRes.value.did;
47 handles.set(did, docRes.value.handle);
48 } else {
49 error = docRes.error;
50 return;
51 }
52
53 // check block relationship
54 if (client.user?.did) {
55 let blockRel = getBlockRelationship(client.user.did, did);
56 blockRel = blockFlags.get(client.user.did)?.has(did)
57 ? blockRel
58 : await (async () => {
59 const [userBlocked, blockedByTarget] = await Promise.all([
60 await fetchBlocked(client, did, client.user!.did),
61 await fetchBlocked(client, client.user!.did, did)
62 ]);
63 return { userBlocked, blockedByTarget };
64 })();
65 userBlocked = blockRel.userBlocked;
66 blockedByTarget = blockRel.blockedByTarget;
67 }
68
69 // don't load profile if blocked
70 if (userBlocked || blockedByTarget) {
71 loading = false;
72 return;
73 }
74
75 const res = await client.getProfile(did, true);
76 if (res.ok) profiles.set(did, res.value);
77 else error = res.error;
78
79 loading = false;
80 };
81
82 $effect(() => {
83 // if we have accounts, wait until we are logged in to load the profile
84 if (!($accounts.length > 0 && !client.user?.did)) loadProfile(actor as ActorIdentifier);
85 });
86
87 const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-accent)');
88 const bannerUrl = $derived(
89 did && profile && isBlob(profile.banner)
90 ? img('feed_fullsize', did, profile.banner.ref.$link)
91 : null
92 );
93</script>
94
95<div class="flex min-h-dvh flex-col">
96 <!-- header -->
97 <div
98 class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-2 backdrop-blur-md"
99 style="border-color: {color};"
100 >
101 <button
102 onclick={onBack}
103 class="rounded-sm p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10"
104 >
105 <Icon icon="heroicons:arrow-left-20-solid" width={24} />
106 </button>
107 <h2 class="text-xl font-bold">
108 {displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')}
109 </h2>
110 <div class="grow"></div>
111 {#if did && client.user && client.user.did !== did}
112 <ProfileActions {client} targetDid={did} bind:userBlocked {blockedByTarget} />
113 {/if}
114 </div>
115
116 {#if !loading}
117 {#if error}
118 <div class="p-8 text-center text-red-500">
119 <p>failed to load profile: {error}</p>
120 </div>
121 {:else if userBlocked || blockedByTarget}
122 <div class="p-8">
123 <BlockedUserIndicator
124 {client}
125 did={did!}
126 reason={userBlocked ? 'blocked' : 'blocks-you'}
127 size="large"
128 />
129 </div>
130 {:else}
131 <!-- banner -->
132 {#if bannerUrl}
133 <div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48">
134 <img src={bannerUrl} alt="banner" class="h-full w-full object-cover" />
135 <div
136 class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)"
137 style="opacity: 0.8;"
138 ></div>
139 </div>
140 {/if}
141
142 {#if did}
143 <div class="px-4 pb-4">
144 <div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4">
145 <ProfileInfo {client} {did} {profile} />
146 </div>
147
148 <TimelineView
149 showReplies={false}
150 {client}
151 targetDid={did}
152 bind:postComposerState
153 class="min-h-[50vh]"
154 />
155 </div>
156 {/if}
157 {/if}
158 {/if}
159</div>