1<script lang="ts">
2 import type { AtpClient } from '$lib/at/client';
3 import { ok, err, type Result, expect } from '$lib/result';
4 import type { AppBskyFeedPost } from '@atcute/bluesky';
5 import { generateColorForDid } from '$lib/accounts';
6 import type { PostWithUri } from '$lib/at/fetch';
7 import BskyPost from './BskyPost.svelte';
8 import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons';
9 import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
10 import type { Writable } from 'svelte/store';
11
12 interface Props {
13 client: AtpClient;
14 selectedDid: Writable<Did | null>;
15 onPostSent: (post: PostWithUri) => void;
16 quoting?: PostWithUri;
17 replying?: PostWithUri;
18 }
19
20 let {
21 client,
22 selectedDid,
23 onPostSent,
24 quoting = $bindable(undefined),
25 replying = $bindable(undefined)
26 }: Props = $props();
27
28 let color = $derived(
29 client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)'
30 );
31
32 const post = async (text: string): Promise<Result<PostWithUri, string>> => {
33 const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({
34 $type: 'com.atproto.repo.strongRef',
35 cid: p.cid!,
36 uri: p.uri
37 });
38 const record: AppBskyFeedPost.Main = {
39 $type: 'app.bsky.feed.post',
40 text,
41 reply: replying
42 ? {
43 root: replying.record.reply?.root ?? strongRef(replying),
44 parent: strongRef(replying)
45 }
46 : undefined,
47 embed: quoting
48 ? {
49 $type: 'app.bsky.embed.record',
50 record: strongRef(quoting)
51 }
52 : undefined,
53 createdAt: new Date().toISOString()
54 };
55
56 const res = await client.atcute?.post('com.atproto.repo.createRecord', {
57 input: {
58 collection: 'app.bsky.feed.post',
59 repo: client.didDoc!.did,
60 record
61 }
62 });
63
64 if (!res) {
65 return err('failed to post: not logged in');
66 }
67
68 if (!res.ok) {
69 return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`);
70 }
71
72 return ok({
73 uri: res.data.uri,
74 cid: res.data.cid,
75 record
76 });
77 };
78
79 let postText = $state('');
80 let info = $state('');
81 let isFocused = $state(false);
82 let textareaEl: HTMLTextAreaElement | undefined = $state();
83
84 const unfocus = () => {
85 isFocused = false;
86 quoting = undefined;
87 replying = undefined;
88 };
89
90 const doPost = () => {
91 if (postText.length === 0 || postText.length > 300) return;
92
93 post(postText).then((res) => {
94 if (res.ok) {
95 onPostSent(res.value);
96 postText = '';
97 info = 'posted!';
98 unfocus();
99 setTimeout(() => (info = ''), 1000 * 0.8);
100 } else {
101 // todo: add a way to clear error
102 info = res.error;
103 }
104 });
105 };
106
107 $effect(() => {
108 if (isFocused && textareaEl) textareaEl.focus();
109 if (quoting || replying) isFocused = true;
110 });
111</script>
112
113<div class="relative min-h-16">
114 <!-- Spacer to maintain layout when focused -->
115 {#if isFocused}
116 <div class="min-h-16"></div>
117 {/if}
118
119 <!-- svelte-ignore a11y_no_static_element_interactions -->
120 <div
121 onmousedown={(e) => {
122 if (isFocused) {
123 e.preventDefault();
124 }
125 }}
126 class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300"
127 class:min-h-16={!isFocused}
128 class:items-center={!isFocused}
129 class:shadow-2xl={isFocused}
130 class:absolute={isFocused}
131 class:top-0={isFocused}
132 class:left-0={isFocused}
133 class:right-0={isFocused}
134 class:z-50={isFocused}
135 style="background: {isFocused
136 ? `color-mix(in srgb, var(--nucleus-bg) 80%, ${color} 20%)`
137 : `color-mix(in srgb, ${color} 9%, transparent)`};
138 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);"
139 >
140 <div class="w-full p-2" class:py-3={isFocused}>
141 {#if info.length > 0}
142 <div
143 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
144 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};"
145 >
146 {info}
147 </div>
148 {:else}
149 <div class="flex flex-col gap-2">
150 {#if isFocused}
151 {#if replying}
152 {@const parsedUri = expect(parseCanonicalResourceUri(replying.uri))}
153 <BskyPost
154 {client}
155 {selectedDid}
156 did={parsedUri.repo}
157 rkey={parsedUri.rkey}
158 data={replying}
159 isOnPostComposer={true}
160 />
161 {/if}
162 <textarea
163 bind:this={textareaEl}
164 bind:value={postText}
165 onfocus={() => (isFocused = true)}
166 onblur={unfocus}
167 onkeydown={(event) => {
168 if (event.key === 'Escape') unfocus();
169 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
170 }}
171 placeholder="what's on your mind?"
172 rows="4"
173 class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
174 style="border-color: color-mix(in srgb, {color} 27%, transparent);"
175 ></textarea>
176 {#if quoting}
177 {@const parsedUri = expect(parseCanonicalResourceUri(quoting.uri))}
178 <BskyPost
179 {client}
180 {selectedDid}
181 did={parsedUri.repo}
182 rkey={parsedUri.rkey}
183 data={quoting}
184 isOnPostComposer={true}
185 />
186 {/if}
187 <div class="flex items-center gap-2">
188 <div class="grow"></div>
189 <span
190 class="text-sm font-medium"
191 style="color: color-mix(in srgb, {postText.length > 300
192 ? '#ef4444'
193 : 'var(--nucleus-fg)'} 53%, transparent);"
194 >
195 {postText.length} / 300
196 </span>
197 <button
198 onmousedown={(e) => {
199 e.preventDefault();
200 doPost();
201 }}
202 disabled={postText.length === 0 || postText.length > 300}
203 class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
204 style="background: color-mix(in srgb, {color} 87%, transparent);"
205 >
206 post
207 </button>
208 </div>
209 {:else}
210 <input
211 bind:value={postText}
212 onfocus={() => (isFocused = true)}
213 type="text"
214 placeholder="what's on your mind?"
215 class="single-line-input flex-1 bg-(--nucleus-bg)/40"
216 style="border-color: color-mix(in srgb, {color} 27%, transparent);"
217 />
218 {/if}
219 </div>
220 {/if}
221 </div>
222 </div>
223</div>