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