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 <textarea
149 bind:this={textareaEl}
150 bind:value={postText}
151 onfocus={() => (isFocused = true)}
152 onblur={unfocus}
153 onkeydown={(event) => {
154 if (event.key === 'Escape') unfocus();
155 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
156 }}
157 placeholder="what's on your mind?"
158 rows="4"
159 class="field-sizing-content resize-none"
160 ></textarea>
161 {#if quoting}
162 {@render renderPost(quoting)}
163 {/if}
164{/snippet}
165
166<div class="relative min-h-13">
167 <!-- Spacer to maintain layout when focused -->
168 {#if isFocused}
169 <div class="min-h-13"></div>
170 {/if}
171
172 <!-- svelte-ignore a11y_no_static_element_interactions -->
173 <div
174 onmousedown={(e) => {
175 if (isFocused) {
176 e.preventDefault();
177 }
178 }}
179 class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300
180 {!isFocused ? 'min-h-13 items-center' : ''}
181 {isFocused ? 'absolute right-0 bottom-0 left-0 z-50 shadow-2xl' : ''}"
182 style="background: {isFocused
183 ? `color-mix(in srgb, var(--nucleus-bg) 75%, ${color})`
184 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`};
185 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);"
186 >
187 <div class="w-full p-1.5 px-2">
188 {#if info.length > 0}
189 <div
190 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
191 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};"
192 >
193 {info}
194 </div>
195 {:else}
196 <div class="flex flex-col gap-2">
197 {#if isFocused}
198 {@render composer()}
199 {:else}
200 <input
201 bind:value={postText}
202 onfocus={() => (isFocused = true)}
203 type="text"
204 placeholder="what's on your mind?"
205 class="flex-1"
206 />
207 {/if}
208 </div>
209 {/if}
210 </div>
211 </div>
212</div>
213
214<style>
215 @reference "../app.css";
216
217 input,
218 textarea {
219 @apply single-line-input bg-(--nucleus-bg)/35;
220 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent);
221 }
222
223 input {
224 @apply p-1 px-2;
225 }
226
227 textarea {
228 @apply focus:scale-100;
229 }
230
231 input::placeholder,
232 textarea::placeholder {
233 color: color-mix(in srgb, var(--acc-color) 45%, var(--nucleus-bg));
234 }
235</style>