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.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)'
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.didDoc!.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 if (isFocused && textareaEl) textareaEl.focus();
106 if (quoting || replying) isFocused = true;
107 });
108</script>
109
110<div class="relative min-h-16">
111 <!-- Spacer to maintain layout when focused -->
112 {#if isFocused}
113 <div class="min-h-16"></div>
114 {/if}
115
116 <!-- svelte-ignore a11y_no_static_element_interactions -->
117 <div
118 onmousedown={(e) => {
119 if (isFocused) {
120 e.preventDefault();
121 }
122 }}
123 class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300"
124 class:min-h-16={!isFocused}
125 class:items-center={!isFocused}
126 class:shadow-2xl={isFocused}
127 class:absolute={isFocused}
128 class:top-0={isFocused}
129 class:left-0={isFocused}
130 class:right-0={isFocused}
131 class:z-50={isFocused}
132 style="background: {isFocused
133 ? `color-mix(in srgb, var(--nucleus-bg) 80%, ${color} 20%)`
134 : `color-mix(in srgb, ${color} 9%, transparent)`};
135 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);"
136 >
137 <div class="w-full p-2" class:py-3={isFocused}>
138 {#if info.length > 0}
139 <div
140 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
141 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};"
142 >
143 {info}
144 </div>
145 {:else}
146 <div class="flex flex-col gap-2">
147 {#snippet renderPost(post: PostWithUri)}
148 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
149 <BskyPost
150 {client}
151 did={parsedUri.repo}
152 rkey={parsedUri.rkey}
153 data={post}
154 isOnPostComposer={true}
155 />
156 {/snippet}
157 {#if isFocused}
158 {#if replying}
159 {@render renderPost(replying)}
160 {/if}
161 <textarea
162 bind:this={textareaEl}
163 bind:value={postText}
164 onfocus={() => (isFocused = true)}
165 onblur={unfocus}
166 onkeydown={(event) => {
167 if (event.key === 'Escape') unfocus();
168 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
169 }}
170 placeholder="what's on your mind?"
171 rows="4"
172 class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
173 style="border-color: color-mix(in srgb, {color} 27%, transparent);"
174 ></textarea>
175 {#if quoting}
176 {@render renderPost(quoting)}
177 {/if}
178 <div class="flex items-center gap-2">
179 <div class="grow"></div>
180 <span
181 class="text-sm font-medium"
182 style="color: color-mix(in srgb, {postText.length > 300
183 ? '#ef4444'
184 : 'var(--nucleus-fg)'} 53%, transparent);"
185 >
186 {postText.length} / 300
187 </span>
188 <button
189 onmousedown={(e) => {
190 e.preventDefault();
191 doPost();
192 }}
193 disabled={postText.length === 0 || postText.length > 300}
194 class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
195 style="background: color-mix(in srgb, {color} 87%, transparent);"
196 >
197 post
198 </button>
199 </div>
200 {:else}
201 <input
202 bind:value={postText}
203 onfocus={() => (isFocused = true)}
204 type="text"
205 placeholder="what's on your mind?"
206 class="single-line-input flex-1 bg-(--nucleus-bg)/40"
207 style="border-color: color-mix(in srgb, {color} 27%, transparent);"
208 />
209 {/if}
210 </div>
211 {/if}
212 </div>
213 </div>
214</div>