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