Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279<script lang="ts"> import { resolve } from "$app/paths"; import type { Did } from "@atcute/lexicons/syntax"; import Pencil from "$icon/pencil"; import Trash2 from "$icon/trash-2"; import { deleteComment } from "$lib/api/comment"; import { getAuth } from "$lib/auth.svelte"; import { type MarkupContext } from "$lib/markup"; import ErrorAlert from "$lib/components/ui/Error.svelte"; import ReportItems from "$lib/components/moderation/ReportItems.svelte"; import { createReporting } from "$lib/components/moderation/reporting.svelte"; import MoreMenu from "$lib/components/ui/MoreMenu.svelte"; import ReactionPicker from "$lib/components/reaction/ReactionPicker.svelte"; import { upsertViewerReaction, withoutViewerReaction, type ReactionGroup, type ReactionKind } from "$lib/components/reaction/reactions"; import Comment from "./Comment.svelte"; import CommentBox from "./CommentBox.svelte"; import CommentEditor from "./CommentEditor.svelte"; import type { CommentThread, CommentView, ThreadInput } from "./comments"; import { createAction } from "$lib/action.svelte";
interface Props { thread: CommentThread; subjectUri: string; subjectCid?: string; markup: MarkupContext; onsubmitted?: (submitted: ThreadInput) => void; onedited?: (edited: ThreadInput) => void; ondeleted?: (uri: string) => void; }
let { thread, subjectUri, subjectCid, markup, onsubmitted, onedited, ondeleted }: Props = $props();
const auth = getAuth(); const currentUser = $derived(auth?.currentUser ?? null); const reporting = createReporting();
let replying = $state(false); let editingUri = $state<string | null>(null);
let reactionsByUri = $derived.by(() => { const map: Record<string, ReactionGroup[]> = {}; map[thread.self.uri] = thread.self.reactions ?? []; for (const reply of thread.replies) map[reply.uri] = reply.reactions ?? []; return map; });
const setReactions = (uri: string, next: ReactionGroup[]) => { reactionsByUri = { ...reactionsByUri, [uri]: next }; };
// kinds the viewer reacted with, mapped to their record's rkey so the picker can toggle const reactedMap = (uri: string): Map<ReactionKind, string> => new Map( (reactionsByUri[uri] ?? []) .filter((g) => g.isReacted && g.viewerRkey) .map((g) => [g.kind, g.viewerRkey!] as [ReactionKind, string]) );
const onReacted = (uri: string) => (kind: ReactionKind, rkey: string) => { const viewer = currentUser; if (!viewer) return; setReactions( uri, upsertViewerReaction(reactionsByUri[uri] ?? [], kind, viewer.handle, rkey) ); };
const onUnreacted = (uri: string) => (kind: ReactionKind) => { const viewer = currentUser; if (!viewer) return; setReactions( uri, (reactionsByUri[uri] ?? []) .map((g) => (g.kind === kind ? withoutViewerReaction(g, viewer.handle) : g)) .filter((g) => g.count > 0) ); };
const deleteSelected = createAction(async (comment: CommentView) => { if (!auth) return; const agent = await auth.agentFor(comment.authorDid as Did); await deleteComment(agent, comment.rkey); ondeleted?.(comment.uri); });
const handleDelete = (comment: CommentView) => { if (deleteSelected.loading) return; if (!confirm("Delete this comment? This cannot be undone.")) return; void deleteSelected.run(comment); };</script>
{#snippet commentActions(comment: CommentView)} {#if currentUser} <div class="absolute top-4 right-4 flex items-center gap-2 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100" > <ReactionPicker subjectUri={comment.uri} reacted={reactedMap(comment.uri)} onreacted={onReacted(comment.uri)} onunreacted={onUnreacted(comment.uri)} /> {#if auth?.hasAccount(comment.authorDid)} <button type="button" aria-label="Edit comment" class="cursor-pointer text-foreground-subtle hover:text-foreground-default" onclick={() => (editingUri = comment.uri)} > <Pencil class="size-3" /> </button> <button type="button" aria-label="Delete comment" disabled={deleteSelected.loading && deleteSelected.args?.[0].uri === comment.uri} class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" onclick={() => handleDelete(comment)} > <Trash2 class="size-3" /> </button> {:else if reporting.canReport(comment.authorDid)} <MoreMenu group="comment-actions" label={`More actions for ${comment.authorHandle}'s reply`} > <ReportItems subjectUri={comment.uri} subjectCid={comment.cid} subjectNoun="reply" authorDid={comment.authorDid} authorHandle={comment.authorHandle} /> </MoreMenu> {/if} </div> {/if}{/snippet}
{#snippet commentEditor( comment: CommentView, replyUri: string | undefined, replyCid: string | undefined)} <CommentEditor {subjectUri} {subjectCid} replyToUri={replyUri} replyToCid={replyCid} authorDid={comment.authorDid} authorHandle={comment.authorHandle} rkey={comment.rkey} createdAt={comment.createdAt} body={comment.body} {markup} rows={4} placeholder="Edit your comment. Markdown is supported." submitLabel="Save" submitIcon={Pencil} autofocus onsubmitted={(edited) => { editingUri = null; onedited?.(edited); }} oncancel={() => (editingUri = null)} />{/snippet}
<div class="overflow-hidden rounded-sm border border-border-default bg-background-canvas bg-background-default/50 drop-shadow-xs"> {#snippet selfEditor()} {@render commentEditor(thread.self, undefined, undefined)} {/snippet} <div class="group relative"> <Comment authorHandle={thread.self.authorHandle} authorDid={thread.self.authorDid} createdAt={thread.self.createdAt} body={thread.self.body} bodyHtml={thread.self.bodyHtml} variant="top" deleted={thread.self.deleted} reactions={reactionsByUri[thread.self.uri]} subjectUri={thread.self.uri} onreactionschange={(next) => setReactions(thread.self.uri, next)} editor={editingUri === thread.self.uri ? selfEditor : undefined} /> {#if editingUri !== thread.self.uri && !thread.self.deleted} {@render commentActions(thread.self)} {/if} </div>
{#if thread.replies.length} <div class="ml-10"> {#each thread.replies as reply, i (reply.uri)} {#snippet replyEditor()} {@render commentEditor(reply, thread.self.uri, thread.self.cid)} {/snippet} <div class="group relative isolate -ml-4"> <!-- thread connector; on the last reply it stops at the avatar's centre (h-8) --> <div class={`pointer-events-none absolute top-0 left-4 -z-10 w-0.5 -translate-x-1 bg-border-default ${i === thread.replies.length - 1 ? "h-8" : "bottom-0"}`} ></div> <Comment authorHandle={reply.authorHandle} authorDid={reply.authorDid} createdAt={reply.createdAt} body={reply.body} bodyHtml={reply.bodyHtml} variant="reply" reactions={reactionsByUri[reply.uri]} subjectUri={reply.uri} onreactionschange={(next) => setReactions(reply.uri, next)} editor={editingUri === reply.uri ? replyEditor : undefined} /> {#if editingUri !== reply.uri} {@render commentActions(reply)} {/if} </div> {/each} </div> {/if}
{#if deleteSelected.error} <div class="border-t border-border-default px-6 py-2"> <ErrorAlert label={deleteSelected.error} /> </div> {/if}
{#if thread.self.deleted && !thread.self.cid} <!-- deleted parent with no recoverable strongRef; nothing to reply against --> {:else if replying && currentUser} <div class={thread.replies.length ? "border-t border-border-default" : ""}> <CommentBox variant="thread" {subjectUri} {subjectCid} replyToUri={thread.self.uri} replyToCid={thread.self.cid} authorDid={currentUser.did} authorHandle={currentUser.handle} {markup} autofocus onsubmitted={(submitted) => { replying = false; onsubmitted?.(submitted); }} oncancel={() => (replying = false)} /> </div> {:else} <div class={`flex items-center gap-2 bg-background-default/50 px-6 py-2 ${thread.replies.length ? "border-t border-border-default" : ""}`} > {#if currentUser} <button type="button" class="w-full cursor-text text-left text-foreground-subtle focus:outline-none" onclick={() => (replying = true)} > Leave a reply... </button> {:else} <span class="text-foreground-subtle"> <a href={resolve("/login")} class="underline">Login</a> to leave a reply </span> {/if} </div> {/if}</div>