~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
564 lines · 16.0 KB Raw History
<script lang="ts">
  import type { CommentView } from '$hatk/client'
  import { callXrpc } from '$hatk/client'
  import { useQueryClient } from '@tanstack/svelte-query'
  import { viewer, requireAuth } from '$lib/stores'
  import { parseTextToFacets } from '$lib/utils/rich-text'
  import CommentItem from '../molecules/Comment.svelte'
  import Avatar from '../atoms/Avatar.svelte'
  import { X, LoaderCircle } from 'lucide-svelte'

  let {
    open = false,
    subjectUri,
    onClose,
    contained = false,
    inline = false,
    pool = undefined,
    before,
    footer,
  }: {
    open: boolean
    subjectUri: string
    onClose: () => void
    /**
     * The group whose pool the subject is in, when it is in one. The thread
     * then lives in that space: every comment is its author's own record inside
     * it, read across the members' repos at request time and readable by nobody
     * else. There is no index behind it, so there is no cursor either — the
     * pool hands over the whole thread or none of it.
     */
    pool?: string
    /** Absolute panel over the story viewer's own dark surface. */
    contained?: boolean
    /** Sits in normal flow as a column of the page — no overlay, no close. */
    inline?: boolean
    /** Rendered at the top of the scroll area, so a caption scrolls with the
        comments instead of competing with them for the column's height. */
    before?: import('svelte').Snippet
    /** Rendered just above the composer, for pinned actions. */
    footer?: import('svelte').Snippet
  } = $props()

  let comments = $state<CommentView[]>([])
  let totalCount = $state(0)
  let cursor = $state<string | undefined>(undefined)
  let loading = $state(false)
  let loadingMore = $state(false)
  let posting = $state(false)
  let inputValue = $state('')
  let replyToUri = $state<string | null>(null)
  let replyToHandle = $state<string | null>(null)
  let error = $state<string | null>(null)
  let inputEl = $state<HTMLInputElement | null>(null)
  let sheetEl = $state<HTMLDivElement | null>(null)

  const queryClient = useQueryClient()

  // Organize comments: roots first, then their replies
  const organized = $derived.by(() => {
    const roots = comments.filter((c) => !c.replyTo)
    const replyMap = new Map<string, CommentView[]>()
    for (const c of comments) {
      if (c.replyTo) {
        if (!replyMap.has(c.replyTo)) replyMap.set(c.replyTo, [])
        replyMap.get(c.replyTo)!.push(c)
      }
    }
    const result: CommentView[] = []
    for (const root of roots) {
      result.push(root)
      const replies = replyMap.get(root.uri) ?? []
      result.push(...replies)
    }
    return result
  })

  // Constrain sheet to center column on desktop
  $effect(() => {
    if (!open || !sheetEl || contained) return
    const col = sheetEl.closest('main')
    if (!col) return
    function position() {
      const rect = col!.getBoundingClientRect()
      sheetEl!.style.left = `${rect.left}px`
      sheetEl!.style.right = `${document.documentElement.clientWidth - rect.right}px`
    }
    position()
    window.addEventListener('resize', position)
    return () => window.removeEventListener('resize', position)
  })

  // Load comments when sheet opens
  $effect(() => {
    if (open && subjectUri) {
      loadComments()
    }
  })

  /** A pooled subject is `at://<author>/social.grain.gallery/<rkey>`. */
  const poolTarget = $derived.by(() => {
    if (!pool) return null
    const m = subjectUri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/)
    return m ? { group: pool, did: m[1], rkey: m[2] } : null
  })

  async function loadComments() {
    loading = true
    error = null
    try {
      if (poolTarget) {
        const res: any = await callXrpc(
          'social.grain.unspecced.getPoolGallery',
          poolTarget as never,
        )
        comments = res?.comments ?? []
        cursor = undefined
        totalCount = res?.commentCount ?? comments.length
        return
      }
      const res = await callXrpc('social.grain.unspecced.getCommentThread', {
        subject: subjectUri,
        limit: 20,
      } as any)
      comments = (res as any).comments ?? []
      cursor = (res as any).cursor
      totalCount = (res as any).totalCount ?? 0
    } catch (err: any) {
      error = 'Failed to load comments'
      console.error(err)
    } finally {
      loading = false
    }
  }

  async function loadMore() {
    if (!cursor || loadingMore) return
    loadingMore = true
    try {
      const res = await callXrpc('social.grain.unspecced.getCommentThread', {
        subject: subjectUri,
        limit: 20,
        cursor,
      } as any)
      const older = (res as any).comments ?? []
      comments = [...older, ...comments]
      cursor = (res as any).cursor
    } catch (err: any) {
      console.error(err)
    } finally {
      loadingMore = false
    }
  }

  async function handlePost() {
    if (!requireAuth()) return
    if (!inputValue.trim() || posting) return
    posting = true
    error = null

    try {
      const text = inputValue.trim()
      const now = new Date().toISOString()

      // Parse facets
      const parsed = await parseTextToFacets(text)
      const facets = parsed.facets.length > 0 ? parsed.facets : undefined

      const result = poolTarget
        ? await callXrpc('social.grain.unspecced.createPoolComment', {
            group: poolTarget.group,
            gallery: subjectUri,
            text,
            ...(facets ? { facets } : {}),
            ...(replyToUri ? { replyTo: replyToUri } : {}),
          } as never)
        : await callXrpc('dev.hatk.createRecord', {
            collection: 'social.grain.comment',
            record: {
              text,
              subject: subjectUri,
              ...(facets ? { facets } : {}),
              ...(replyToUri ? { replyTo: replyToUri } : {}),
              createdAt: now,
            },
          })

      // Optimistic add
      const newComment: CommentView = {
        uri: (result as any).uri,
        cid: (result as any).cid,
        text,
        facets,
        author: {
          did: $viewer!.did,
          handle: $viewer!.handle ?? $viewer!.did,
          displayName: $viewer!.displayName,
          avatar: $viewer!.avatar ?? undefined,
          cid: '',
        },
        replyTo: replyToUri ?? undefined,
        createdAt: now,
      } as any

      comments = [...comments, newComment]
      totalCount++
      inputValue = ''
      replyToUri = null
      replyToHandle = null

      // Invalidate feed queries to update comment counts
      queryClient.invalidateQueries({ queryKey: ['getFeed'], refetchType: 'none' })
      if (poolTarget) queryClient.invalidateQueries({ queryKey: ['poolGallery'] })
    } catch (err: any) {
      error = 'Failed to post comment'
      console.error(err)
    } finally {
      posting = false
    }
  }

  async function handleDelete(uri: string) {
    const rkey = uri.split('/').pop()!
    try {
      if (poolTarget) {
        await callXrpc('social.grain.unspecced.deletePoolComment', {
          group: poolTarget.group,
          rkey,
        } as never)
      } else {
        await callXrpc('dev.hatk.deleteRecord', {
          collection: 'social.grain.comment',
          rkey,
        })
      }
      comments = comments.filter((c) => c.uri !== uri)
      totalCount--
      queryClient.invalidateQueries({ queryKey: ['getFeed'], refetchType: 'none' })
      if (poolTarget) queryClient.invalidateQueries({ queryKey: ['poolGallery'] })
    } catch (err: any) {
      console.error('Failed to delete comment:', err)
    }
  }

  function handleReply(uri: string, handle: string) {
    replyToUri = uri
    replyToHandle = handle
    inputValue = `@${handle} `
    inputEl?.focus()
  }

  function cancelReply() {
    replyToUri = null
    replyToHandle = null
    inputValue = ''
  }
</script>

{#if open}
  {#if !contained && !inline}
    <div class="overlay" onclick={onClose} onkeydown={(e) => e.key === 'Escape' && onClose()} role="button" tabindex="-1"></div>
  {/if}
  <div class="sheet" class:contained class:inline bind:this={sheetEl}>
    <!-- The split view reads top-to-bottom as author -> caption -> comments,
         so a "Comments (n)" bar between the caption and the thread only pushes
         the two apart. The sheet and contained modes still need the title. -->
    {#if !inline}
      <div class="sheet-header">
        <span class="sheet-title">Comments ({totalCount})</span>
        <button class="close-btn" onclick={onClose}>
          <X size={20} />
        </button>
      </div>
    {/if}

    <div class="comment-list">
      {#if before}
        <div class="list-before">{@render before()}</div>
      {/if}
      {#if cursor}
        <button class="load-more" onclick={loadMore} disabled={loadingMore}>
          {#if loadingMore}
            <LoaderCircle size={14} class="spin" /> Loading...
          {:else}
            Load earlier comments
          {/if}
        </button>
      {/if}

      {#if loading}
        <div class="empty"><LoaderCircle size={24} class="spin" /></div>
      {:else if organized.length === 0}
        <div class="empty">No comments yet. Be the first!</div>
      {:else}
        {#each organized as comment (comment.uri)}
          <CommentItem {comment} onReply={handleReply} onDelete={handleDelete} />
        {/each}
      {/if}
    </div>

    {#if error}
      <p class="error">{error}</p>
    {/if}

    {#if footer}
      <div class="list-footer">{@render footer()}</div>
    {/if}

    <!-- Directly above the composer it applies to. Rendered before the actions
         row, the reply context was separated from its own input by the heart. -->
    {#if replyToHandle}
      <div class="reply-bar">
        <span>Replying to @{replyToHandle}</span>
        <button class="cancel-reply" onclick={cancelReply}>
          <X size={14} />
        </button>
      </div>
    {/if}
    <div class="input-bar">
      {#if $viewer}
        <Avatar did={$viewer.did} src={$viewer.avatar} name={$viewer.displayName || $viewer.handle} size={28} />
      {/if}
      <div class="input-wrapper">
        <input
          type="text"
          placeholder="Add a comment..."
          bind:value={inputValue}
          bind:this={inputEl}
          disabled={posting}
          onkeydown={(e) => e.key === 'Enter' && handlePost()}
        />
        <button class="send-btn" onclick={handlePost} disabled={!inputValue.trim() || posting}>
          {#if posting}
            <LoaderCircle size={14} class="spin" />
          {:else}
            Post
          {/if}
        </button>
      </div>
    </div>
  </div>
{/if}

<style>
  .overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.5);
    z-index: 200;
  }
  .sheet {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    max-height: 70vh;
    background: var(--bg-root);
    border-radius: 16px 16px 0 0;
    z-index: 201;
    display: flex;
    flex-direction: column;
  }
  /* Inline: part of the page rather than over it, so it takes the theme's own
     surface and stretches to whatever height the column gives it. */
  .sheet.inline {
    position: static;
    max-height: none;
    /* Not height:100% — the column sizes itself from its content now, so a
       percentage has no definite parent to resolve against and the sheet grows
       to fit every comment instead of scrolling them. Flex sizing shrinks
       against the column's clamped height. */
    flex: 1 1 0;
    min-height: 0;
    border-radius: 0;
    background: none;
    box-shadow: none;
    z-index: auto;
  }
  .sheet.inline .input-bar,
  .sheet.inline .reply-bar {
    padding-left: 0;
    padding-right: 0;
    /* No side padding here to inset a tint against, so the fill ran edge to
       edge with the text flush against it. Plain text suits the flat column;
       the bottom sheet keeps its tint. */
    background: none;
    padding-bottom: 4px;
  }
  .sheet.inline .input-bar {
    padding-bottom: 16px;
  }
  .list-before { padding-bottom: 4px; }
  /* The bottom padding is load-bearing: without it the actions row sits
     directly on the composer's border-top and reads as crowded. */
  .list-footer { padding: 10px 0 12px; }
  .sheet.inline .comment-list {
    flex: 1;
    /* Never collapse to a slit: the thread is the point of the column, so it
       claims a floor and the sidebar grows to accommodate it. */
    min-height: 140px;
    /* The actions row and composer are siblings of this list, not children, so
       its side padding indented the caption and comments away from them. */
    padding-left: 0;
    padding-right: 0;
  }
  /* 16px is the largest text in a column where the comments it writes are 14px.
     Deliberately not applied to the bottom sheet: sub-16px inputs make iOS zoom
     on focus, and the inline sheet is desktop-only. */
  .sheet.inline .input-wrapper input {
    font-size: 14px;
  }
  .sheet.contained {
    position: absolute;
    max-height: 60%;
    background: rgba(30, 30, 30, 0.95);
    backdrop-filter: blur(12px);
    z-index: 20;
  }
  .sheet.contained .sheet-header,
  .sheet.contained .input-bar,
  .sheet.contained .reply-bar {
    border-color: rgba(255, 255, 255, 0.1);
  }
  @media (max-width: 600px) {
    .sheet:not(.contained) {
      bottom: calc(50px + env(safe-area-inset-bottom, 0px));
    }
  }
  .sheet-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 16px;
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
  }
  .sheet-title {
    font-weight: 600;
    font-size: 16px;
  }
  .close-btn {
    background: none;
    border: none;
    color: var(--text-primary);
    cursor: pointer;
    padding: 4px;
    display: flex;
  }

  .comment-list {
    flex: 1;
    overflow-y: auto;
    padding: 8px 16px;
  }
  .empty {
    text-align: center;
    color: var(--text-muted);
    padding: 32px 0;
    font-size: 14px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .load-more {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    width: 100%;
    padding: 8px;
    margin-bottom: 8px;
    background: none;
    border: 1px solid var(--border);
    border-radius: 8px;
    color: var(--text-secondary);
    font-size: 13px;
    cursor: pointer;
    font-family: inherit;
  }
  .load-more:hover { background: var(--bg-hover); }
  .load-more:disabled { opacity: 0.5; cursor: not-allowed; }

  .error {
    color: var(--danger);
    font-size: 13px;
    text-align: center;
    margin: 0;
    padding: 8px 16px;
  }

  .reply-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 8px 16px;
    background: var(--bg-hover);
    font-size: 13px;
    color: var(--text-secondary);
    border-top: 1px solid var(--border);
  }
  /* Now adjacent to the composer, so only one of the two draws the rule that
     separates the thread from the input. */
  .reply-bar + .input-bar {
    border-top: none;
  }
  .cancel-reply {
    background: none;
    border: none;
    color: var(--text-muted);
    cursor: pointer;
    padding: 2px;
    display: flex;
  }

  .input-bar {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 12px 16px;
    padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
    border-top: 1px solid var(--border);
    flex-shrink: 0;
  }
  .input-wrapper {
    flex: 1;
    display: flex;
    align-items: center;
    background: var(--bg-hover);
    border-radius: 20px;
    padding: 6px 6px 6px 14px;
  }
  .input-wrapper input {
    flex: 1;
    background: none;
    border: none;
    outline: none;
    font-size: 16px;
    color: var(--text-primary);
    font-family: inherit;
  }
  .input-wrapper input::placeholder {
    color: var(--text-muted);
  }
  .send-btn {
    background: none;
    border: none;
    color: var(--grain);
    font-weight: 600;
    font-size: 14px;
    cursor: pointer;
    padding: 4px 8px;
    font-family: inherit;
    display: flex;
    align-items: center;
  }
  .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }

  :global(.spin) {
    animation: spin 1s linear infinite;
  }
  @keyframes spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
  }
</style>