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