Coves frontend - a photon fork
1<script lang="ts">
2 import { page } from '$app/state'
3 import type { StrongRef } from '$lib/api/coves/types'
4 import type { DID } from '$lib/types/atproto'
5 import { profile } from '$lib/app/auth.svelte'
6 import { t } from '$lib/app/i18n'
7 import Markdown from '$lib/app/markdown/Markdown.svelte'
8 import { settings } from '$lib/app/settings.svelte'
9 import { publishedToDate } from '$lib/ui/util/date'
10 import { Button, Modal, toast } from 'mono-svelte'
11 import RelativeDate from 'mono-svelte/util/RelativeDate.svelte'
12 import { Icon, Microphone, Minus, Plus, Trash } from 'svelte-hero-icons/dist'
13 import { expoOut } from 'svelte/easing'
14 import type { ClassValue } from 'svelte/elements'
15 import { slide } from 'svelte/transition'
16 import UserLink from '../user/UserLink.svelte'
17 import CommentActions from './CommentActions.svelte'
18 import CommentForm from './CommentForm.svelte'
19 import {
20 type CommentNodeI,
21 createOptimisticCommentView,
22 } from './comments.svelte'
23
24 interface Props {
25 node: CommentNodeI
26 postRef: StrongRef
27 postAuthorDid?: DID
28 actions?: boolean
29 meta?: boolean
30 open?: boolean
31 replying?: boolean
32 contentClass?: ClassValue
33 class?: ClassValue
34 metaSuffix?: import('svelte').Snippet
35 children?: import('svelte').Snippet
36 }
37
38 let {
39 node = $bindable(),
40 postRef,
41 postAuthorDid,
42 actions = true,
43 meta = true,
44 replying = $bindable(false),
45 open = $bindable(true),
46 contentClass = '',
47 class: clazz = '',
48 metaSuffix,
49 children,
50 }: Props = $props()
51
52 let editing = $state(false)
53 let newComment = $state(node.comment.record.content)
54 let editingLoad = $state(false)
55
56 async function save() {
57 if (!profile.current?.jwt || newComment.length <= 0) return
58
59 editingLoad = true
60
61 try {
62 // TODO(coves-migration): Implement edit comment when backend API is available
63 toast({
64 content: 'Editing comments is not yet supported.',
65 type: 'warning',
66 })
67 editing = false
68 } catch (err) {
69 toast({
70 content: err instanceof Error ? err.message : String(err),
71 type: 'error',
72 })
73 }
74
75 editingLoad = false
76 }
77</script>
78
79{#if editing}
80 <Modal bind:open={editing}>
81 {#snippet customTitle()}
82 <div>{$t('form.edit')}</div>
83 {/snippet}
84 <form
85 onsubmit={(e) => {
86 e.preventDefault()
87 save()
88 }}
89 class="contents"
90 >
91 <CommentForm
92 bind:value={newComment}
93 {postRef}
94 actions={false}
95 preview={true}
96 />
97 <Button
98 submit
99 color="primary"
100 size="lg"
101 loading={editingLoad}
102 disabled={editingLoad}
103 class="w-full"
104 >
105 {$t('form.submit')}
106 </Button>
107 </form>
108 </Modal>
109{/if}
110
111<li class={['py-3 relative', clazz]} id={node.comment.uri as string}>
112 {#if meta}
113 {@const creatorIsOp =
114 postAuthorDid !== undefined && node.comment.author.did === postAuthorDid}
115 <label
116 for="comment-expand-{node.comment.uri}"
117 class="flex flex-row cursor-pointer gap-2 items-center group text-sm flex-wrap w-full z-0 group relative"
118 >
119 <div
120 class={[
121 'absolute -inset-0.5 right-1 group-hover:right-0 group-hover:-inset-1.5 opacity-0 group-hover:opacity-100 transition-all',
122 'bg-slate-100 dark:bg-zinc-900 -z-10 rounded-full inline-flex items-center justify-end',
123 ]}
124 >
125 {#if node.comment.stats.replyCount > 0}
126 {@const replyCount = node.comment.stats.replyCount}
127 <div
128 aria-label={$t('aria.comments.children', {
129 childCount: replyCount,
130 })}
131 class="font-medium"
132 >
133 {replyCount}
134 </div>
135 {/if}
136 <div
137 class={[
138 !open && 'rotate-90',
139 'transition-all duration-500 ease-out my-auto h-full w-8 grid place-items-center',
140 ]}
141 >
142 <Icon src={open ? Minus : Plus} size="16" micro />
143 </div>
144 </div>
145 {@render metaSuffix?.()}
146 <span
147 class={[
148 'flex flex-row gap-1 items-center',
149 creatorIsOp && 'text-blue-600 dark:text-blue-400 font-bold',
150 ]}
151 >
152 <UserLink inComment avatarSize={20} avatar user={node.comment.author} />
153 </span>
154 {#if creatorIsOp}
155 <Icon
156 mini
157 size="16"
158 src={Microphone}
159 class="text-blue-500 dark:text-blue-400"
160 />
161 {/if}
162 <RelativeDate
163 class="text-slate-600 dark:text-zinc-400"
164 date={publishedToDate(node.comment.createdAt)}
165 />
166 <span class="text-slate-600 dark:text-zinc-400 flex flex-row gap-2 ml-1">
167 {#if node.comment.isDeleted}
168 <Icon
169 src={Trash}
170 solid
171 size="12"
172 aria-label={$t('post.badges.deleted')}
173 class="text-red-600 dark:text-red-500"
174 />
175 {/if}
176 {#if node.comment.deletionReason}
177 <Icon
178 src={Trash}
179 solid
180 size="12"
181 aria-label={$t('post.badges.removed')}
182 class="text-green-600 dark:text-green-500"
183 />
184 {/if}
185 </span>
186 {#if settings.debugInfo}
187 <span class="text-slate-600 dark:text-zinc-400 font-mono ml-auto">
188 {node.comment.uri}
189 </span>
190 {/if}
191 </label>
192 {/if}
193 <input
194 class="appearance-none absolute top-0 left-0 h-8 w-full pointer-events-none comment-expand"
195 type="checkbox"
196 id="comment-expand-{node.comment.uri}"
197 bind:checked={open}
198 />
199 <div class={['expand max-w-full', contentClass]} inert={!open}>
200 <div id="comment-content">
201 <div
202 class={[
203 'flex flex-col whitespace-pre-wrap max-w-full gap-1 mt-1 relative w-full',
204 ]}
205 >
206 <Markdown
207 source={node.comment.record.content}
208 noStyle
209 class={[
210 'text-[15px] sm:text-base text-slate-700 dark:text-zinc-300 *:leading-[1.6] break-words space-y-3',
211 page.url.hash.slice(1) === (node.comment.uri as string) &&
212 'material-info px-3 py-1.5 rounded-xl max-w-max',
213 ]}
214 />
215 {#if actions}
216 <!-- TODO(coves-migration): Re-enable ban/lock checking when API provides banned_from_community and post.locked fields -->
217 <CommentActions
218 comment={node.comment}
219 bind:replying
220 onedit={() => (editing = true)}
221 disabled={false}
222 />
223 {/if}
224 </div>
225
226 {#if replying}
227 <div transition:slide={{ duration: 600, easing: expoOut }}>
228 <CommentForm
229 label={$t('comment.reply')}
230 {postRef}
231 parentRef={{ uri: node.comment.uri, cid: node.comment.cid }}
232 oncomment={(output, content) => {
233 const currentProfile = profile.current
234 if (!currentProfile || currentProfile.type !== 'authenticated') {
235 replying = false
236 return
237 }
238 const comment = createOptimisticCommentView(
239 output,
240 content,
241 postRef,
242 { uri: node.comment.uri, cid: node.comment.cid },
243 {
244 did: currentProfile.did,
245 handle: currentProfile.handle,
246 avatar: currentProfile.avatar,
247 },
248 )
249 node.children = [
250 {
251 children: [],
252 comment,
253 depth: node.depth + 1,
254 expanded: true,
255 },
256 ...node.children,
257 ]
258 replying = false
259 }}
260 oncancel={() => (replying = false)}
261 />
262 </div>
263 {/if}
264 {@render children?.()}
265 </div>
266 </div>
267</li>
268
269<style>
270 .expand {
271 display: grid;
272 grid-template-rows: 0fr;
273 grid-template-columns: 100%;
274 overflow: hidden;
275 transition: grid-template-rows 0.5s cubic-bezier(0.19, 1, 0.22, 1);
276 }
277
278 .comment-expand:checked + .expand {
279 grid-template-rows: 1fr;
280 }
281
282 .expand > * {
283 min-height: 0;
284 }
285</style>