Thread viewer for Bluesky
at 2.0 223 lines 5.9 kB view raw
1<script module lang="ts"> 2 export const [getPostContext, setPostContext] = createContext<{ post: Post, placement: PostPlacement}>(); 3</script> 4 5<script lang="ts"> 6 import { createContext } from 'svelte'; 7 import { settings } from '../../models/settings.svelte.js'; 8 import { Post, BlockedPost } from '../../models/posts.js'; 9 import { Embed, InlineLinkEmbed } from '../../models/embeds.js'; 10 import { isValidURL, showError } from '../../utils.js'; 11 12 import EdgeMargin from './EdgeMargin.svelte'; 13 import FediSourceLink from './FediSourceLink.svelte'; 14 import HiddenRepliesLink from './HiddenRepliesLink.svelte'; 15 import LoadMoreLink from './LoadMoreLink.svelte'; 16 import PostBody from './PostBody.svelte'; 17 import PostComponent from './PostComponent.svelte'; 18 import PostHeader from './PostHeader.svelte'; 19 import PostTagsRow from './PostTagsRow.svelte'; 20 import PostFooter from './PostFooter.svelte'; 21 import PostWrapper from './PostWrapper.svelte'; 22 23 import EmbedComponent from '../embeds/EmbedComponent.svelte'; 24 25 /** 26 Contexts: 27 - thread - a post in the thread tree 28 - parent - parent reference above the thread root 29 - quote - a quote embed 30 - quotes - a post on the quotes page 31 - feed - a post on the hashtag feed page 32 */ 33 34 type Props = { 35 post: Post, 36 placement: PostPlacement, 37 highlightedMatches?: string[] | undefined, 38 class?: string | undefined 39 } 40 41 let { post, placement, highlightedMatches = undefined, ...props }: Props = $props(); 42 43 let collapsed = $state(false); 44 let replies: AnyPost[] = $state(post.replies); 45 let repliesLoaded = $state(false); 46 let missingHiddenReplies: number | undefined = $state(); 47 48 setPostContext({ post, placement }); 49 50 // TODO: make Post reactive 51 let quoteCount: number | undefined = $state(post.quoteCount); 52 53 export function setQuoteCount(x: number) { 54 quoteCount = x; 55 } 56 57 function shouldRenderReply(reply: AnyPost): boolean { 58 if (reply instanceof Post) { 59 return true; 60 } else if (reply instanceof BlockedPost) { 61 return (settings.biohazardsEnabled !== false); 62 } else { 63 return false; 64 } 65 } 66 67 function shouldRenderEmbed(embed: Embed): boolean { 68 if (post.originalFediURL) { 69 if (embed instanceof InlineLinkEmbed && embed.title?.startsWith('Original post on ')) { 70 return false; 71 } 72 } 73 74 return true; 75 } 76 77 function onMoreRepliesLoaded(newPost: Post) { 78 post.updateDataFromPost(newPost); 79 replies = post.replies; 80 } 81 82 function onHiddenRepliesLoaded(newReplies: (AnyPost | null)[]) { 83 let okReplies = newReplies.filter(x => x !== null); 84 replies.push(...okReplies); 85 post.replies = replies; 86 87 if (okReplies.length === newReplies.length && okReplies.length > 0) { 88 missingHiddenReplies = undefined; 89 } else { 90 missingHiddenReplies = newReplies.length - okReplies.length; 91 } 92 93 repliesLoaded = true; 94 } 95 96 function onRepliesLoadingError(error: Error) { 97 showError(error); 98 } 99</script> 100 101{#snippet body()} 102 <PostBody {highlightedMatches} /> 103 104 {#if post.tags} 105 <PostTagsRow /> 106 {/if} 107 108 {#if post.embed && shouldRenderEmbed(post.embed)} 109 <EmbedComponent embed={post.embed} /> 110 {/if} 111 112 {#if post.originalFediURL && isValidURL(post.originalFediURL)} 113 <FediSourceLink url={post.originalFediURL} /> 114 {/if} 115 116 {#if post.likeCount !== undefined || post.repostCount !== undefined} 117 <PostFooter {quoteCount} /> 118 {/if} 119{/snippet} 120 121<div class="post post-{placement} {props.class || ''}" class:muted={post.muted} class:collapsed={collapsed}> 122 <PostHeader /> 123 124 {#if placement == 'thread' && !post.isPageRoot} 125 <EdgeMargin bind:collapsed /> 126 {/if} 127 128 <div class="content"> 129 {#if post.muted} 130 <details> 131 <summary>{post.muteList ? `Muted (${post.muteList})` : 'Muted - click to show'}</summary> 132 133 {@render body()} 134 </details> 135 {:else} 136 {@render body()} 137 {/if} 138 139 {#if post.replyCount == 1 && (replies[0] instanceof Post) && replies[0].author.did == post.author.did} 140 <PostComponent post={replies[0]} placement="thread" class="flat" /> 141 {:else} 142 {#each replies as reply (reply.uri)} 143 {#if shouldRenderReply(reply)} 144 <PostWrapper post={reply} placement="thread" /> 145 {/if} 146 {/each} 147 {/if} 148 149 {#if placement == 'thread' && !repliesLoaded} 150 {#key replies} 151 {#if post.hasMoreReplies} 152 <LoadMoreLink onLoad={onMoreRepliesLoaded} onError={onRepliesLoadingError} /> 153 {:else if post.hasHiddenReplies && settings.biohazardsEnabled !== false} 154 <HiddenRepliesLink onLoad={onHiddenRepliesLoaded} onError={onRepliesLoadingError} /> 155 {/if} 156 {/key} 157 {/if} 158 159 {#if missingHiddenReplies !== undefined} 160 <p class="missing-replies-info"> 161 <i class="fa-solid fa-ban"></i> 162 {#if missingHiddenReplies > 1} 163 {missingHiddenReplies} replies are missing 164 {:else if missingHiddenReplies == 1} 165 1 reply is missing 166 {:else} 167 Some replies are missing 168 {/if} 169 (likely taken down by moderation) 170 </p> 171 {/if} 172 </div> 173</div> 174 175<style> 176 :global(.post) { 177 position: relative; 178 padding-left: 21px; 179 margin-top: 30px; 180 } 181 182 .post.collapsed .content { 183 display: none; 184 } 185 186 .post.flat { 187 padding-left: 0px; 188 margin-top: 25px; 189 } 190 191 .post.muted > :global(h2) { 192 opacity: 0.3; 193 font-weight: 600; 194 } 195 196 .post.muted > :global(.content > details > p), .post.muted > :global(.content > details summary) { 197 opacity: 0.3; 198 } 199 200 details { 201 margin-top: 12px; 202 margin-bottom: 10px; 203 } 204 205 summary { 206 font-size: 10pt; 207 user-select: none; 208 -webkit-user-select: none; 209 cursor: default; 210 } 211 212 .missing-replies-info { 213 font-size: 11pt; 214 color: darkred; 215 margin-top: 25px; 216 } 217 218 .post :global(img.loader) { 219 width: 24px; 220 animation: rotation 3s infinite linear; 221 margin-top: 5px; 222 } 223</style>