Thread viewer for Bluesky
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>