replies timeline only, appview-less bluesky client

make settings and notifications a 'view' like the timeline and not popups

ptr.pet c4d2a459 ae5f88ad

verified
+30
src/app.css
··· 95 95 box-shadow: 0 0 20px 5px var(--nucleus-selected-post); 96 96 } 97 97 } 98 + 99 + @keyframes slide-in-from-right { 100 + from { 101 + transform: translateX(144px); 102 + opacity: 0; 103 + } 104 + to { 105 + transform: translateX(0); 106 + opacity: 1; 107 + } 108 + } 109 + 110 + @keyframes slide-in-from-left { 111 + from { 112 + transform: translateX(-144px); 113 + opacity: 0; 114 + } 115 + to { 116 + transform: translateX(0); 117 + opacity: 1; 118 + } 119 + } 120 + 121 + .animate-slide-in-right { 122 + animation: slide-in-from-right 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 123 + } 124 + 125 + .animate-slide-in-left { 126 + animation: slide-in-from-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 127 + }
-30
src/components/NotificationsPopup.svelte
··· 1 - <script lang="ts"> 2 - import Popup from './Popup.svelte'; 3 - 4 - interface Props { 5 - isOpen: boolean; 6 - onClose: () => void; 7 - } 8 - 9 - let { isOpen = $bindable(false), onClose }: Props = $props(); 10 - 11 - const handleClose = () => { 12 - onClose(); 13 - }; 14 - </script> 15 - 16 - <Popup 17 - bind:isOpen 18 - onClose={handleClose} 19 - title="notifications" 20 - width="w-[42vmax] max-w-2xl" 21 - height="60vh" 22 - showHeaderDivider={true} 23 - > 24 - <div class="flex h-full items-center justify-center"> 25 - <div class="text-center"> 26 - <div class="mb-4 text-6xl opacity-50">🚧</div> 27 - <h3 class="text-xl font-bold opacity-80">todo</h3> 28 - </div> 29 - </div> 30 - </Popup>
+18
src/components/NotificationsView.svelte
··· 1 + <div class="p-4"> 2 + <div class="mb-6"> 3 + <h2 class="text-3xl font-bold">notifications</h2> 4 + <div class="mt-2 flex gap-2"> 5 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 6 + <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> 7 + </div> 8 + </div> 9 + 10 + <div 11 + class="flex h-64 items-center justify-center rounded-sm border-2 border-dashed border-(--nucleus-fg)/10" 12 + > 13 + <div class="text-center"> 14 + <div class="mb-4 text-6xl opacity-50">🚧</div> 15 + <h3 class="text-xl font-bold opacity-80">todo</h3> 16 + </div> 17 + </div> 18 + </div>
+2 -1
src/components/RichText.svelte
··· 41 41 > 42 42 {:else if feature.$type === 'app.bsky.richtext.facet#link'} 43 43 {@const uri = new URL(feature.uri)} 44 + {@const text = `${!uri.protocol.startsWith('http') ? `${uri.protocol}//` : ''}${uri.host}${uri.hash.length === 0 && uri.search.length === 0 && uri.pathname === '/' ? '' : uri.pathname}${uri.search}${uri.hash}`} 44 45 <a 45 46 class="text-(--nucleus-accent2)" 46 47 href={uri.href} 47 48 target="_blank" 48 49 rel="noopener noreferrer" 49 - >{@render plainText(uri.href.replace(`${uri.protocol}//`, ''))}</a 50 + >{@render plainText(`${text.substring(0, 40)}${text.length > 40 ? '...' : ''}`)}</a 50 51 > 51 52 {:else if feature.$type === 'app.bsky.richtext.facet#tag'} 52 53 <a
+63 -69
src/components/SettingsPopup.svelte src/components/SettingsView.svelte
··· 3 3 import { handleCache, didDocCache, recordCache } from '$lib/at/client'; 4 4 import { get } from 'svelte/store'; 5 5 import ColorPicker from 'svelte-awesome-color-picker'; 6 - import Popup from './Popup.svelte'; 7 6 import Tabs from './Tabs.svelte'; 8 - 9 - interface Props { 10 - isOpen: boolean; 11 - onClose: () => void; 12 - } 13 - 14 - let { isOpen = $bindable(false), onClose }: Props = $props(); 7 + import { portal } from 'svelte-portal'; 15 8 16 9 type Tab = 'style' | 'moderation' | 'advanced'; 17 10 let activeTab = $state<Tab>('advanced'); ··· 23 16 $settings.theme = localSettings.theme; 24 17 }); 25 18 26 - const resetSettingsToSaved = () => { 27 - localSettings = $settings; 28 - }; 29 - 30 - const handleClose = () => { 31 - resetSettingsToSaved(); 32 - onClose(); 33 - }; 34 - 35 19 const handleSave = () => { 36 20 settings.set(localSettings); 37 21 window.location.reload(); ··· 56 40 <div class="h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 57 41 {/snippet} 58 42 59 - {#snippet settingHeader(name: string, desc: string)} 60 - <h3 class="mb-3 text-lg font-bold">{name}</h3> 61 - <p class="mb-4 text-sm opacity-80">{desc}</p> 62 - {/snippet} 63 - 64 43 {#snippet advancedTab()} 65 - <div class="space-y-5"> 44 + <div class="space-y-3 p-4"> 66 45 <div> 67 - <h3 class="mb-3 text-lg font-bold">api endpoints</h3> 68 - <div class="space-y-4"> 46 + <h3 class="header">api endpoints</h3> 47 + <div class="borders space-y-4"> 69 48 {#snippet _input(name: string, desc: string)} 70 49 <div> 71 - <label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 50 + <label for={name} class="header-desc block"> 72 51 {desc} 73 52 </label> 74 53 <input ··· 86 65 </div> 87 66 </div> 88 67 89 - {@render divider()} 90 - 91 - <div> 68 + <div class="borders"> 92 69 <label for="social-app-url" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 93 70 social-app url (for when copying links to posts / profiles) 94 71 </label> ··· 101 78 /> 102 79 </div> 103 80 104 - {@render divider()} 105 - 106 - <div> 107 - {@render settingHeader( 108 - 'cache management', 109 - 'clears cached data (records, DID documents, handles, etc.)' 110 - )} 81 + <h3 class="header">cache management</h3> 82 + <div class="borders"> 83 + <p class="header-desc">clears cached data (records, DID documents, handles, etc.)</p> 111 84 <button onclick={handleClearCache} class="action-button"> clear cache </button> 112 85 </div> 113 86 114 - {@render divider()} 115 - 116 - <div> 117 - {@render settingHeader('reset settings', 'resets all settings to their default values')} 87 + <h3 class="header">reset settings</h3> 88 + <div class="borders"> 89 + <p class="header-desc">resets all settings to their default values</p> 118 90 <button 119 91 onclick={handleReset} 120 92 class="action-button border-red-600 text-red-600 hover:bg-red-600/20" ··· 126 98 {/snippet} 127 99 128 100 {#snippet styleTab()} 129 - <div class="space-y-5"> 101 + <div class="space-y-5 p-4"> 130 102 <div> 131 - <h3 class="mb-3 text-lg font-bold">colors</h3> 132 - <div class="space-y-4"> 103 + <h3 class="header">colors</h3> 104 + <div class="borders"> 133 105 {#snippet color(name: string, desc: string)} 134 106 <div> 135 - <label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 107 + <label for={name} class="header-desc block"> 136 108 {desc} 137 109 </label> 138 110 <div class="color-picker"> ··· 154 126 </div> 155 127 {/snippet} 156 128 157 - <Popup 158 - bind:isOpen 159 - onClose={handleClose} 160 - title="settings" 161 - width="w-[42vmax] max-w-2xl" 162 - height="60vh" 163 - showHeaderDivider={true} 164 - > 165 - {#snippet headerActions()} 129 + <div class="flex flex-col"> 130 + <div class="mb-6 flex items-center justify-between p-4 pb-0"> 131 + <div> 132 + <h2 class="text-3xl font-bold">settings</h2> 133 + <div class="mt-2 flex gap-2"> 134 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 135 + <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> 136 + </div> 137 + </div> 166 138 {#if hasReloadChanges} 167 - <button onclick={handleSave} class="shrink-0 action-button"> save & reload </button> 139 + <button onclick={handleSave} class="action-button animate-pulse shadow-lg"> 140 + save & reload 141 + </button> 168 142 {/if} 169 - {/snippet} 143 + </div> 170 144 171 - {#if activeTab === 'advanced'} 172 - {@render advancedTab()} 173 - {:else if activeTab === 'moderation'} 174 - <div class="flex h-full items-center justify-center"> 175 - <div class="text-center"> 176 - <div class="mb-4 text-6xl opacity-50">🚧</div> 177 - <h3 class="text-xl font-bold opacity-80">todo</h3> 145 + <div class="flex-1"> 146 + {#if activeTab === 'advanced'} 147 + {@render advancedTab()} 148 + {:else if activeTab === 'moderation'} 149 + <div class="p-4"> 150 + <div class="flex h-64 items-center justify-center"> 151 + <div class="text-center"> 152 + <div class="mb-4 text-6xl opacity-50">🚧</div> 153 + <h3 class="text-xl font-bold opacity-80">todo</h3> 154 + </div> 155 + </div> 178 156 </div> 179 - </div> 180 - {:else if activeTab === 'style'} 181 - {@render styleTab()} 182 - {/if} 157 + {:else if activeTab === 'style'} 158 + {@render styleTab()} 159 + {/if} 160 + </div> 183 161 184 - {#snippet footer()} 162 + <div 163 + use:portal={'#app-footer'} 164 + class="fixed bottom-[5dvh] z-20 w-full max-w-2xl p-4 pt-2 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)]" 165 + > 185 166 <Tabs 186 167 tabs={['style', 'moderation', 'advanced']} 187 168 bind:activeTab 188 169 onTabChange={(tab) => (activeTab = tab)} 189 170 /> 190 - {/snippet} 191 - </Popup> 171 + </div> 172 + </div> 173 + 174 + <style> 175 + @reference "../app.css"; 176 + .borders { 177 + @apply rounded-sm border-2 border-dashed border-(--nucleus-fg)/10 p-4; 178 + } 179 + .header-desc { 180 + @apply mb-2 text-sm text-(--nucleus-fg)/80; 181 + } 182 + .header { 183 + @apply mb-2 text-lg font-bold; 184 + } 185 + </style>
+6 -4
src/components/Tabs.svelte
··· 8 8 let { tabs, activeTab = $bindable(), onTabChange }: Props = $props(); 9 9 </script> 10 10 11 - <div class="flex"> 12 - {#each tabs as tab (tab)} 11 + <div class="flex rounded border-x-3 border-b-3 border-(--nucleus-accent)/20"> 12 + {#each tabs as tab, idx (tab)} 13 13 {@const isActive = activeTab === tab} 14 14 <button 15 15 onclick={() => onTabChange(tab)} 16 - class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive 17 - ? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)' 16 + class="flex-1 border-t-3 px-4 py-3 17 + font-semibold transition-colors hover:cursor-pointer 18 + {isActive 19 + ? 'rounded-t border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)' 18 20 : 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}" 19 21 > 20 22 {tab}
+123 -94
src/routes/+page.svelte
··· 2 2 import BskyPost from '$components/BskyPost.svelte'; 3 3 import PostComposer, { type State as PostComposerState } from '$components/PostComposer.svelte'; 4 4 import AccountSelector from '$components/AccountSelector.svelte'; 5 - import SettingsPopup from '$components/SettingsPopup.svelte'; 5 + import SettingsView from '$components/SettingsView.svelte'; 6 + import NotificationsView from '$components/NotificationsView.svelte'; 6 7 import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client'; 7 8 import { accounts, type Account } from '$lib/accounts'; 8 9 import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons'; 9 - import { onMount } from 'svelte'; 10 + import { onMount, tick } from 'svelte'; 10 11 import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch'; 11 12 import { expect } from '$lib/result'; 12 13 import { AppBskyFeedPost } from '@atcute/bluesky'; ··· 19 20 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 20 21 import type { PageProps } from './+page'; 21 22 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 22 - import NotificationsPopup from '$components/NotificationsPopup.svelte'; 23 23 24 24 const { data: loadData }: PageProps = $props(); 25 25 ··· 66 66 handleAccountSelected(newAccounts[0]?.did); 67 67 }; 68 68 69 - let isSettingsOpen = $state(false); 70 - let isNotificationsOpen = $state(false); 69 + type View = 'timeline' | 'notifications' | 'settings'; 70 + let currentView = $state<View>('timeline'); 71 + let animClass = $state('animate-fade-in-scale'); 72 + let timelineScrollPosition = $state(0); 73 + 74 + const viewOrder: Record<View, number> = { 75 + timeline: 0, 76 + notifications: 1, 77 + settings: 2 78 + }; 79 + 80 + const switchView = async (newView: View) => { 81 + if (currentView === newView) return; 82 + if (currentView === 'timeline') timelineScrollPosition = window.scrollY; 83 + 84 + const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left'; 85 + animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; 86 + currentView = newView; 87 + 88 + await tick(); 89 + 90 + if (newView !== 'timeline') window.scrollTo({ top: 0, behavior: 'instant' }); 91 + else window.scrollTo({ top: timelineScrollPosition, behavior: 'instant' }); 92 + }; 93 + 71 94 let reverseChronological = $state(true); 72 95 let viewOwnPosts = $state(true); 73 96 ··· 152 175 } 153 176 }; 154 177 155 - // const handleJetstream = async (subscription: JetstreamSubscription) => { 156 - // for await (const event of subscription) { 157 - // if (event.kind !== 'commit') continue; 158 - // const commit = event.commit; 159 - // if (commit.operation === 'delete') { 160 - // continue; 161 - // } 162 - // const record = commit.record as AppBskyFeedPost.Main; 163 - // addPosts( 164 - // event.did, 165 - // new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]]) 166 - // ); 167 - // } 168 - // }; 169 - 170 178 const loaderState = new LoaderState(); 171 179 let scrollContainer = $state<HTMLDivElement>(); 172 180 ··· 175 183 let showScrollToTop = $state(false); 176 184 177 185 const handleScroll = () => { 178 - showScrollToTop = window.scrollY > 300; 186 + if (currentView === 'timeline') showScrollToTop = window.scrollY > 300; 179 187 }; 180 188 181 189 const scrollToTop = () => { ··· 215 223 'app.bsky.feed.post:reply.parent.uri' 216 224 ) 217 225 ); 218 - // jetstream.set( 219 - // viewClient.streamJetstream( 220 - // newAccounts.map((account) => account.did), 221 - // 'app.bsky.feed.post' 222 - // ) 223 - // ); 224 226 }); 225 227 notificationStream.subscribe((stream) => { 226 228 if (!stream) return; 227 229 stream.listen(handleNotification); 228 230 }); 229 - // jetstream.subscribe((stream) => { 230 - // if (!stream) return; 231 - // handleJetstream(stream); 232 - // }); 233 231 if ($accounts.length > 0) { 234 232 loaderState.status = 'LOADING'; 235 233 if (loadData.client.ok && loadData.client.value) { ··· 252 250 }); 253 251 </script> 254 252 255 - {#snippet appButton(onClick: () => void, icon: string, ariaLabel: string, iconHover?: string)} 253 + {#snippet appButton( 254 + onClick: () => void, 255 + icon: string, 256 + ariaLabel: string, 257 + isActive: boolean, 258 + iconHover?: string 259 + )} 256 260 <button 257 261 onclick={onClick} 258 - class="group rounded-sm bg-(--nucleus-accent)/15 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg" 262 + class="group rounded-sm p-2 transition-all hover:scale-110 hover:shadow-lg 263 + {isActive 264 + ? 'bg-(--nucleus-accent)/25 text-(--nucleus-accent)' 265 + : 'bg-(--nucleus-accent)/10 text-(--nucleus-accent) hover:bg-(--nucleus-accent)/15'}" 259 266 aria-label={ariaLabel} 260 267 > 261 268 <Icon class="group-hover:hidden" {icon} width={28} /> ··· 263 270 </button> 264 271 {/snippet} 265 272 266 - <div class="mx-auto max-w-2xl"> 267 - <!-- thread list (page scrolls as a whole) --> 268 - <div 269 - id="app-thread-list" 270 - class="mb-4 min-h-screen p-2 [scrollbar-color:var(--nucleus-accent)_transparent]" 271 - bind:this={scrollContainer} 272 - > 273 - {#if $accounts.length > 0} 274 - {@render renderThreads()} 275 - {:else} 276 - <div class="flex justify-center py-4"> 277 - <p class="text-xl opacity-80"> 278 - <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 279 - </p> 273 + <div class="mx-auto flex min-h-dvh max-w-2xl flex-col"> 274 + <!-- Views Container --> 275 + <div class="flex-1"> 276 + <!-- timeline --> 277 + <div 278 + id="app-thread-list" 279 + class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {currentView === 280 + 'timeline' 281 + ? `block ${animClass}` 282 + : 'hidden'}" 283 + bind:this={scrollContainer} 284 + > 285 + {#if $accounts.length > 0} 286 + {@render renderThreads()} 287 + {:else} 288 + <div class="flex justify-center py-4"> 289 + <p class="text-xl opacity-80"> 290 + <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 291 + </p> 292 + </div> 293 + {/if} 294 + </div> 295 + 296 + <!-- other views --> 297 + {#if currentView === 'settings'} 298 + <div class={animClass}> 299 + <SettingsView /> 300 + </div> 301 + {:else if currentView === 'notifications'} 302 + <div class={animClass}> 303 + <NotificationsView /> 280 304 </div> 281 305 {/if} 282 306 </div> 283 307 284 - <!-- header --> 285 - <div class="sticky bottom-0 z-10"> 308 + <!-- header / footer --> 309 + <div id="app-footer" class="sticky bottom-0 z-10 mt-4"> 286 310 {#if errors.length > 0} 287 311 <div class="relative m-3 mb-1 error-disclaimer"> 288 312 <div class="flex items-center gap-2 text-red-500"> ··· 315 339 background: linear-gradient(to right, color-mix(in srgb, var(--nucleus-accent) 18%, var(--nucleus-bg)), color-mix(in srgb, var(--nucleus-accent2) 13%, var(--nucleus-bg))); 316 340 " 317 341 > 318 - <!-- composer and error disclaimer (above thread list, not scrollable) --> 319 - <div class="flex gap-2 px-2 pt-2 pb-1"> 320 - <AccountSelector 321 - client={viewClient} 322 - accounts={$accounts} 323 - bind:selectedDid 324 - onAccountSelected={handleAccountSelected} 325 - onLogout={handleLogout} 326 - /> 342 + {#if currentView === 'timeline'} 343 + <!-- composer and error disclaimer (above thread list, not scrollable) --> 344 + <div class="flex gap-2 px-2 pt-2 pb-1"> 345 + <AccountSelector 346 + client={viewClient} 347 + accounts={$accounts} 348 + bind:selectedDid 349 + onAccountSelected={handleAccountSelected} 350 + onLogout={handleLogout} 351 + /> 327 352 328 - {#if selectedClient} 329 - <div class="flex-1"> 330 - <PostComposer 331 - client={selectedClient} 332 - onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)} 333 - bind:_state={postComposerState} 334 - /> 335 - </div> 336 - {:else} 337 - <div 338 - class="flex flex-1 items-center justify-center rounded-sm border-2 border-(--nucleus-accent)/20 bg-(--nucleus-accent)/4 px-4 py-2.5 backdrop-blur-sm" 339 - > 340 - <p class="text-sm opacity-80">select or add an account to post</p> 341 - </div> 342 - {/if} 353 + {#if selectedClient} 354 + <div class="flex-1"> 355 + <PostComposer 356 + client={selectedClient} 357 + onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)} 358 + bind:_state={postComposerState} 359 + /> 360 + </div> 361 + {:else} 362 + <div 363 + class="flex flex-1 items-center justify-center rounded-sm border-2 border-(--nucleus-accent)/20 bg-(--nucleus-accent)/4 px-4 py-2.5 backdrop-blur-sm" 364 + > 365 + <p class="text-sm opacity-80">select or add an account to post</p> 366 + </div> 367 + {/if} 343 368 344 - {#if postComposerState.type === 'null' && showScrollToTop} 345 - {@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top')} 346 - {/if} 347 - </div> 369 + {#if postComposerState.type === 'null' && showScrollToTop} 370 + {@render appButton( 371 + scrollToTop, 372 + 'heroicons:arrow-up-16-solid', 373 + 'scroll to top', 374 + false 375 + )} 376 + {/if} 377 + </div> 348 378 349 - <div 350 - class="mt-1 h-px w-full opacity-50" 351 - style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 352 - ></div> 379 + <div 380 + class="mt-1 h-px w-full opacity-50" 381 + style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 382 + ></div> 383 + {/if} 353 384 354 385 <div class="flex items-center gap-1.5 px-2 py-1"> 355 386 <div class="mb-2"> ··· 361 392 </div> 362 393 <div class="grow"></div> 363 394 {@render appButton( 364 - () => (isNotificationsOpen = true), 395 + () => switchView('timeline'), 396 + 'heroicons:home', 397 + 'timeline', 398 + currentView === 'timeline', 399 + 'heroicons:home-solid' 400 + )} 401 + {@render appButton( 402 + () => switchView('notifications'), 365 403 'heroicons:bell', 366 404 'notifications', 405 + currentView === 'notifications', 367 406 'heroicons:bell-solid' 368 407 )} 369 408 {@render appButton( 370 - () => (isSettingsOpen = true), 409 + () => switchView('settings'), 371 410 'heroicons:cog-6-tooth', 372 411 'settings', 412 + currentView === 'settings', 373 413 'heroicons:cog-6-tooth-solid' 374 414 )} 375 415 </div> 376 - 377 - <!-- <hr 378 - class="h-[4px] w-full rounded-full border-0" 379 - style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 380 - /> --> 381 416 </div> 382 417 </div> 383 418 </div> 384 419 </div> 385 - 386 - <SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} /> 387 - <NotificationsPopup 388 - bind:isOpen={isNotificationsOpen} 389 - onClose={() => (isNotificationsOpen = false)} 390 - /> 391 420 392 421 {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 393 422 <span