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 } 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>