replies timeline only, appview-less bluesky client

add remove attach button to quotes / replies in the post composer, allow attaching quote and reply at the same time

ptr.pet 6059d530 375937b9

verified
Changed files
+57 -51
src
+18 -16
src/components/BskyPost.svelte
··· 31 31 router 32 32 } from '$lib/state.svelte'; 33 33 import type { PostWithUri } from '$lib/at/fetch'; 34 - import { onMount } from 'svelte'; 34 + import { onMount, type Snippet } from 'svelte'; 35 35 import { derived } from 'svelte/store'; 36 36 import Device from 'svelte-device-info'; 37 37 import Dropdown from './Dropdown.svelte'; ··· 54 54 isOnPostComposer?: boolean; 55 55 onQuote?: (quote: PostWithUri) => void; 56 56 onReply?: (reply: PostWithUri) => void; 57 + cornerFragment?: Snippet; 57 58 } 58 59 59 60 const { ··· 65 66 mini, 66 67 onQuote, 67 68 onReply, 68 - isOnPostComposer = false /* replyBacklinks */ 69 + isOnPostComposer = false /* replyBacklinks */, 70 + cornerFragment 69 71 }: Props = $props(); 70 72 71 73 const selectedDid = $derived(client.user?.did ?? null); ··· 93 95 const postId = $derived(`timeline-post-${aturi}-${quoteDepth}`); 94 96 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 95 97 98 + // todo: this fucking sucks 96 99 const scrollToAndPulse = (targetUri: ResourceUri) => { 97 100 const targetId = `timeline-post-${targetUri}-0`; 98 101 // console.log(`Scrolling to ${targetId}`); ··· 274 277 border-color: {color}{isOnPostComposer ? '99' : '66'}; 275 278 " 276 279 > 277 - <div 278 - class=" 279 - mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1 280 - " 281 - style="background: {color}33;" 282 - > 283 - {@render profilePopout()} 284 - <span>·</span> 285 - <span 286 - title={new Date(record.createdAt).toLocaleString()} 287 - class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 288 - > 289 - {getRelativeTime(new Date(record.createdAt), currentTime)} 290 - </span> 280 + <div class="mb-3 flex max-w-full items-center justify-between"> 281 + <div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;"> 282 + {@render profilePopout()} 283 + <span>·</span> 284 + <span 285 + title={new Date(record.createdAt).toLocaleString()} 286 + class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 287 + > 288 + {getRelativeTime(new Date(record.createdAt), currentTime)} 289 + </span> 290 + </div> 291 + {@render cornerFragment?.()} 291 292 </div> 293 + 292 294 <p class="leading-normal text-wrap wrap-break-word"> 293 295 <RichText text={record.text} facets={record.facets ?? []} /> 294 296 {#if isOnPostComposer && record.embed}
+29 -31
src/components/PostComposer.svelte
··· 9 9 import type { ComAtprotoRepoStrongRef } from '@atcute/atproto'; 10 10 import { parseToRichText } from '$lib/richtext'; 11 11 import { tokenize } from '$lib/richtext/parser'; 12 + import Icon from '@iconify/svelte'; 12 13 13 - export type FocusState = 14 - | { type: 'null' } 15 - | { type: 'focused'; quoting?: PostWithUri; replying?: PostWithUri }; 14 + export type FocusState = 'null' | 'focused'; 16 15 export type State = { 17 16 focus: FocusState; 18 17 text: string; 18 + quoting?: PostWithUri; 19 + replying?: PostWithUri; 19 20 }; 20 21 21 22 interface Props { ··· 24 25 _state: State; 25 26 } 26 27 27 - let { 28 - client, 29 - onPostSent, 30 - _state = $bindable({ focus: { type: 'null' }, text: '' }) 31 - }: Props = $props(); 28 + let { client, onPostSent, _state = $bindable({ focus: 'null', text: '' }) }: Props = $props(); 32 29 33 - const isFocused = $derived(_state.focus.type === 'focused'); 30 + const isFocused = $derived(_state.focus === 'focused'); 34 31 35 32 const color = $derived( 36 33 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' ··· 46 43 // Parse rich text (mentions, links, tags) 47 44 const rt = await parseToRichText(text); 48 45 49 - const focus = _state.focus; 50 46 const record: AppBskyFeedPost.Main = { 51 47 $type: 'app.bsky.feed.post', 52 48 text: rt.text, 53 49 facets: rt.facets, 54 50 reply: 55 - focus.type === 'focused' && focus.replying 51 + _state.focus === 'focused' && _state.replying 56 52 ? { 57 - root: focus.replying.record.reply?.root ?? strongRef(focus.replying), 58 - parent: strongRef(focus.replying) 53 + root: _state.replying.record.reply?.root ?? strongRef(_state.replying), 54 + parent: strongRef(_state.replying) 59 55 } 60 56 : undefined, 61 57 embed: 62 - focus.type === 'focused' && focus.quoting 58 + _state.focus === 'focused' && _state.quoting 63 59 ? { 64 60 $type: 'app.bsky.embed.record', 65 - record: strongRef(focus.quoting) 61 + record: strongRef(_state.quoting) 66 62 } 67 63 : undefined, 68 64 createdAt: new Date().toISOString() ··· 91 87 let info = $state(''); 92 88 let textareaEl: HTMLTextAreaElement | undefined = $state(); 93 89 94 - const unfocus = () => (_state.focus.type = 'null'); 90 + const unfocus = () => (_state.focus = 'null'); 95 91 96 92 const doPost = () => { 97 93 if (_state.text.length === 0 || _state.text.length > 300) return; ··· 117 113 }); 118 114 </script> 119 115 120 - {#snippet renderPost(post: PostWithUri)} 116 + {#snippet attachedPost(post: PostWithUri, type: 'quoting' | 'replying')} 121 117 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 122 - <BskyPost 123 - {client} 124 - did={parsedUri.repo} 125 - rkey={parsedUri.rkey} 126 - data={post} 127 - isOnPostComposer={true} 128 - /> 118 + <BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} data={post} isOnPostComposer={true}> 119 + {#snippet cornerFragment()} 120 + <button 121 + class="transition-transform hover:scale-150" 122 + onclick={() => { 123 + if (_state.focus === 'focused') _state[type] = undefined; 124 + }}><Icon width={24} icon="heroicons:x-mark-16-solid" /></button 125 + > 126 + {/snippet} 127 + </BskyPost> 129 128 {/snippet} 130 129 131 130 {#snippet highlighter(text: string)} ··· 166 165 </button> 167 166 </div> 168 167 {#if replying} 169 - {@render renderPost(replying)} 168 + {@render attachedPost(replying, 'replying')} 170 169 {/if} 171 170 <div class="composer space-y-2"> 172 171 <div class="relative grid"> ··· 181 180 <textarea 182 181 bind:this={textareaEl} 183 182 bind:value={_state.text} 184 - onfocus={() => (_state.focus.type = 'focused')} 183 + onfocus={() => (_state.focus = 'focused')} 185 184 onblur={unfocus} 186 185 onkeydown={(event) => { 187 186 if (event.key === 'Escape') unfocus(); ··· 192 191 class="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45" 193 192 ></textarea> 194 193 </div> 195 - 196 194 {#if quoting} 197 - {@render renderPost(quoting)} 195 + {@render attachedPost(quoting, 'quoting')} 198 196 {/if} 199 197 </div> 200 198 {/snippet} ··· 228 226 </div> 229 227 {:else} 230 228 <div class="flex flex-col gap-2"> 231 - {#if _state.focus.type === 'focused'} 232 - {@render composer(_state.focus.replying, _state.focus.quoting)} 229 + {#if _state.focus === 'focused'} 230 + {@render composer(_state.replying, _state.quoting)} 233 231 {:else} 234 232 <input 235 233 bind:value={_state.text} 236 - onfocus={() => (_state.focus.type = 'focused')} 234 + onfocus={() => (_state.focus = 'focused')} 237 235 type="text" 238 236 placeholder="what's on your mind?" 239 237 class="flex-1"
+8 -2
src/components/TimelineView.svelte
··· 120 120 <div class="mb-1.5"> 121 121 <BskyPost 122 122 client={client!} 123 - onQuote={(post) => (postComposerState.focus = { type: 'focused', quoting: post })} 124 - onReply={(post) => (postComposerState.focus = { type: 'focused', replying: post })} 123 + onQuote={(post) => { 124 + postComposerState.focus = 'focused'; 125 + postComposerState.quoting = post; 126 + }} 127 + onReply={(post) => { 128 + postComposerState.focus = 'focused'; 129 + postComposerState.replying = post; 130 + }} 125 131 {...post} 126 132 /> 127 133 </div>
+2 -2
src/routes/[...catchall]/+page.svelte
··· 83 83 else animClass = 'animate-fade-in-scale'; 84 84 }); 85 85 86 - let postComposerState = $state<PostComposerState>({ focus: { type: 'null' }, text: '' }); 86 + let postComposerState = $state<PostComposerState>({ focus: 'null', text: '' }); 87 87 let showScrollToTop = $state(false); 88 88 const handleScroll = () => { 89 89 if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor') ··· 306 306 </div> 307 307 {/if} 308 308 309 - {#if postComposerState.focus.type === 'null' && showScrollToTop} 309 + {#if postComposerState.focus === 'null' && showScrollToTop} 310 310 {@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)} 311 311 {/if} 312 312 </div>