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