replies timeline only, appview-less bluesky client
at main 6.1 kB view raw
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 <div class="composer space-y-2"> 149 <textarea 150 bind:this={textareaEl} 151 bind:value={postText} 152 onfocus={() => (isFocused = true)} 153 onblur={unfocus} 154 onkeydown={(event) => { 155 if (event.key === 'Escape') unfocus(); 156 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 157 }} 158 placeholder="what's on your mind?" 159 rows="4" 160 class="field-sizing-content resize-none" 161 ></textarea> 162 {#if quoting} 163 {@render renderPost(quoting)} 164 {/if} 165 </div> 166{/snippet} 167 168<div class="relative min-h-13"> 169 <!-- Spacer to maintain layout when focused --> 170 {#if isFocused} 171 <div class="min-h-13"></div> 172 {/if} 173 174 <!-- svelte-ignore a11y_no_static_element_interactions --> 175 <div 176 onmousedown={(e) => { 177 if (isFocused) { 178 e.preventDefault(); 179 } 180 }} 181 class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300 182 {!isFocused ? 'min-h-13 items-center' : ''} 183 {isFocused ? 'absolute right-0 bottom-0 left-0 z-50 shadow-2xl' : ''}" 184 style="background: {isFocused 185 ? `color-mix(in srgb, var(--nucleus-bg) 75%, ${color})` 186 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 187 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 188 > 189 <div class="w-full p-1.5 px-2"> 190 {#if info.length > 0} 191 <div 192 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 193 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 194 > 195 {info} 196 </div> 197 {:else} 198 <div class="flex flex-col gap-2"> 199 {#if isFocused} 200 {@render composer()} 201 {:else} 202 <input 203 bind:value={postText} 204 onfocus={() => (isFocused = true)} 205 type="text" 206 placeholder="what's on your mind?" 207 class="flex-1" 208 /> 209 {/if} 210 </div> 211 {/if} 212 </div> 213 </div> 214</div> 215 216<!-- TODO: this fucking blows --> 217<style> 218 @reference "../app.css"; 219 220 input, 221 .composer { 222 @apply single-line-input bg-(--nucleus-bg)/35; 223 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); 224 } 225 226 .composer { 227 @apply p-2; 228 } 229 230 textarea { 231 @apply w-full bg-transparent p-0; 232 } 233 234 input { 235 @apply p-1 px-2; 236 } 237 238 .composer { 239 @apply focus:scale-100; 240 } 241 242 input::placeholder, 243 textarea::placeholder { 244 color: color-mix(in srgb, var(--acc-color) 45%, var(--nucleus-bg)); 245 } 246 247 textarea:focus { 248 @apply border-none! [box-shadow:none]! outline-none!; 249 } 250</style>