replies timeline only, appview-less bluesky client
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>