Coves frontend - a photon fork
1<script lang="ts">
2 import { browser } from '$app/environment'
3 import { goto } from '$app/navigation'
4 import { coves } from '$lib/api/client.svelte'
5 import type { StrongRef } from '$lib/api/coves/types'
6 import type { DID } from '$lib/types/atproto'
7 import { errorMessage } from '$lib/app/error'
8 import { t } from '$lib/app/i18n'
9 import { Button, toast } from 'mono-svelte'
10 import { ArrowDownCircle } from 'svelte-hero-icons/dist'
11 import Comment from './Comment.svelte'
12 import {
13 type CommentNodeI,
14 buildCommentsTree,
15 searchCommentTree,
16 } from './comments.svelte'
17 import CommentTree from './CommentTree.svelte'
18
19 interface Props {
20 nodes: CommentNodeI[]
21 postRef: StrongRef
22 postAuthorDid?: DID
23 }
24
25 let { nodes = $bindable(), postRef, postAuthorDid }: Props = $props()
26
27 function adjustDepths(nodes: CommentNodeI[], depth: number): void {
28 for (const n of nodes) {
29 n.depth = depth
30 adjustDepths(n.children, depth + 1)
31 }
32 }
33
34 async function fetchChildren(parent: CommentNodeI) {
35 if (
36 !(parent.comment.stats.replyCount > 0 && parent.children.length === 0)
37 ) {
38 return
39 }
40
41 try {
42 parent.loading = true
43
44 // TODO: The API does not yet support a `parent` param to scope to a subtree.
45 // Once supported, pass `parent: parent.comment.uri` to avoid fetching all comments.
46 const response = await coves().getComments({
47 post: postRef.uri,
48 })
49
50 // TODO: response.cursor is currently ignored — use it for pagination support.
51
52 if (response.comments.length === 0) {
53 toast({
54 content: $t('toast.noComments'),
55 type: 'error',
56 })
57 return
58 }
59
60 // The server returns ThreadViewComment[] for the full post tree.
61 // Build with baseDepth=0 since the server returns the complete tree from root.
62 const fullTree = buildCommentsTree(response.comments, 0)
63 const matchedNode = searchCommentTree(fullTree, parent.comment.uri)
64 if (matchedNode) {
65 parent.children = matchedNode.children
66 adjustDepths(parent.children, parent.depth + 1)
67 } else {
68 console.warn(
69 `[comments] Could not find parent node ${parent.comment.uri as string} in fetched comment tree. Children not loaded.`,
70 )
71 toast({
72 content: $t('toast.failedToLoadComments'),
73 type: 'error',
74 })
75 parent.children = []
76 }
77 } catch (err) {
78 console.error(err)
79 toast({
80 content: errorMessage(err),
81 type: 'error',
82 })
83 } finally {
84 parent.loading = false
85 }
86 }
87</script>
88
89<ul>
90 {#each nodes as node, index (node.comment.uri)}
91 <Comment
92 bind:node={nodes[index]}
93 {postRef}
94 {postAuthorDid}
95 contentClass={[
96 (node.children.length > 0 || node.comment.stats.replyCount > 0) &&
97 'border-l',
98 'ml-2.5 pl-3 sm:pl-4 lg:pl-5',
99 'comment-border',
100 ]}
101 bind:open={nodes[index].expanded}
102 >
103 <button
104 class="expand-btn"
105 onclick={() => (nodes[index].expanded = !nodes[index].expanded)}
106 aria-label={$t('comment.expand')}
107 ></button>
108 <div class={['comment-corner', node.depth == 0 && 'hidden']}></div>
109 {#if node.children?.length > 0}
110 <CommentTree
111 {postRef}
112 {postAuthorDid}
113 bind:nodes={nodes[index].children}
114 />
115 {/if}
116 </Comment>
117 {#if node.comment.stats.replyCount > 0 && node.children.length == 0}
118 <svelte:element
119 this={browser ? 'div' : 'a'}
120 class="w-full h-10 -mt-2 -ml-2.5"
121 href="/comment/{encodeURIComponent(node.comment.uri as string)}"
122 >
123 <Button
124 loading={nodes[index].loading}
125 disabled={nodes[index].loading}
126 rounding="pill"
127 color="tertiary"
128 class="font-normal text-slate-600 dark:text-zinc-400"
129 shadow="none"
130 loaderWidth={16}
131 onclick={() => {
132 if (nodes[index].depth > 4) {
133 goto(
134 `/comment/${encodeURIComponent(nodes[index].comment.uri as string)}#comments`,
135 )
136 } else {
137 fetchChildren(nodes[index])
138 }
139 }}
140 icon={ArrowDownCircle}
141 >
142 {$t('comment.more', {
143 comments: node.comment.stats.replyCount,
144 })}
145 </Button>
146 </svelte:element>
147 {/if}
148 {/each}
149</ul>
150
151<style>
152 @reference '../../../app.css';
153
154 :global(.comment-border) {
155 border-color: var(--color-slate-200);
156 @variant dark {
157 border-color: var(--color-zinc-800);
158 }
159
160 transition: border-color 600ms cubic-bezier(0.075, 0.82, 0.165, 1);
161
162 &:has(:global(> * > .expand-btn:hover:not(:active))) {
163 border-color: color-mix(
164 in oklab,
165 var(--color-primary-900),
166 var(--color-slate-500)
167 );
168 @variant dark {
169 border-color: color-mix(
170 in oklab,
171 var(--color-primary-100),
172 var(--color-zinc-500)
173 );
174 }
175 }
176 }
177
178 .expand-btn {
179 width: calc(var(--spacing) * 4);
180 position: absolute;
181 top: 0;
182 left: calc(var(--spacing) * 0.5);
183 height: 100%;
184 cursor: pointer;
185 }
186
187 .comment-corner {
188 position: absolute;
189 top: calc(var(--spacing) * 2);
190 left: calc(var(--spacing) * -3);
191 border-bottom-left-radius: calc(infinity * 1px);
192 border-left-width: 1px;
193 border-bottom-width: 1px;
194 border-color: var(--color-slate-200);
195 @variant dark {
196 border-color: var(--color-zinc-800);
197 }
198 width: calc(var(--spacing) * 3);
199 height: calc(var(--spacing) * 3);
200
201 @variant sm {
202 top: calc(var(--spacing) * 1);
203 left: calc(var(--spacing) * -5.5);
204 width: calc(var(--spacing) * 5);
205 height: calc(var(--spacing) * 5);
206 }
207 }
208</style>