feat(comments): reply action with prefilled quote and jump-to-original chevron

Each rendered comment gets a "Reply" action (shown whenever the
viewer has write access, regardless of authorship). Clicking it
prefills the comment editor with a <blockquote data-comment-id="X">
wrapping the parent body so the canonical reply marker is the
blockquote itself.

A Vue NodeView on the blockquote extension renders an author
header + chevron when an injected commentReplyContext can resolve
the parent. The chevron scrolls to and briefly highlights the
original. Quotes whose parent isn't in the in-memory list (deleted,
on another page) render a degraded header with the chevron hidden.
This commit is contained in:
kolaente 2026-05-19 19:26:28 +02:00 committed by kolaente
parent 46dbeb5784
commit 82975f9bd2
6 changed files with 298 additions and 13 deletions

View File

@ -0,0 +1,161 @@
<template>
<NodeViewWrapper
as="blockquote"
class="comment-quote"
:class="{'comment-quote--has-parent': hasParent}"
:data-comment-id="commentId === null ? null : String(commentId)"
>
<div
v-if="commentId !== null && ctx"
contenteditable="false"
class="comment-quote__header"
>
<template v-if="parent">
<img
v-if="avatarUrl"
:src="avatarUrl"
alt=""
class="comment-quote__avatar"
width="20"
height="20"
>
<span class="comment-quote__author">{{ authorName }}</span>
<BaseButton
v-tooltip="t('task.comment.jumpToOriginal')"
class="comment-quote__jump"
:aria-label="t('task.comment.jumpToOriginal')"
@click="onJump"
>
<Icon icon="angle-right" />
</BaseButton>
</template>
<span
v-else
class="comment-quote__author comment-quote__author--missing"
>
{{ t('task.comment.deletedComment') }}
</span>
</div>
<NodeViewContent class="comment-quote__body" />
</NodeViewWrapper>
</template>
<script lang="ts" setup>
import {computed, inject, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {nodeViewProps, NodeViewWrapper, NodeViewContent} from '@tiptap/vue-3'
import BaseButton from '@/components/base/BaseButton.vue'
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
import {commentReplyContextKey} from '@/components/tasks/partials/commentReplyContext'
const props = defineProps(nodeViewProps)
const {t} = useI18n({useScope: 'global'})
const ctx = inject(commentReplyContextKey, null)
const commentId = computed<number | null>(() => {
const raw = props.node.attrs.commentId
if (raw === null || raw === undefined) {
return null
}
const id = Number(raw)
return Number.isInteger(id) && id > 0 ? id : null
})
const parent = computed(() => {
if (commentId.value === null || !ctx) {
return undefined
}
return ctx.findComment(commentId.value)
})
const hasParent = computed(() => parent.value !== undefined)
const authorName = computed(() => {
const p = parent.value
return p ? getDisplayName(p.author) : ''
})
const avatarUrl = ref('')
// Bumped on every parent change so stale avatar fetches (older parent)
// don't overwrite a newer one if the user navigates between comments
// while fetches are still in flight.
let avatarFetchToken = 0
watch(parent, (p) => {
avatarUrl.value = ''
const token = ++avatarFetchToken
if (!p?.author) {
return
}
fetchAvatarBlobUrl(p.author, 20)
.then((url) => {
if (token === avatarFetchToken) {
avatarUrl.value = (url as string) ?? ''
}
})
.catch(() => {
// Swallow a missing avatar isn't worth a user-visible error;
// the header still renders with the author name.
})
}, {immediate: true})
function onJump() {
if (commentId.value !== null && ctx) {
ctx.scrollToComment(commentId.value)
}
}
</script>
<style lang="scss">
.tiptap blockquote.comment-quote {
margin-block: .5rem;
.comment-quote__header {
display: flex;
align-items: center;
gap: .5rem;
padding-block-end: .25rem;
font-size: .85rem;
color: var(--grey-600);
user-select: none;
}
.comment-quote__avatar {
border-radius: 50%;
flex: 0 0 auto;
}
.comment-quote__author {
font-weight: 600;
color: var(--grey-700);
&--missing {
font-style: italic;
color: var(--grey-500);
}
}
.comment-quote__jump {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--grey-500);
padding: .15rem .25rem;
border-radius: 9999px;
transition: background-color $transition, color $transition;
&:hover {
color: var(--grey-800);
background: var(--grey-200);
}
}
.comment-quote__body > :first-child {
margin-block-start: 0;
}
}
</style>

View File

@ -778,6 +778,24 @@ function setModeAndValue(value: string) {
})
}
// Replace the editor content with a reply draft (prefilled blockquote + empty
// paragraph) and enter edit mode immediately so the user can start typing.
// Returns synchronously after the next tick to let DOM updates settle.
async function setReplyContent(value: string) {
if (!editor.value) return
editor.value.commands.setContent(value, {
...defaultSetContentOptions,
emitUpdate: false,
})
internalMode.value = 'edit'
modelValue.value = editor.value.getHTML()
contentHasChanged.value = true
await nextTick()
editor.value.commands.focus('end')
}
defineExpose({setReplyContent})
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function setFocusToEditor(event: KeyboardEvent) {

View File

@ -1,4 +1,7 @@
import Blockquote from '@tiptap/extension-blockquote'
import {VueNodeViewRenderer} from '@tiptap/vue-3'
import BlockquoteCommentView from './BlockquoteCommentView.vue'
/**
* Blockquote extension that preserves `data-comment-id` across parse/serialize.
@ -6,6 +9,11 @@ import Blockquote from '@tiptap/extension-blockquote'
* stores the referenced comment's id on the wrapping blockquote, so both the
* backend (for implicit-mention notifications) and the frontend (for the
* jump-to-original chevron) can find it without a separate schema field.
*
* A Vue NodeView renders the in-app header + chevron when the surrounding
* component (Comments.vue) provides a `commentReplyContext`. Outside that
* context (task descriptions, etc.) the NodeView falls back to a plain
* blockquote.
*/
export const BlockquoteWithCommentId = Blockquote.extend({
addAttributes() {
@ -35,4 +43,8 @@ export const BlockquoteWithCommentId = Blockquote.extend({
},
}
},
addNodeView() {
return VueNodeViewRenderer(BlockquoteCommentView)
},
})

View File

@ -173,6 +173,7 @@
<div class="field">
<Editor
v-if="editorActive"
ref="newCommentEditor"
v-model="newCommentText"
:class="{
'is-loading':
@ -222,7 +223,7 @@
</template>
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, watch} from 'vue'
import {ref, reactive, computed, nextTick, provide, shallowReactive, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
@ -246,6 +247,7 @@ import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
import Reactions from '@/components/input/Reactions.vue'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {commentReplyContextKey, scrollAndHighlightComment} from '@/components/tasks/partials/commentReplyContext'
const props = withDefaults(defineProps<{
taskId: number,
@ -304,15 +306,19 @@ const actions = computed(() => {
if (!props.canWrite) {
return {}
}
return Object.fromEntries(comments.value.map((comment) => ([
comment.id,
comment.author.id === currentUserId.value
? [{
return Object.fromEntries(comments.value.map((comment) => {
const list: {action: () => void, title: string}[] = [{
action: () => startReplyTo(comment),
title: t('task.comment.reply'),
}]
if (comment.author.id === currentUserId.value) {
list.push({
action: () => toggleDelete(comment.id),
title: t('misc.delete'),
}]
: [],
])))
})
}
return [comment.id, list]
}))
})
const frontendUrl = computed(() => configStore.frontendUrl)
@ -321,6 +327,55 @@ const commentStorageKey = computed(() => `task-comment-${props.taskId}`)
const currentPage = ref(1)
const commentsRef = ref<HTMLElement | null>(null)
const newCommentEditor = ref<{setReplyContent: (html: string) => Promise<void>} | null>(null)
provide(commentReplyContextKey, {
findComment: (id: number) => comments.value.find(c => c.id === id),
scrollToComment: scrollAndHighlightComment,
})
// Strip <mention-user> elements from a reply quote so reposting the parent
// body doesn't trigger fresh notifications for users mentioned in the
// original. The inner text is kept so the quote still reads correctly.
function stripMentionsForQuote(html: string): string {
if (!html) {
return ''
}
const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html')
doc.querySelectorAll('mention-user').forEach((el) => {
const label = (el.getAttribute('data-label') ?? el.textContent ?? '').trim()
el.replaceWith(label ? `@${label.replace(/^@+/, '')}` : '')
})
return doc.body.firstElementChild?.innerHTML ?? ''
}
async function startReplyTo(parent: ITaskComment) {
const body = stripMentionsForQuote(parent.comment ?? '')
const draft = `<blockquote data-comment-id="${parent.id}">${body}</blockquote><p></p>`
if (!editorActive.value) {
editorActive.value = true
}
// Editor mounts asynchronously through defineAsyncComponent; wait until
// the ref is populated before pushing content in. Bail with a warning
// rather than fall back to `newCommentText = draft` the modelValue
// watcher in TipTap.vue would land the editor in preview mode, leaving
// the user unable to type without clicking the editor first.
const editor = await waitForEditorRef()
if (!editor) {
console.warn('Reply editor did not mount in time; aborting reply prefill.')
return
}
await editor.setReplyContent(draft)
}
async function waitForEditorRef() {
const start = performance.now()
while (!newCommentEditor.value && performance.now() - start < 2000) {
await nextTick()
}
return newCommentEditor.value
}
async function attachmentUpload(files: File[] | FileList): (Promise<string[]>) {
@ -519,9 +574,8 @@ function getCommentUrl(commentId: string) {
text-align: inherit;
& + .media {
border-block-start: 1px solid rgba(var(--border-rgb), 0.5);
margin-block-start: 1rem;
padding-block-start: 1rem;
margin-block-start: .5rem;
padding-block-start: .5rem;
}
}
@ -529,7 +583,7 @@ function getCommentUrl(commentId: string) {
flex-basis: auto;
flex-grow: 0;
flex-shrink: 0;
margin: 0 1rem !important;
margin: 0 .5rem !important;
}
.comment-info {
@ -605,4 +659,15 @@ function getCommentUrl(commentId: string) {
.comments-container {
scroll-margin-block-start: 4rem;
}
.media.comment {
scroll-margin-block-start: 4rem;
transition: background-color .3s ease-out;
border-radius: $radius;
}
.media.comment.comment-highlight {
background-color: hsla(var(--primary-hsl), 0.18);
transition: background-color .15s ease-in;
}
</style>

View File

@ -0,0 +1,26 @@
import type {InjectionKey} from 'vue'
import type {ITaskComment} from '@/modelTypes/ITaskComment'
export interface CommentReplyContext {
findComment: (id: number) => ITaskComment | undefined
scrollToComment: (id: number) => void
}
export const commentReplyContextKey: InjectionKey<CommentReplyContext> = Symbol('commentReplyContext')
const HIGHLIGHT_CLASS = 'comment-highlight'
const HIGHLIGHT_DURATION_MS = 1500
export function scrollAndHighlightComment(id: number): void {
const el = document.getElementById(`comment-${id}`)
if (!el) {
return
}
el.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'nearest'})
el.classList.remove(HIGHLIGHT_CLASS)
// Re-apply on next frame so the animation restarts even if already running.
requestAnimationFrame(() => {
el.classList.add(HIGHLIGHT_CLASS)
window.setTimeout(() => el.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_DURATION_MS)
})
}

View File

@ -1066,7 +1066,10 @@
"addedSuccess": "The comment was added successfully.",
"permalink": "Copy permalink to this comment",
"sortNewestFirst": "Newest first",
"sortOldestFirst": "Oldest first"
"sortOldestFirst": "Oldest first",
"reply": "Reply",
"jumpToOriginal": "Jump to original comment",
"deletedComment": "deleted comment"
},
"mention": {
"noUsersFound": "No users found"