From 0e4994887725c2b15ce49b50ccc05029a84a0cba Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Mon, 14 Sep 2026 23:46:51 +0800 Subject: [PATCH 1/5] feat: add comment and reply permalinks --- README.md | 1 + dev/component-usage.md | 26 ++ .../comment-widget/src/base-comment-item.ts | 12 +- packages/comment-widget/src/comment-detail.ts | 130 +++++++++ packages/comment-widget/src/comment-item.ts | 19 +- packages/comment-widget/src/comment-link.ts | 125 +++++++++ .../comment-widget/src/comment-replies.ts | 38 ++- packages/comment-widget/src/comment-widget.ts | 43 ++- .../src/generated/locales/es.ts | 8 + .../src/generated/locales/zh-CN.ts | 8 + .../src/generated/locales/zh-TW.ts | 8 + packages/comment-widget/src/reply-item.ts | 2 + .../comment-widget/src/utils/comment-link.ts | 22 ++ .../tests/accessibility.browser.test.js | 7 +- .../tests/comment-permalink.browser.test.js | 264 ++++++++++++++++++ packages/comment-widget/xliff/es.xlf | 32 +++ packages/comment-widget/xliff/zh-CN.xlf | 32 +++ packages/comment-widget/xliff/zh-TW.xlf | 32 +++ .../comment/widget/ReplyDetailEndpoint.java | 160 +++++++++++ .../resources/extensions/role-templates.yaml | 3 + .../widget/ReplyDetailEndpointTest.java | 167 +++++++++++ 21 files changed, 1125 insertions(+), 14 deletions(-) create mode 100644 packages/comment-widget/src/comment-detail.ts create mode 100644 packages/comment-widget/src/comment-link.ts create mode 100644 packages/comment-widget/src/utils/comment-link.ts create mode 100644 packages/comment-widget/tests/comment-permalink.browser.test.js create mode 100644 src/main/java/run/halo/comment/widget/ReplyDetailEndpoint.java create mode 100644 src/test/java/run/halo/comment/widget/ReplyDetailEndpointTest.java diff --git a/README.md b/README.md index 1e9b865..e9c7115 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Halo 2.0 的通用评论组件插件,为前台提供完整的评论解决方 ## 功能特性 - 支持评论和回复,可分别配置分页条数 +- 支持评论和回复固定链接,点击发布时间可复制链接,访问链接直接查看对应讨论 - 支持私密评论,限制评论内容的可见范围 - 支持表情选择,可自定义评论框占位符 - 支持上传 JPEG、PNG、GIF、WebP、AVIF 图片,可配置文件大小限制、存储策略和匿名上传权限 diff --git a/dev/component-usage.md b/dev/component-usage.md index 21f26b2..f02a502 100644 --- a/dev/component-usage.md +++ b/dev/component-usage.md @@ -69,3 +69,29 @@ function App() { export default App; ``` + +## 评论固定链接 + +点击评论或回复的发布时间,可以查看完整时间并复制固定链接。链接基于当前前台页面地址生成,和 `baseUrl` 指向的 API 地址无关: + +```text +/archives/example#halo-comment= +/archives/example#halo-comment=&reply= +``` + +组件初始化时识别上述 hash,直接进入详情模式,不请求主评论列表: + +- 评论链接展示根评论并分页加载回复。 +- 回复链接展示根评论和指定回复;点击“查看全部回复”后才加载回复列表。 +- 点击“返回评论列表”清除定位参数并加载正常列表,浏览器后退可返回详情。 +- 根评论必须属于当前组件的 `group`、`kind`、`name`;不存在或不可见的内容不会展示。 + +主题保持原来的挂载方式即可。相同页面内修改 hash 会更新详情;Headless 应用若通过 `history.pushState` 切换 URL,需要由应用通知组件(派发 `hashchange`)或重新挂载。使用 hash 路由的应用需自行协调路由片段,不能直接覆盖其路由 hash。内容页地址变更后的旧链接跳转由站点维护。 + +指定回复通过插件公开接口查询: + +```text +GET /apis/api.commentwidget.halo.run/v1alpha1/comments/{commentName}/replies/{replyName} +``` + +接口校验根评论、回复归属和当前访问者的可见性,并返回脱敏展示数据;根评论详情及回复列表继续使用 Halo Core 接口。 diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts index 81850de..ab26113 100644 --- a/packages/comment-widget/src/base-comment-item.ts +++ b/packages/comment-widget/src/base-comment-item.ts @@ -3,7 +3,7 @@ import { msg } from '@lit/localize'; import { css, html, LitElement } from 'lit'; import { property, state } from 'lit/decorators.js'; import baseStyles from './styles/base'; -import { formatDate, timeAgo } from './utils/date'; +import './comment-link'; import './commenter-ua-bar'; import { consume } from '@lit/context'; import { canManageCommentsContext, configMapDataContext } from './context'; @@ -25,6 +25,12 @@ export class BaseCommentItem extends LitElement { @property({ type: String }) creationTime: string | undefined; + @property() + commentName = ''; + + @property() + replyName = ''; + @property({ type: Boolean }) approved: boolean | undefined; @@ -99,9 +105,7 @@ export class BaseCommentItem extends LitElement { ${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html``)} - + ${when(!this.approved, () => html`
${msg('Reviewing')}
`)} diff --git a/packages/comment-widget/src/comment-detail.ts b/packages/comment-widget/src/comment-detail.ts new file mode 100644 index 0000000..f0a0018 --- /dev/null +++ b/packages/comment-widget/src/comment-detail.ts @@ -0,0 +1,130 @@ +import type { CommentVo, ReplyVo } from '@halo-dev/api-client'; +import { consume } from '@lit/context'; +import { msg } from '@lit/localize'; +import { css, html, LitElement } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { ofetch } from 'ofetch'; +import { + baseUrlContext, + groupContext, + kindContext, + nameContext, +} from './context'; +import baseStyles from './styles/base'; +import type { CommentTarget } from './utils/comment-link'; +import './comment-item'; +import './loading-block'; + +export class CommentDetail extends LitElement { + @consume({ context: baseUrlContext }) @state() baseUrl = ''; + @consume({ context: groupContext }) @state() group = ''; + @consume({ context: kindContext }) @state() kind = ''; + @consume({ context: nameContext }) @state() name = ''; + @property({ attribute: false }) target!: CommentTarget; + @state() private comment?: CommentVo; + @state() private reply?: ReplyVo; + @state() private loading = true; + @state() private error = ''; + private requestId = 0; + private activeReplyItem?: { closeReplyForm(): void }; + + override connectedCallback() { + super.connectedCallback(); + void this.load(); + } + + override disconnectedCallback() { + ++this.requestId; + this.activeReplyItem?.closeReplyForm(); + super.disconnectedCallback(); + } + + private async load() { + const requestId = ++this.requestId; + this.loading = true; + this.error = ''; + try { + const commentName = encodeURIComponent(this.target.commentName); + const comment = await ofetch( + `${this.baseUrl}/apis/api.halo.run/v1alpha1/comments/${commentName}`, + { retry: 0 } + ); + if (requestId !== this.requestId) return; + const subject = comment.spec.subjectRef; + if ( + subject.group !== this.group || + subject.kind !== this.kind || + subject.name !== this.name + ) { + this.error = msg('Comment not found or unavailable'); + return; + } + const reply = this.target.replyName + ? await ofetch( + `${this.baseUrl}/apis/api.commentwidget.halo.run/v1alpha1/comments/${commentName}/replies/${encodeURIComponent(this.target.replyName)}`, + { retry: 0 } + ) + : undefined; + if (requestId !== this.requestId) return; + this.comment = comment; + this.reply = reply; + } catch (error) { + if (requestId !== this.requestId) return; + this.error = + (error as { status?: number }).status === 404 + ? msg('Comment not found or unavailable') + : msg('Failed to load comment, please try again'); + } finally { + if (requestId === this.requestId) { + this.loading = false; + await this.updateComplete; + if ( + requestId === this.requestId && + this.isConnected && + this.comment && + !this.target.replyName && + !this.error + ) { + this.scrollIntoView({ block: 'start' }); + } + } + } + } + + private returnToList() { + this.dispatchEvent( + new CustomEvent('comment-list-requested', { + bubbles: true, + composed: true, + }) + ); + } + + override render() { + return html`
this.load()} @reply-form-open=${( + event: CustomEvent<{ closeReplyForm(): void }> + ) => { + event.stopPropagation(); + if (this.activeReplyItem !== event.detail) + this.activeReplyItem?.closeReplyForm(); + this.activeReplyItem = event.detail; + }}> + + ${this.loading ? html`` : this.error ? html`

${this.error}

` : keyed(this.requestId, html``)} +
`; + } + + static override styles = [ + ...baseStyles, + css` + :host { display: block; scroll-margin-top: 5rem; } + .back { cursor: pointer; text-decoration: underline; text-underline-offset: 3px; } + .back:focus-visible { outline: 2px solid var(--halo-cw-primary-1-color); outline-offset: 2px; } + @unocss-placeholder; + `, + ]; +} + +customElements.get('comment-detail') || + customElements.define('comment-detail', CommentDetail); diff --git a/packages/comment-widget/src/comment-item.ts b/packages/comment-widget/src/comment-item.ts index 58476d3..7499052 100644 --- a/packages/comment-widget/src/comment-item.ts +++ b/packages/comment-widget/src/comment-item.ts @@ -1,4 +1,4 @@ -import type { CommentVo } from '@halo-dev/api-client'; +import type { CommentVo, ReplyVo } from '@halo-dev/api-client'; import { css, html, LitElement } from 'lit'; import { property, state } from 'lit/decorators.js'; import type { BaseForm } from './base-form'; @@ -26,6 +26,12 @@ export class CommentItem extends LitElement { @property({ type: Object }) comment: CommentVo | undefined; + @property({ type: Boolean }) + detail = false; + + @property({ attribute: false }) + targetReply: ReplyVo | undefined; + @consume({ context: configMapDataContext }) @state() configMapData: ConfigMapData | undefined; @@ -48,7 +54,7 @@ export class CommentItem extends LitElement { super.connectedCallback(); this.checkUpvotedStatus(); - if (this.configMapData?.basic.withReplies) { + if (this.detail || this.configMapData?.basic.withReplies) { this.showReplies = true; this.showReplyForm = false; } @@ -113,7 +119,7 @@ export class CommentItem extends LitElement { } handleShowReplies() { - if (!this.configMapData?.basic.withReplies) { + if (!this.detail && !this.configMapData?.basic.withReplies) { this.handleToggleReplyForm(); this.showReplies = this.showReplyForm; return; @@ -129,7 +135,7 @@ export class CommentItem extends LitElement { this.closeReplyForm(); this.renderRoot .querySelector( - this.configMapData?.basic.withReplies + this.detail || this.configMapData?.basic.withReplies ? '.reply-button' : '.show-replies-button' ) @@ -164,6 +170,7 @@ export class CommentItem extends LitElement { .userDisplayName="${this.comment?.owner.displayName}" .content="${this.comment?.spec.content || ''}" .creationTime="${this.comment?.spec.creationTime}" + .commentName=${this.comment?.metadata.name} .approved=${this.comment?.spec.approved} .pinned=${this.comment?.spec.top} .userWebsite=${this.comment?.spec.owner.annotations?.website} @@ -197,7 +204,7 @@ export class CommentItem extends LitElement { } ${when( - this.configMapData?.basic.withReplies, + this.detail || this.configMapData?.basic.withReplies, () => html` + ${ + this.open + ? html`` + : '' + } + `; + } + + static override styles = [ + ...baseStyles, + css` + :host { display: inline-flex; font-size: 0.75rem; color: var(--halo-cw-text-3-color, #475569); } + .trigger { cursor: pointer; text-underline-offset: 3px; } + .trigger:hover { text-decoration: underline; } + .panel { position: fixed; z-index: 100; padding: 1em; width: min(32rem, calc(100vw - 24px)); box-sizing: border-box; font-size: 0.875rem; } + .date { margin-bottom: 0.75em; } + .link-row { display: flex; gap: 0.75em; align-items: center; } + input { min-width: 0; flex: 1; border: 1px solid var(--halo-cw-muted-1-color, #cbd5e1); border-radius: var(--halo-cw-base-rounded, 0.5em); padding: 0.5em; background: var(--halo-cw-muted-3-color, #f1f5f9); } + .copy { flex: none; cursor: pointer; } + [role=status]:not(:empty) { display: block; margin-top: 0.5em; } + button:focus-visible, input:focus-visible { outline: 2px solid var(--halo-cw-primary-1-color, #4ccba0); outline-offset: 2px; } + @unocss-placeholder; + `, + ]; +} + +customElements.get('comment-link') || + customElements.define('comment-link', CommentLink); diff --git a/packages/comment-widget/src/comment-replies.ts b/packages/comment-widget/src/comment-replies.ts index e80050b..c067af2 100644 --- a/packages/comment-widget/src/comment-replies.ts +++ b/packages/comment-widget/src/comment-replies.ts @@ -27,6 +27,15 @@ export class CommentReplies extends LitElement { @property({ type: Object }) comment: CommentVo | undefined; + @property({ attribute: false }) + targetReply: ReplyVo | undefined; + + @property({ type: Boolean }) + managedByParent = false; + + @state() + private targetOnly = false; + @property({ type: Boolean }) showReplyForm = false; @@ -56,7 +65,9 @@ export class CommentReplies extends LitElement { toastManager: ToastManager | undefined; override render() { - return html`
+ return html`
{ + if (!this.managedByParent) this.refreshReplies(); + }}> ${when( this.replies.length, () => html`
@@ -65,6 +76,7 @@ export class CommentReplies extends LitElement { (item) => item.metadata.name, (item) => html`` )} + ${when(this.targetOnly, () => html``)} ${when(this.loading, () => html` `)} ${when( this.hasNext, @@ -89,7 +102,12 @@ export class CommentReplies extends LitElement { this.activeQuoteReply = event.detail.quoteReply; } + private showAllReplies() { + return this.fetchReplies(); + } + refreshReplies() { + if (this.targetOnly) return this.showAllReplies(); const size = this.configMapData?.basic.replySize ?? 10; return this.fetchReplies({ size: Math.max(1, Math.ceil(this.replies.length / size)) * size, @@ -123,7 +141,7 @@ export class CommentReplies extends LitElement { if (requestId !== this.requestId) return; const restoreFocus = - !data.hasNext && + (this.targetOnly || !data.hasNext) && this.renderRoot .querySelector('.replies-next-button') ?.matches(':focus'); @@ -134,6 +152,7 @@ export class CommentReplies extends LitElement { this.replies = data.items; } + this.targetOnly = false; this.hasNext = data.hasNext; this.page = data.page; this.currentPageSize = data.size; @@ -191,6 +210,18 @@ export class CommentReplies extends LitElement { override connectedCallback(): void { super.connectedCallback(); + if (this.targetReply) { + this.targetOnly = true; + this.replies = [this.targetReply]; + void this.updateComplete.then(() => { + if (this.isConnected && this.targetOnly) { + this.renderRoot + .querySelector('reply-item') + ?.scrollIntoView({ block: 'center' }); + } + }); + return; + } if (this.configMapData?.basic.withReplies) { const comment = this.comment as | (CommentVo & { replies?: ReplyVoList }) @@ -217,6 +248,9 @@ export class CommentReplies extends LitElement { static override styles = [ ...baseStyles, css` + .target-reply { display: block; scroll-margin-top: 5rem; animation: highlight 2s ease-out; } + @keyframes highlight { from { background: var(--halo-cw-muted-2-color); } to { background: transparent; } } + @media (prefers-reduced-motion: reduce) { .target-reply { animation: none; } } @unocss-placeholder; `, ]; diff --git a/packages/comment-widget/src/comment-widget.ts b/packages/comment-widget/src/comment-widget.ts index ebd15f8..382a9a3 100644 --- a/packages/comment-widget/src/comment-widget.ts +++ b/packages/comment-widget/src/comment-widget.ts @@ -30,7 +30,9 @@ import { ToastManager } from './lit-toast'; import baseStyles from './styles/base'; import type { ConfigMapData } from './types'; import './comment-list'; +import './comment-detail'; import { ofetch } from 'ofetch'; +import { readCommentTarget } from './utils/comment-link'; import './comment-editor-skeleton'; import { fetchManagementPermission } from './utils/comment-management'; @@ -78,6 +80,34 @@ export class CommentWidget extends LitElement { @state() isInitialized = false; + @state() + private commentTarget = readCommentTarget(); + + private onLocationChange = () => { + this.commentTarget = readCommentTarget(); + }; + + private onCommentCreated = () => { + if (this.commentTarget) this.returnToList(); + }; + + private returnToList() { + const url = new URL(location.href); + const params = new URLSearchParams(url.hash.slice(1)); + params.delete('halo-comment'); + params.delete('reply'); + url.hash = params.toString(); + history.pushState(history.state, '', url); + window.dispatchEvent(new Event('hashchange')); + } + + override disconnectedCallback() { + window.removeEventListener('halo:comment:created', this.onCommentCreated); + window.removeEventListener('hashchange', this.onLocationChange); + window.removeEventListener('popstate', this.onLocationChange); + super.disconnectedCallback(); + } + override render() { return html`
${ @@ -87,7 +117,14 @@ export class CommentWidget extends LitElement { JSON.stringify([this.group, this.kind, this.version, this.name]), html` - + ${ + this.commentTarget + ? keyed( + JSON.stringify(this.commentTarget), + html`` + ) + : html`` + } ` ) } @@ -149,6 +186,10 @@ export class CommentWidget extends LitElement { override connectedCallback(): void { super.connectedCallback(); + this.onLocationChange(); + window.addEventListener('halo:comment:created', this.onCommentCreated); + window.addEventListener('hashchange', this.onLocationChange); + window.addEventListener('popstate', this.onLocationChange); this.init(); } diff --git a/packages/comment-widget/src/generated/locales/es.ts b/packages/comment-widget/src/generated/locales/es.ts index 48541ba..a4df68e 100644 --- a/packages/comment-widget/src/generated/locales/es.ts +++ b/packages/comment-widget/src/generated/locales/es.ts @@ -20,9 +20,11 @@ 's0fbf6dc6a1966408': `Siguiente`, 's107ccef507b51f2c': `Completa la verificación`, 's15e33945d13c5176': `Se está confirmando tu envío anterior. Inténtalo más tarde; se conservarán tus imágenes.`, +'s19172ed3838854fc': `Copiar enlace`, 's1c6fefb092506753': `Error al cargar la lista de comentarios, por favor intente más tarde`, 's1d468f888124a55e': `Tu borrador ha cambiado. Vuelve a abrirlo antes de reintentar.`, 's1e3e30a26025484c': `Error al cargar la lista de respuestas, por favor intente más tarde`, +'s1f224cf8b88cb893': `Enlace copiado`, 's2406b89e991a4524': `Actualizar código de verificación`, 's26e4d65f2801ac9c': `Por favor, ingrese el contenido`, 's299b10f3a58a09fd': `Haga clic en OK para ir a la página de cierre de sesión, Por favor, asegúrese de que el contenido editado haya sido guardado.`, @@ -49,10 +51,12 @@ 's7437373e541a8037': `Apodo`, 's75206bde78be40fc': `No se pudo guardar el borrador con imágenes. Mantén esta página abierta.`, 's7584ded3d749c75e': `Cargar más`, +'s78f95c3c2b1b0512': `Enlace al comentario`, 's82665b2ffabc9c0a': `Sitio web`, 's838e512973be01d4': `Actualmente conectado. Después de seleccionar la opción privada, los comentarios solo serán visibles para usted y el administrador del sitio.`, 's84b033b2f7360187': `Por favor, inicie sesión o complete la información primero`, 's851926ed399df4e4': `El envío anterior se canceló. Vuelve a enviar para publicar tus cambios.`, +'s879ba21b93adbbbd': `No se pudo cargar el comentario. Inténtalo de nuevo`, 's8db89619f41917b4': `Fijar`, 's8e176d64f6528d3d': str`El tamaño de la imagen no debe superar ${0} MiB.`, 's96668830629e0dfc': `Subir`, @@ -65,20 +69,24 @@ 'sa8dddacbaa66f8e0': `Por favor, inicie sesión primero`, 'sb206d700d26b14ff': `Subrayado`, 'sb3d4f79d9d8b71e5': `Enviar comentario`, +'sb490fb4653620603': `Volver a los comentarios`, 'sb5a28aa35006ff08': `Comentario enviado con éxito`, 'sbc0cd0b4dfb7dde1': `Desfijar`, 'sc0ffeb5d53b60209': `Error al subir`, +'sc29d248fb3e5c59b': `Ver todas las respuestas`, 'sc59a34be5493a0af': `Error al subir. Inténtalo de nuevo.`, 'sc8da3cc71de63832': `Iniciar sesión`, 'sd1f44f1a8bc20e67': `Correo electrónico`, 'sd5326f2cc13eb2b8': `Tu comentario anterior se envió. Guarda los cambios antes de continuar.`, 'sd5e242ab9574958a': `Error al comentar, por favor intente más tarde`, +'sd68dcf08044cb636': `El comentario no existe o no está disponible`, 'sdc673e73b5c13aea': `Eliminar`, 'se2a73d664d3d2bfb': `Ampliar imagen`, 'se5d01bef737e2e3a': `Aprobar`, 'se7bee6e9a9b5394c': `Íntimo`, 'se8662271b21bc06c': `¿Eliminar este comentario y sus respuestas? Esta acción no se puede deshacer.`, 'sea7e567ed89dc0d7': `Seleccionar emoticono`, +'sef8291267fe91bbb': `Selecciona y copia el enlace manualmente`, 'sf3ff78cc329d3528': `Anterior`, 'sf77128b082955d42': `(O iniciar sesión)`, 'sf7c0eba7c822e3d6': `Negrita`, diff --git a/packages/comment-widget/src/generated/locales/zh-CN.ts b/packages/comment-widget/src/generated/locales/zh-CN.ts index 0024d5b..242aa24 100644 --- a/packages/comment-widget/src/generated/locales/zh-CN.ts +++ b/packages/comment-widget/src/generated/locales/zh-CN.ts @@ -20,9 +20,11 @@ 's0fbf6dc6a1966408': `下一页`, 's107ccef507b51f2c': `请完成人机验证`, 's15e33945d13c5176': `上次提交结果正在确认,请稍后重试;图片会被保留。`, +'s19172ed3838854fc': `复制链接`, 's1c6fefb092506753': `加载评论列表失败,请稍后重试`, 's1d468f888124a55e': `草稿已更改,请重新打开后再重试。`, 's1e3e30a26025484c': `加载回复列表失败,请稍后重试`, +'s1f224cf8b88cb893': `链接已复制`, 's2406b89e991a4524': `刷新验证码`, 's26e4d65f2801ac9c': `请输入内容`, 's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`, @@ -49,10 +51,12 @@ 's7437373e541a8037': `昵称`, 's75206bde78be40fc': `无法保存图片草稿,请保持此页面打开。`, 's7584ded3d749c75e': `加载更多`, +'s78f95c3c2b1b0512': `评论链接`, 's82665b2ffabc9c0a': `网站`, 's838e512973be01d4': `当前已登录,选择私密选项后,评论将仅对您和网站管理员可见。`, 's84b033b2f7360187': `请先登录或者完善信息`, 's851926ed399df4e4': `上次提交已取消,请再次提交以发送修改后的内容。`, +'s879ba21b93adbbbd': `评论加载失败,请重试`, 's8db89619f41917b4': `置顶`, 's8e176d64f6528d3d': str`单张图片大小不能超过 ${0} MiB。`, 's96668830629e0dfc': `上传`, @@ -65,20 +69,24 @@ 'sa8dddacbaa66f8e0': `请先登录`, 'sb206d700d26b14ff': `下划线`, 'sb3d4f79d9d8b71e5': `提交评论`, +'sb490fb4653620603': `返回评论列表`, 'sb5a28aa35006ff08': `评论成功`, 'sbc0cd0b4dfb7dde1': `取消置顶`, 'sc0ffeb5d53b60209': `上传失败`, +'sc29d248fb3e5c59b': `查看全部回复`, 'sc59a34be5493a0af': `上传失败,请重试。`, 'sc8da3cc71de63832': `登录`, 'sd1f44f1a8bc20e67': `电子邮件`, 'sd5326f2cc13eb2b8': `上次评论已提交成功,请先保留修改内容再继续。`, 'sd5e242ab9574958a': `评论失败,请稍后重试`, +'sd68dcf08044cb636': `评论不存在或暂不可查看`, 'sdc673e73b5c13aea': `删除`, 'se2a73d664d3d2bfb': `放大图片`, 'se5d01bef737e2e3a': `审核通过`, 'se7bee6e9a9b5394c': `私密`, 'se8662271b21bc06c': `确定删除此评论及其回复?此操作无法撤销。`, 'sea7e567ed89dc0d7': `选择表情`, +'sef8291267fe91bbb': `请选中并手动复制链接`, 'sf3ff78cc329d3528': `上一页`, 'sf77128b082955d42': `(或登录账号)`, 'sf7c0eba7c822e3d6': `粗体`, diff --git a/packages/comment-widget/src/generated/locales/zh-TW.ts b/packages/comment-widget/src/generated/locales/zh-TW.ts index cc6c064..2c23741 100644 --- a/packages/comment-widget/src/generated/locales/zh-TW.ts +++ b/packages/comment-widget/src/generated/locales/zh-TW.ts @@ -20,9 +20,11 @@ 's0fbf6dc6a1966408': `下一頁`, 's107ccef507b51f2c': `請完成人機驗證`, 's15e33945d13c5176': `上次提交結果正在確認,請稍後重試;圖片會被保留。`, +'s19172ed3838854fc': `複製連結`, 's1c6fefb092506753': `載入評論列表失敗,請稍後重試`, 's1d468f888124a55e': `草稿已變更,請重新開啟後再重試。`, 's1e3e30a26025484c': `載入回覆列表失敗,請稍後重試`, +'s1f224cf8b88cb893': `連結已複製`, 's2406b89e991a4524': `重新整理驗證碼`, 's26e4d65f2801ac9c': `請輸入內容`, 's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`, @@ -49,10 +51,12 @@ 's7437373e541a8037': `暱稱`, 's75206bde78be40fc': `無法儲存圖片草稿,請保持此頁面開啟。`, 's7584ded3d749c75e': `載入更多`, +'s78f95c3c2b1b0512': `留言連結`, 's82665b2ffabc9c0a': `網站`, 's838e512973be01d4': `目前已登入,選擇私密選項後,評論將僅對您和網站管理員可見。`, 's84b033b2f7360187': `請先登入或者完善資訊`, 's851926ed399df4e4': `上次提交已取消,請再次提交以送出修改後的內容。`, +'s879ba21b93adbbbd': `留言載入失敗,請重試`, 's8db89619f41917b4': `置頂`, 's8e176d64f6528d3d': str`單張圖片大小不能超過 ${0} MiB。`, 's96668830629e0dfc': `上傳`, @@ -65,20 +69,24 @@ 'sa8dddacbaa66f8e0': `請先登入`, 'sb206d700d26b14ff': `底線`, 'sb3d4f79d9d8b71e5': `提交評論`, +'sb490fb4653620603': `返回留言列表`, 'sb5a28aa35006ff08': `評論成功`, 'sbc0cd0b4dfb7dde1': `取消置頂`, 'sc0ffeb5d53b60209': `上傳失敗`, +'sc29d248fb3e5c59b': `查看所有回覆`, 'sc59a34be5493a0af': `上傳失敗,請重試。`, 'sc8da3cc71de63832': `登入`, 'sd1f44f1a8bc20e67': `電子郵件`, 'sd5326f2cc13eb2b8': `上次評論已提交成功,請先保留修改內容再繼續。`, 'sd5e242ab9574958a': `評論失敗,請稍後重試`, +'sd68dcf08044cb636': `留言不存在或暫時無法查看`, 'sdc673e73b5c13aea': `刪除`, 'se2a73d664d3d2bfb': `放大圖片`, 'se5d01bef737e2e3a': `審核通過`, 'se7bee6e9a9b5394c': `私密`, 'se8662271b21bc06c': `確定刪除此評論及其回覆?此操作無法復原。`, 'sea7e567ed89dc0d7': `選擇表情`, +'sef8291267fe91bbb': `請選取並手動複製連結`, 'sf3ff78cc329d3528': `上一頁`, 'sf77128b082955d42': `(或登入帳號)`, 'sf7c0eba7c822e3d6': `粗體`, diff --git a/packages/comment-widget/src/reply-item.ts b/packages/comment-widget/src/reply-item.ts index 06f4a53..50d2331 100644 --- a/packages/comment-widget/src/reply-item.ts +++ b/packages/comment-widget/src/reply-item.ts @@ -165,6 +165,8 @@ export class ReplyItem extends LitElement { .userDisplayName="${this.reply?.owner.displayName}" .content="${this.reply?.spec.content || ''}" .creationTime="${this.reply?.spec.creationTime}" + .commentName=${this.comment?.metadata.name} + .replyName=${this.reply?.metadata.name} .approved=${this.reply?.spec.approved} .breath=${this.isQuoteReplyHovered} .userWebsite=${this.reply?.spec.owner.annotations?.website} diff --git a/packages/comment-widget/src/utils/comment-link.ts b/packages/comment-widget/src/utils/comment-link.ts new file mode 100644 index 0000000..e243a0e --- /dev/null +++ b/packages/comment-widget/src/utils/comment-link.ts @@ -0,0 +1,22 @@ +export interface CommentTarget { + commentName: string; + replyName?: string; +} + +export function readCommentTarget( + url = new URL(location.href) +): CommentTarget | undefined { + const params = new URLSearchParams(url.hash.slice(1)); + const commentName = params.get('halo-comment'); + return commentName + ? { commentName, replyName: params.get('reply') || undefined } + : undefined; +} + +export function commentLink(commentName: string, replyName?: string): string { + const url = new URL(location.href); + const params = new URLSearchParams({ 'halo-comment': commentName }); + if (replyName) params.set('reply', replyName); + url.hash = params.toString(); + return url.href; +} diff --git a/packages/comment-widget/tests/accessibility.browser.test.js b/packages/comment-widget/tests/accessibility.browser.test.js index 295f1ed..54fd695 100644 --- a/packages/comment-widget/tests/accessibility.browser.test.js +++ b/packages/comment-widget/tests/accessibility.browser.test.js @@ -287,8 +287,10 @@ test('Comment widget accessibility checks', async () => { .contentVisibility === 'auto', 'Offscreen comment content should skip rendering' ); + const timeLink = base.shadowRoot.querySelector('comment-link'); + await timeLink.updateComplete; assert( - base.shadowRoot.querySelector('time').title === + timeLink.shadowRoot.querySelector('time').title === new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', @@ -297,8 +299,9 @@ test('Comment widget accessibility checks', async () => { ); base.creationTime = 'invalid'; await base.updateComplete; + await timeLink.updateComplete; assert( - base.shadowRoot.querySelector('time').title === '', + timeLink.shadowRoot.querySelector('time').title === '', 'Invalid dates must not crash rendering' ); base.creationTime = comment.spec.creationTime; diff --git a/packages/comment-widget/tests/comment-permalink.browser.test.js b/packages/comment-widget/tests/comment-permalink.browser.test.js new file mode 100644 index 0000000..a61ce86 --- /dev/null +++ b/packages/comment-widget/tests/comment-permalink.browser.test.js @@ -0,0 +1,264 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { mockApi, until } from './browser-helpers.js'; + +const originalUrl = location.href; +afterEach(() => history.replaceState(null, '', originalUrl)); +const root = '/apis/api.halo.run/v1alpha1/comments'; +const replyPath = + '/apis/api.commentwidget.halo.run/v1alpha1/comments/c1/replies/r99'; +const comment = { + metadata: { name: 'c1' }, + spec: { + subjectRef: { group: 'content.halo.run', kind: 'Post', name: 'post' }, + content: '

Parent comment

', + creationTime: '2026-09-11T08:12:00Z', + approved: true, + hidden: false, + owner: { kind: 'User', name: '', annotations: {} }, + }, + owner: { displayName: 'Reader' }, + stats: { upvote: 1 }, + status: { visibleReplyCount: 99 }, +}; +const reply = { + metadata: { name: 'r99' }, + spec: { ...comment.spec, content: '

Target reply

', commentName: 'c1' }, + owner: { displayName: 'Author' }, + stats: { upvote: 2 }, +}; +const list = (items) => ({ + items, + page: 1, + size: 20, + total: items.length, + totalPages: 1, + hasNext: false, +}); + +function api(overrides = {}) { + const requests = []; + mockApi(async (input) => { + const url = new URL(String(input), location.href); + requests.push(url.pathname); + if (overrides[url.pathname]) return overrides[url.pathname](); + let data = {}; + if (url.pathname.endsWith('/globalinfo')) + data = { allowAnonymousComments: true }; + else if (url.pathname.endsWith('/config')) + data = { + basic: {}, + avatar: { enable: false }, + editor: { enableEmoji: false }, + }; + else if (url.pathname.endsWith('/users/-')) + data = { user: { metadata: { name: 'anonymousUser' }, spec: {} } }; + else if (url.pathname === `${root}/c1`) data = comment; + else if (url.pathname === replyPath) data = reply; + else if (url.pathname === `${root}/c1/reply`) data = list([reply]); + else if (url.pathname === root) data = list([comment]); + return Response.json(data); + }); + return requests; +} + +async function mount(hash) { + history.replaceState( + null, + '', + `${location.pathname}${location.search}${hash}` + ); + await import('../src/index.ts'); + const widget = document.createElement('comment-widget'); + widget.group = 'content.halo.run'; + widget.kind = 'Post'; + widget.name = 'post'; + document.body.append(widget); + await until(() => widget.isInitialized); + await widget.updateComplete; + return widget; +} +const detailOf = (widget) => widget.shadowRoot.querySelector('comment-detail'); +const itemOf = (widget) => + detailOf(widget)?.shadowRoot.querySelector('comment-item'); +const repliesOf = (widget) => + itemOf(widget)?.shadowRoot.querySelector('comment-replies'); + +test('comment detail skips the list, loads replies, returns to the list and follows history', async () => { + const requests = api(); + const widget = await mount('#halo-comment=c1'); + await until(() => repliesOf(widget)?.replies.length === 1); + expect(requests).not.toContain(root); + expect(itemOf(widget).showReplies).toBe(true); + detailOf(widget).shadowRoot.querySelector('.back').click(); + await until( + () => + widget.shadowRoot.querySelector('comment-list')?.comments.items.length === + 1 + ); + expect(location.hash).toBe(''); + expect(requests.filter((path) => path === root)).toHaveLength(1); + history.back(); + await until(() => repliesOf(widget)?.replies.length === 1); + expect(location.hash).toBe('#halo-comment=c1'); + expect(requests.filter((path) => path === root)).toHaveLength(1); +}); + +test('reply detail loads only the target until all replies are requested', async () => { + const requests = api(); + const widget = await mount('#halo-comment=c1&reply=r99'); + await until(() => repliesOf(widget)?.replies.length === 1); + const replies = repliesOf(widget); + await replies.updateComplete; + expect(requests).toContain(replyPath); + expect(requests).not.toContain(root); + expect(requests).not.toContain(`${root}/c1/reply`); + expect(replies.replies[0].metadata.name).toBe('r99'); + expect(replies.shadowRoot.querySelector('.target-reply')).not.toBeNull(); + replies.shadowRoot.querySelector('button').click(); + await until(() => requests.includes(`${root}/c1/reply`)); + expect(requests).not.toContain(root); +}); + +test.each(['missing', 'different-subject', 'missing-reply'])( + 'unavailable detail (%s) does not render foreign content or fall back to a list', + async (reason) => { + const overrides = + reason === 'missing-reply' + ? { [replyPath]: () => new Response('', { status: 404 }) } + : { + [`${root}/c1`]: () => + reason === 'missing' + ? new Response('', { status: 404 }) + : Response.json({ + ...comment, + spec: { + ...comment.spec, + subjectRef: { + ...comment.spec.subjectRef, + name: 'another-post', + }, + }, + }), + }; + const requests = api(overrides); + const widget = await mount('#halo-comment=c1&reply=r99'); + await until(() => + detailOf(widget)?.shadowRoot.querySelector('[role=status]') + ); + expect(itemOf(widget)).toBeNull(); + expect(requests).not.toContain(root); + expect(requests).not.toContain(`${root}/c1/reply`); + if (reason !== 'missing-reply') expect(requests).not.toContain(replyPath); + expect(detailOf(widget).shadowRoot.textContent).toContain( + 'Comment not found or unavailable' + ); + } +); + +test('returning to list ignores an in-flight detail response', async () => { + let resolve; + const requests = api({ + [`${root}/c1`]: () => + new Promise((done) => { + resolve = done; + }), + }); + const widget = await mount('#halo-comment=c1&reply=r99'); + await until(() => resolve); + detailOf(widget).shadowRoot.querySelector('.back').click(); + await until(() => widget.shadowRoot.querySelector('comment-list')); + resolve(Response.json(comment)); + await new Promise((done) => setTimeout(done, 50)); + expect(detailOf(widget)).toBeNull(); + expect(requests).not.toContain(replyPath); +}); + +test('timestamps open a copyable frontend URL, select it, close with Escape and outside click', async () => { + api(); + const widget = await mount('#halo-comment=c1&reply=r99'); + await until(() => repliesOf(widget)?.replies.length === 1); + const replies = repliesOf(widget); + await replies.updateComplete; + const replyItem = replies.shadowRoot.querySelector('reply-item'); + await replyItem.updateComplete; + const baseItem = replyItem.shadowRoot.querySelector('base-comment-item'); + await baseItem.updateComplete; + const link = baseItem.shadowRoot.querySelector('comment-link'); + await link.updateComplete; + const trigger = link.shadowRoot.querySelector('.trigger'); + trigger.click(); + await until(() => link.shadowRoot.querySelector('input')); + const input = link.shadowRoot.querySelector('input'); + expect(input.value).toBe( + `${location.origin}${location.pathname}${location.search}#halo-comment=c1&reply=r99` + ); + expect(input.readOnly).toBe(true); + await until(() => input.selectionEnd === input.value.length); + const clipboard = vi + .spyOn(navigator.clipboard, 'writeText') + .mockResolvedValue(); + link.shadowRoot.querySelector('.copy').click(); + await until( + () => + link.shadowRoot.querySelector('[role=status]').textContent === + 'Link copied' + ); + expect(clipboard).toHaveBeenCalledWith(input.value); + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + await link.updateComplete; + expect(link.shadowRoot.querySelector('input')).toBeNull(); + expect(link.shadowRoot.activeElement).toBe(trigger); + trigger.click(); + await until(() => link.shadowRoot.querySelector('input')); + document.body.click(); + await link.updateComplete; + expect(link.shadowRoot.querySelector('input')).toBeNull(); +}); + +test('a new root comment exits detail mode and loads the normal list once', async () => { + const requests = api(); + const widget = await mount('#halo-comment=c1'); + await until(() => itemOf(widget)); + window.dispatchEvent(new CustomEvent('halo:comment:created')); + await until( + () => widget.shadowRoot.querySelector('comment-list')?.comments.items.length + ); + expect(requests.filter((path) => path === root)).toHaveLength(1); + expect(location.hash).toBe(''); +}); + +test('copy failure keeps the link selected for manual copying on a narrow screen', async () => { + const { page } = await import('vitest/browser'); + await page.viewport(390, 844); + await import('../src/comment-link.ts'); + const link = document.createElement('comment-link'); + link.commentName = 'c1'; + link.creationTime = comment.spec.creationTime; + document.body.append(link); + await link.updateComplete; + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue( + new Error('Denied') + ); + link.shadowRoot.querySelector('.trigger').click(); + await until(() => link.shadowRoot.querySelector('input')?.selectionEnd); + link.shadowRoot.querySelector('.copy').click(); + await until(() => + link.shadowRoot + .querySelector('[role=status]') + .textContent.includes('manually') + ); + const input = link.shadowRoot.querySelector('input'); + expect(input.selectionEnd).toBe(input.value.length); + const panel = link.shadowRoot.querySelector('.panel'); + await until(() => panel.style.left); + expect(panel.getBoundingClientRect().left).toBeGreaterThanOrEqual(0); + expect(panel.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth); + expect(panel.scrollWidth).toBeLessThanOrEqual(panel.clientWidth); + await page.viewport(1200, 800); +}); diff --git a/packages/comment-widget/xliff/es.xlf b/packages/comment-widget/xliff/es.xlf index 8905730..3937807 100644 --- a/packages/comment-widget/xliff/es.xlf +++ b/packages/comment-widget/xliff/es.xlf @@ -302,6 +302,38 @@ Zoom image Ampliar imagen + + Comment link + Enlace al comentario + + + Copy link + Copiar enlace + + + Link copied + Enlace copiado + + + Select and copy the link manually + Selecciona y copia el enlace manualmente + + + Back to comments + Volver a los comentarios + + + Comment not found or unavailable + El comentario no existe o no está disponible + + + Failed to load comment, please try again + No se pudo cargar el comentario. Inténtalo de nuevo + + + View all replies + Ver todas las respuestas + diff --git a/packages/comment-widget/xliff/zh-CN.xlf b/packages/comment-widget/xliff/zh-CN.xlf index df62c1b..b4f9f1e 100644 --- a/packages/comment-widget/xliff/zh-CN.xlf +++ b/packages/comment-widget/xliff/zh-CN.xlf @@ -302,6 +302,38 @@ Zoom image 放大图片 + + Comment link + 评论链接 + + + Copy link + 复制链接 + + + Link copied + 链接已复制 + + + Select and copy the link manually + 请选中并手动复制链接 + + + Back to comments + 返回评论列表 + + + Comment not found or unavailable + 评论不存在或暂不可查看 + + + Failed to load comment, please try again + 评论加载失败,请重试 + + + View all replies + 查看全部回复 + diff --git a/packages/comment-widget/xliff/zh-TW.xlf b/packages/comment-widget/xliff/zh-TW.xlf index 10e2fc4..2ddf569 100644 --- a/packages/comment-widget/xliff/zh-TW.xlf +++ b/packages/comment-widget/xliff/zh-TW.xlf @@ -302,6 +302,38 @@ Zoom image 放大圖片 + + Comment link + 留言連結 + + + Copy link + 複製連結 + + + Link copied + 連結已複製 + + + Select and copy the link manually + 請選取並手動複製連結 + + + Back to comments + 返回留言列表 + + + Comment not found or unavailable + 留言不存在或暫時無法查看 + + + Failed to load comment, please try again + 留言載入失敗,請重試 + + + View all replies + 查看所有回覆 + diff --git a/src/main/java/run/halo/comment/widget/ReplyDetailEndpoint.java b/src/main/java/run/halo/comment/widget/ReplyDetailEndpoint.java new file mode 100644 index 0000000..d72c1d1 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/ReplyDetailEndpoint.java @@ -0,0 +1,160 @@ +package run.halo.comment.widget; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Counter; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.user.service.UserService; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.AnonymousUserConst; +import run.halo.comment.widget.upload.UploadIdentity; + +/** Public reply lookup for comment permalinks. */ +@Component +@RequiredArgsConstructor +public class ReplyDetailEndpoint implements CustomEndpoint { + private final ReactiveExtensionClient client; + private final UserService userService; + + @Override + public RouterFunction endpoint() { + return RouterFunctions.route() + .GET("comments/{commentName}/replies/{replyName}", this::getReply) + .build(); + } + + private Mono getReply(ServerRequest request) { + var username = ReactiveSecurityContextHolder.getContext() + .mapNotNull(SecurityContext::getAuthentication) + .filter(auth -> auth.isAuthenticated()) + .map(auth -> auth.getName()) + .filter(StringUtils::isNotBlank) + .defaultIfEmpty(AnonymousUserConst.PRINCIPAL); + var permission = userService.hasSufficientRoles(Set.of("role-template-view-comments")) + .defaultIfEmpty(false); + return Mono.zip(username, permission) + .flatMap(identity -> client.fetch(Comment.class, request.pathVariable("commentName")) + .filter(comment -> !ExtensionUtil.isDeleted(comment) + && visible(comment.getSpec(), identity.getT1(), identity.getT2())) + .flatMap(comment -> client.fetch(Reply.class, request.pathVariable("replyName")) + .filter(reply -> !ExtensionUtil.isDeleted(reply) + && Objects.equals(reply.getSpec().getCommentName(), comment.getMetadata().getName()) + && visible(reply.getSpec(), identity.getT1(), identity.getT2() + || (Boolean.TRUE.equals(comment.getSpec().getHidden()) + && owns(comment.getSpec(), identity.getT1())))))) + .flatMap(this::toPublicReply) + .flatMap(reply -> ServerResponse.ok() + .cacheControl(CacheControl.noStore().cachePrivate()) + .headers(headers -> headers.setVary(List.of(HttpHeaders.COOKIE, HttpHeaders.AUTHORIZATION))) + .bodyValue(reply)) + .switchIfEmpty(ServerResponse.notFound() + .cacheControl(CacheControl.noStore().cachePrivate()).build()); + } + + // Match Core's public visibility rules, including the owner of a private thread. + private static boolean visible(Comment.BaseCommentSpec spec, String username, boolean canView) { + var published = Boolean.TRUE.equals(spec.getApproved()) && Boolean.FALSE.equals(spec.getHidden()); + return published || (!AnonymousUserConst.isAnonymousUser(username) && (canView || owns(spec, username))); + } + + private static boolean owns(Comment.BaseCommentSpec spec, String username) { + var owner = spec.getOwner(); + return !AnonymousUserConst.isAnonymousUser(username) && owner != null + && User.KIND.equals(owner.getKind()) && Objects.equals(owner.getName(), username); + } + + private Mono toPublicReply(Reply reply) { + var owner = reply.getSpec().getOwner(); + Mono resolvedOwner; + if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { + resolvedOwner = Mono.just(new Owner(owner.getKind(), owner.getDisplayName(), + owner.getAnnotation(Comment.CommentOwner.AVATAR_ANNO), owner.getName())); + } else { + resolvedOwner = userService.getUserOrGhost(owner.getName()) + .map(user -> new Owner(user.getKind(), user.getSpec().getDisplayName(), + user.getSpec().getAvatar(), user.getSpec().getEmail())); + } + // Core stores interaction counters under plural.group/name. + var votes = client.fetch(Counter.class, "replies.content.halo.run/" + reply.getMetadata().getName()) + .mapNotNull(Counter::getUpvote).defaultIfEmpty(0); + return Mono.zip(resolvedOwner, votes).map(tuple -> { + var resolved = tuple.getT1(); + var source = reply.getSpec(); + var publicOwner = new Comment.CommentOwner(); + publicOwner.setKind(owner.getKind()); + publicOwner.setName(""); + publicOwner.setDisplayName(resolved.displayName()); + var annotations = new HashMap(); + var website = owner.getAnnotation(Comment.CommentOwner.WEBSITE_ANNO); + if (website != null) { + annotations.put(Comment.CommentOwner.WEBSITE_ANNO, website); + } + if (StringUtils.isNotBlank(resolved.email())) { + annotations.put(Comment.CommentOwner.EMAIL_HASH_ANNO, + UploadIdentity.hash(resolved.email().toLowerCase(Locale.ROOT))); + } + publicOwner.setAnnotations(annotations); + // Copy only public fields; never mutate an Extension obtained from the client. + var spec = new Reply.ReplySpec(); + spec.setCommentName(source.getCommentName()); + spec.setQuoteReply(source.getQuoteReply()); + spec.setContent(source.getContent()); + spec.setRaw(source.getRaw()); + spec.setOwner(publicOwner); + spec.setUserAgent(source.getUserAgent()); + spec.setIpAddress(""); + spec.setCreationTime(source.getCreationTime()); + spec.setApproved(source.getApproved()); + spec.setApprovedTime(source.getApprovedTime()); + spec.setHidden(source.getHidden()); + spec.setTop(source.getTop()); + spec.setPriority(source.getPriority()); + spec.setAllowNotification(source.getAllowNotification()); + var metadata = new Metadata(); + metadata.setName(reply.getMetadata().getName()); + metadata.setCreationTimestamp(reply.getMetadata().getCreationTimestamp()); + metadata.setVersion(reply.getMetadata().getVersion()); + return new PublicReply(metadata, spec, + new PublicOwner(resolved.kind(), resolved.displayName(), resolved.avatar()), + new Stats(tuple.getT2())); + }); + } + + private record Owner(String kind, String displayName, String avatar, String email) { + } + + public record PublicOwner(String kind, String displayName, String avatar) { + } + + public record Stats(int upvote) { + } + + public record PublicReply(Metadata metadata, Reply.ReplySpec spec, PublicOwner owner, Stats stats) { + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("api.commentwidget.halo.run/v1alpha1"); + } +} diff --git a/src/main/resources/extensions/role-templates.yaml b/src/main/resources/extensions/role-templates.yaml index 9d0ae76..5b2d842 100644 --- a/src/main/resources/extensions/role-templates.yaml +++ b/src/main/resources/extensions/role-templates.yaml @@ -25,3 +25,6 @@ rules: - apiGroups: [ "api.commentwidget.halo.run" ] resources: [ "submissions" ] verbs: [ "create" ] + - apiGroups: [ "api.commentwidget.halo.run" ] + resources: [ "comments/replies" ] + verbs: [ "get" ] diff --git a/src/test/java/run/halo/comment/widget/ReplyDetailEndpointTest.java b/src/test/java/run/halo/comment/widget/ReplyDetailEndpointTest.java new file mode 100644 index 0000000..8043891 --- /dev/null +++ b/src/test/java/run/halo/comment/widget/ReplyDetailEndpointTest.java @@ -0,0 +1,167 @@ +package run.halo.comment.widget; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Counter; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.core.user.service.UserService; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; + +class ReplyDetailEndpointTest { + ReactiveExtensionClient client; + UserService users; + Comment comment; + Reply reply; + WebTestClient web; + + @BeforeEach + void setUp() { + client = mock(ReactiveExtensionClient.class); + users = mock(UserService.class); + comment = new Comment(); + comment.setMetadata(metadata("comment")); + comment.setSpec(new Comment.CommentSpec()); + initialize(comment.getSpec(), "parent"); + reply = new Reply(); + reply.setMetadata(metadata("reply")); + reply.setSpec(new Reply.ReplySpec()); + initialize(reply.getSpec(), "reader"); + reply.getSpec().setCommentName("comment"); + when(client.fetch(Comment.class, "comment")).thenReturn(Mono.just(comment)); + when(client.fetch(Reply.class, "reply")).thenReturn(Mono.just(reply)); + when(client.fetch(Counter.class, "replies.content.halo.run/reply")) + .thenReturn(Mono.just(Counter.emptyCounter("counter"))); + when(users.hasSufficientRoles(Set.of("role-template-view-comments"))).thenReturn(Mono.just(false)); + var user = new User(); + user.setMetadata(metadata("reader")); + user.setSpec(new User.UserSpec()); + user.getSpec().setDisplayName("Reader"); + user.getSpec().setEmail("reader@example.com"); + when(users.getUserOrGhost("reader")).thenReturn(Mono.just(user)); + web = WebTestClient.bindToRouterFunction(new ReplyDetailEndpoint(client, users).endpoint()) + .webFilter((exchange, chain) -> { + var name = exchange.getRequest().getHeaders().getFirst("X-Test-User"); + var response = chain.filter(exchange); + return name == null ? response : response.contextWrite( + ReactiveSecurityContextHolder.withAuthentication( + UsernamePasswordAuthenticationToken.authenticated(name, "", List.of()))); + }).build(); + } + + static Stream visibility() { + // user, view permission, parent approved/hidden, reply approved/hidden, expected status + return Stream.of( + Arguments.of("", false, true, false, true, false, 200), + Arguments.of("", false, true, false, false, false, 404), + Arguments.of("", false, true, false, true, true, 404), + Arguments.of("", false, false, false, true, false, 404), + Arguments.of("", false, true, true, true, false, 404), + Arguments.of("reader", false, true, false, false, true, 200), + Arguments.of("other", false, true, false, false, true, 404), + Arguments.of("parent", false, true, true, false, true, 200), + Arguments.of("reader", false, true, true, true, false, 404), + Arguments.of("admin", true, false, true, false, true, 200), + Arguments.of("", true, false, true, false, true, 404) + ); + } + + @ParameterizedTest + @MethodSource("visibility") + void honorsParentAndReplyVisibility(String username, boolean permission, boolean parentApproved, + boolean parentHidden, boolean replyApproved, boolean replyHidden, int status) { + when(users.hasSufficientRoles(Set.of("role-template-view-comments"))).thenReturn(Mono.just(permission)); + comment.getSpec().setApproved(parentApproved); + comment.getSpec().setHidden(parentHidden); + reply.getSpec().setApproved(replyApproved); + reply.getSpec().setHidden(replyHidden); + web.get().uri("/comments/comment/replies/reply").header("X-Test-User", username) + .exchange().expectStatus().isEqualTo(status); + } + + @Test + void rejectsDeletedMissingAndUnrelatedResources() { + reply.getSpec().setCommentName("different"); + expectNotFound(); + reply.getSpec().setCommentName("comment"); + reply.getMetadata().setDeletionTimestamp(Instant.now()); + expectNotFound(); + reply.getMetadata().setDeletionTimestamp(null); + comment.getMetadata().setDeletionTimestamp(Instant.now()); + expectNotFound(); + comment.getMetadata().setDeletionTimestamp(null); + when(client.fetch(Reply.class, "reply")).thenReturn(Mono.empty()); + expectNotFound(); + when(client.fetch(Comment.class, "comment")).thenReturn(Mono.empty()); + expectNotFound(); + } + + @Test + void returnsOnlyPublicDataWithoutMutatingStoredReply() { + var owner = reply.getSpec().getOwner(); + owner.setKind(Comment.CommentOwner.KIND_EMAIL); + owner.setName("Private@Example.com"); + owner.setAnnotations(Map.of("Email", "secret@example.com", "website", "https://example.com", + "private-field", "secret")); + reply.getMetadata().setAnnotations(Map.of("private-field", "secret")); + reply.getSpec().setIpAddress("192.0.2.1"); + var counter = Counter.emptyCounter("counter"); + counter.setUpvote(8); + when(client.fetch(Counter.class, "replies.content.halo.run/reply")).thenReturn(Mono.just(counter)); + web.get().uri("/comments/comment/replies/reply").exchange().expectStatus().isOk() + .expectHeader().value("Cache-Control", value -> assertThat(value).contains("no-store", "private")) + .expectHeader().value("Vary", value -> assertThat(value).contains("Cookie", "Authorization")) + .expectBody().jsonPath("$.spec.owner.name").isEqualTo("") + .jsonPath("$.spec.ipAddress").isEqualTo("") + .jsonPath("$.spec.owner.annotations.website").isEqualTo("https://example.com") + .jsonPath("$.spec.owner.annotations['email-hash']").isNotEmpty() + .jsonPath("$.spec.owner.annotations.Email").doesNotExist() + .jsonPath("$.spec.owner.annotations['private-field']").doesNotExist() + .jsonPath("$.metadata.annotations['private-field']").doesNotExist() + .jsonPath("$.owner.email").doesNotExist() + .jsonPath("$.owner.name").doesNotExist() + .jsonPath("$.stats.upvote").isEqualTo(8); + assertThat(owner.getName()).isEqualTo("Private@Example.com"); + assertThat(reply.getSpec().getIpAddress()).isEqualTo("192.0.2.1"); + verify(users, never()).getUserOrGhost(anyString()); + } + + private void expectNotFound() { + web.get().uri("/comments/comment/replies/reply").exchange().expectStatus().isNotFound(); + } + + private static Metadata metadata(String name) { + var metadata = new Metadata(); + metadata.setName(name); + return metadata; + } + + private static void initialize(Comment.BaseCommentSpec spec, String name) { + spec.setApproved(true); + spec.setHidden(false); + spec.setContent("

Content

"); + spec.setRaw("Content"); + var owner = new Comment.CommentOwner(); + owner.setKind(User.KIND); + owner.setName(name); + owner.setDisplayName(name); + spec.setOwner(owner); + } +} From 0b6d34911a04bea0442f39e4baf461cbf12ebd52 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Tue, 15 Sep 2026 00:11:01 +0800 Subject: [PATCH 2/5] Fix permalink detail focus and reply state preservation --- packages/comment-widget/src/comment-item.ts | 3 +- .../comment-widget/src/comment-replies.ts | 5 +- packages/comment-widget/src/comment-widget.ts | 11 ++- .../tests/comment-permalink.browser.test.js | 69 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/packages/comment-widget/src/comment-item.ts b/packages/comment-widget/src/comment-item.ts index 7499052..a053f38 100644 --- a/packages/comment-widget/src/comment-item.ts +++ b/packages/comment-widget/src/comment-item.ts @@ -229,8 +229,9 @@ export class CommentItem extends LitElement { ` )} ${when( - this.showReplies, + this.detail || this.showReplies, () => html`` )} - ${when(this.targetOnly, () => html``)} + ${when(this.targetOnly, () => html``)} ${when(this.loading, () => html` `)} ${when( this.hasNext, @@ -103,11 +103,12 @@ export class CommentReplies extends LitElement { } private showAllReplies() { + if (this.loading) return; return this.fetchReplies(); } refreshReplies() { - if (this.targetOnly) return this.showAllReplies(); + if (this.targetOnly) return this.fetchReplies(); const size = this.configMapData?.basic.replySize ?? 10; return this.fetchReplies({ size: Math.max(1, Math.ceil(this.replies.length / size)) * size, diff --git a/packages/comment-widget/src/comment-widget.ts b/packages/comment-widget/src/comment-widget.ts index 382a9a3..92674bf 100644 --- a/packages/comment-widget/src/comment-widget.ts +++ b/packages/comment-widget/src/comment-widget.ts @@ -91,7 +91,10 @@ export class CommentWidget extends LitElement { if (this.commentTarget) this.returnToList(); }; - private returnToList() { + private async returnToList() { + const restoreFocus = this.renderRoot + .querySelector('comment-detail') + ?.matches(':focus-within'); const url = new URL(location.href); const params = new URLSearchParams(url.hash.slice(1)); params.delete('halo-comment'); @@ -99,6 +102,12 @@ export class CommentWidget extends LitElement { url.hash = params.toString(); history.pushState(history.state, '', url); window.dispatchEvent(new Event('hashchange')); + await this.updateComplete; + const list = this.renderRoot.querySelector('comment-list'); + if (restoreFocus && this.isConnected && list) { + list.tabIndex = -1; + list.focus({ preventScroll: true }); + } } override disconnectedCallback() { diff --git a/packages/comment-widget/tests/comment-permalink.browser.test.js b/packages/comment-widget/tests/comment-permalink.browser.test.js index a61ce86..ccd9a14 100644 --- a/packages/comment-widget/tests/comment-permalink.browser.test.js +++ b/packages/comment-widget/tests/comment-permalink.browser.test.js @@ -262,3 +262,72 @@ test('copy failure keeps the link selected for manual copying on a narrow screen expect(panel.scrollWidth).toBeLessThanOrEqual(panel.clientWidth); await page.viewport(1200, 800); }); + +test('view-all preserves focus while loading and moves it to the replies', async () => { + let resolveList; + const requests = api({ + [`${root}/c1/reply`]: () => + new Promise((resolve) => { + resolveList = resolve; + }), + }); + const widget = await mount('#halo-comment=c1&reply=r99'); + await until(() => repliesOf(widget)?.replies.length === 1); + const replies = repliesOf(widget); + await replies.updateComplete; + const button = replies.shadowRoot.querySelector('button'); + button.focus(); + expect(replies.shadowRoot.activeElement).toBe(button); + button.click(); + await until(() => resolveList && replies.loading); + await replies.updateComplete; + button.click(); + expect(requests.filter((path) => path === `${root}/c1/reply`)).toHaveLength( + 1 + ); + expect(replies.shadowRoot.activeElement).toBe(button); + resolveList(Response.json(list([reply]))); + await until(() => !replies.loading); + await replies.updateComplete; + expect(replies.shadowRoot.activeElement).toBe( + replies.shadowRoot.querySelector('reply-item') + ); +}); + +test('collapsing detail replies preserves the expanded list', async () => { + const other = { ...reply, metadata: { name: 'r100' } }; + api({ [`${root}/c1/reply`]: () => Response.json(list([reply, other])) }); + const widget = await mount('#halo-comment=c1&reply=r99'); + await until(() => repliesOf(widget)?.replies.length === 1); + const replies = repliesOf(widget); + await replies.updateComplete; + replies.shadowRoot.querySelector('button').click(); + await until(() => replies.replies.length === 2); + const item = itemOf(widget); + item.shadowRoot.querySelector('.show-replies-button').click(); + await until(() => repliesOf(widget)?.hidden); + expect(replies.getClientRects()).toHaveLength(0); + item.shadowRoot.querySelector('.show-replies-button').click(); + await until(() => !repliesOf(widget)?.hidden); + expect(repliesOf(widget)).toBe(replies); + expect(repliesOf(widget).replies).toHaveLength(2); + expect(repliesOf(widget).shadowRoot.textContent).not.toContain( + 'View all replies' + ); +}); +test('returning to the list moves keyboard focus to the list', async () => { + api(); + const widget = await mount('#halo-comment=c1&reply=r99'); + await until(() => repliesOf(widget)?.replies.length === 1); + const detail = detailOf(widget); + const back = detail.shadowRoot.querySelector('.back'); + back.focus(); + expect(detail.shadowRoot.activeElement).toBe(back); + back.click(); + await until( + () => widget.shadowRoot.querySelector('comment-list')?.comments.items.length + ); + expect(widget.shadowRoot.activeElement).toBe( + widget.shadowRoot.querySelector('comment-list') + ); +}); From 275ca69fd92491bbdeb87404b7c19a6f65b7e64c Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Tue, 15 Sep 2026 12:28:24 +0800 Subject: [PATCH 3/5] Fix lazy loading and scrolling for comment permalinks --- packages/comment-widget/src/comment-detail.ts | 4 +- .../tests/comment-permalink.browser.test.js | 196 +++++++++++++++++- packages/widget/src/index.ts | 67 +++++- 3 files changed, 253 insertions(+), 14 deletions(-) diff --git a/packages/comment-widget/src/comment-detail.ts b/packages/comment-widget/src/comment-detail.ts index f0a0018..d5904cf 100644 --- a/packages/comment-widget/src/comment-detail.ts +++ b/packages/comment-widget/src/comment-detail.ts @@ -82,9 +82,7 @@ export class CommentDetail extends LitElement { if ( requestId === this.requestId && this.isConnected && - this.comment && - !this.target.replyName && - !this.error + (!this.target.replyName || this.error) ) { this.scrollIntoView({ block: 'start' }); } diff --git a/packages/comment-widget/tests/comment-permalink.browser.test.js b/packages/comment-widget/tests/comment-permalink.browser.test.js index ccd9a14..0a470a0 100644 --- a/packages/comment-widget/tests/comment-permalink.browser.test.js +++ b/packages/comment-widget/tests/comment-permalink.browser.test.js @@ -1,8 +1,13 @@ import { afterEach, expect, test, vi } from 'vitest'; import { mockApi, until } from './browser-helpers.js'; +vi.mock('@halo-dev/comment-widget', () => import('../src/index.ts')); + const originalUrl = location.href; -afterEach(() => history.replaceState(null, '', originalUrl)); +afterEach(() => { + history.replaceState(null, '', originalUrl); + window.scrollTo(0, 0); +}); const root = '/apis/api.halo.run/v1alpha1/comments'; const replyPath = '/apis/api.commentwidget.halo.run/v1alpha1/comments/c1/replies/r99'; @@ -83,6 +88,192 @@ const itemOf = (widget) => const repliesOf = (widget) => itemOf(widget)?.shadowRoot.querySelector('comment-replies'); +async function lazyContainer(hash) { + history.replaceState( + null, + '', + `${location.pathname}${location.search}${hash}` + ); + const { init } = await import('../../widget/src/index.ts'); + const parent = document.createElement('div'); + parent.id = 'theme-comments'; + parent.style.cssText = 'margin: 200vh 0; min-height: 1px'; + document.body.append(parent); + window.scrollTo(0, 0); + const scroll = vi.spyOn(parent, 'scrollIntoView'); + init('#theme-comments', { + group: 'content.halo.run', + kind: 'Post', + name: 'post', + }); + return { parent, scroll }; +} + +test.each(['#halo-comment=c1', '#halo-comment=c1&reply=r99'])( + 'an offscreen permalink mounts immediately and scrolls only its target (%s)', + async (hash) => { + const requests = api(); + const allScrolls = vi.spyOn(Element.prototype, 'scrollIntoView'); + const { parent } = await lazyContainer(hash); + expect(parent.childElementCount).toBe(1); + const widget = parent.firstElementChild; + await until(() => repliesOf(widget)?.replies.length === 1); + await until(() => allScrolls.mock.calls.length > 0); + expect(allScrolls).toHaveBeenCalledTimes(1); + expect(allScrolls.mock.instances[0].tagName).toBe( + hash.includes('reply=') ? 'REPLY-ITEM' : 'COMMENT-DETAIL' + ); + expect(window.scrollY).toBeGreaterThan(0); + expect(requests).not.toContain(root); + expect(parent.childElementCount).toBe(1); + } +); + +test.each(['', '#chapter', '#halo-comment=', '#reply=r99'])( + 'the plugin entry keeps ordinary visits lazy (%s)', + async (hash) => { + const requests = api(); + const { parent, scroll } = await lazyContainer(hash); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ); + expect(parent.childElementCount).toBe(0); + expect(requests).toHaveLength(0); + expect(scroll).not.toHaveBeenCalled(); + parent.scrollIntoView(); + await until(() => parent.firstElementChild?.isInitialized); + await until(() => requests.includes(root)); + history.replaceState(null, '', '#halo-comment=c1'); + window.dispatchEvent(new Event('hashchange')); + await until(() => itemOf(parent.firstElementChild)); + expect(parent.childElementCount).toBe(1); + expect(scroll).toHaveBeenCalledTimes(1); + } +); + +test.each(['hashchange', 'popstate'])( + 'the plugin entry wakes an offscreen widget on %s and then leaves scrolling to the component', + async (event) => { + api(); + const { parent, scroll } = await lazyContainer(''); + expect(parent.childElementCount).toBe(0); + history.replaceState(null, '', '#halo-comment=c1&reply=r99'); + window.dispatchEvent(new Event(event)); + expect(parent.childElementCount).toBe(1); + expect(scroll).not.toHaveBeenCalled(); + const widget = parent.firstElementChild; + await until(() => repliesOf(widget)?.replies.length === 1); + history.replaceState(null, '', '#halo-comment=c1'); + window.dispatchEvent(new Event(event)); + await until(() => itemOf(widget) && !detailOf(widget).target.replyName); + expect(parent.firstElementChild).toBe(widget); + expect(scroll).not.toHaveBeenCalled(); + } +); + +test.each(['#halo-comment=c1', '#halo-comment=c1&reply=r99'])( + 'a named target waits for the theme to reveal the page and scrolls once (%s)', + async (hash) => { + const requests = api(); + const allScrolls = vi.spyOn(Element.prototype, 'scrollIntoView'); + document.body.hidden = true; + try { + const { parent } = await lazyContainer(hash); + expect(parent.childElementCount).toBe(0); + expect(requests).toHaveLength(0); + document.body.hidden = false; + await until(() => allScrolls.mock.calls.length > 0); + expect(allScrolls).toHaveBeenCalledTimes(1); + expect(allScrolls.mock.instances[0].tagName).toBe( + hash.includes('reply=') ? 'REPLY-ITEM' : 'COMMENT-DETAIL' + ); + expect(window.scrollY).toBeGreaterThan(0); + } finally { + document.body.hidden = false; + } + } +); + +test.each(['initial', 'hashchange', 'popstate'])( + 'the bare comment anchor scrolls to the list before and after mounting (%s)', + async (event) => { + const requests = api(); + const { parent, scroll } = await lazyContainer( + event === 'initial' ? '#halo-comment' : '' + ); + if (event === 'hashchange') { + location.hash = '#halo-comment'; + await until(() => parent.childElementCount === 1); + } else if (event === 'popstate') { + history.replaceState(null, '', '#halo-comment'); + window.dispatchEvent(new Event(event)); + } + expect(parent.childElementCount).toBe(1); + expect(scroll).toHaveBeenCalledExactlyOnceWith({ + block: 'start', + behavior: 'instant', + }); + expect(window.scrollY).toBeGreaterThan(0); + const widget = parent.firstElementChild; + await until( + () => + widget.shadowRoot.querySelector('comment-list')?.comments.items.length + ); + expect(detailOf(widget)).toBeNull(); + expect(requests).not.toContain(`${root}/c1`); + + history.replaceState(null, '', '#halo-comment=c1&reply=r99'); + window.dispatchEvent(new Event('hashchange')); + await until(() => repliesOf(widget)?.replies.length === 1); + expect(scroll).toHaveBeenCalledTimes(1); + window.scrollTo(0, 0); + history.replaceState(null, '', '#halo-comment'); + window.dispatchEvent(new Event('hashchange')); + expect(scroll).toHaveBeenCalledTimes(2); + expect(window.scrollY).toBeGreaterThan(0); + await until( + () => + widget.shadowRoot.querySelector('comment-list')?.comments.items.length + ); + expect(detailOf(widget)).toBeNull(); + expect(parent.firstElementChild).toBe(widget); + } +); + +test('the comment anchor scrolls after the theme reveals the page', async () => { + api(); + const previousScrollBehavior = document.documentElement.style.scrollBehavior; + document.documentElement.style.scrollBehavior = 'smooth'; + document.body.hidden = true; + try { + const { parent, scroll } = await lazyContainer('#halo-comment'); + let scrolledImmediately = false; + scroll.mockImplementation((options) => { + Element.prototype.scrollIntoView.call(parent, options); + scrolledImmediately = window.scrollY > 0; + }); + const widget = parent.firstElementChild; + await until( + () => + widget?.shadowRoot.querySelector('comment-list')?.comments.items.length + ); + expect(window.scrollY).toBe(0); + document.body.hidden = false; + await until(() => { + const rect = parent.getBoundingClientRect(); + return rect.top >= 0 && rect.top < innerHeight; + }); + expect(scrolledImmediately).toBe(true); + expect(scroll).toHaveBeenCalledExactlyOnceWith({ + block: 'start', + behavior: 'instant', + }); + } finally { + document.body.hidden = false; + document.documentElement.style.scrollBehavior = previousScrollBehavior; + } +}); + test('comment detail skips the list, loads replies, returns to the list and follows history', async () => { const requests = api(); const widget = await mount('#halo-comment=c1'); @@ -141,6 +332,7 @@ test.each(['missing', 'different-subject', 'missing-reply'])( }), }; const requests = api(overrides); + const scroll = vi.spyOn(Element.prototype, 'scrollIntoView'); const widget = await mount('#halo-comment=c1&reply=r99'); await until(() => detailOf(widget)?.shadowRoot.querySelector('[role=status]') @@ -152,6 +344,8 @@ test.each(['missing', 'different-subject', 'missing-reply'])( expect(detailOf(widget).shadowRoot.textContent).toContain( 'Comment not found or unavailable' ); + expect(scroll).toHaveBeenCalledTimes(1); + expect(scroll.mock.instances[0]).toBe(detailOf(widget)); } ); diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 4135d29..7b1f7c6 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -14,6 +14,7 @@ export function init(el: string, props: Props) { if (!parent) { console.error('Element not found', el); + return; } const commentWidget = document.createElement( @@ -25,19 +26,65 @@ export function init(el: string, props: Props) { commentWidget.version = 'v1alpha1'; commentWidget.name = props.name; + const mount = () => { + if (parent.childElementCount !== 0) return; + + parent.appendChild(commentWidget); + observer.disconnect(); + + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { + parent.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: 300, + fill: 'forwards', + }); + } + }; + + const showTarget = () => { + if (!parent.isConnected) { + targetObserver.disconnect(); + return; + } + if (!parent.getClientRects().length) return; + targetObserver.disconnect(); + mount(); + if (location.hash === '#halo-comment') { + parent.scrollIntoView({ block: 'start', behavior: 'instant' }); + } + }; + const targetObserver = new ResizeObserver(showTarget); + + let previousHash: string | undefined; + const onLocationChange = () => { + if (!parent.isConnected) { + observer.disconnect(); + targetObserver.disconnect(); + window.removeEventListener('hashchange', onLocationChange); + window.removeEventListener('popstate', onLocationChange); + return; + } + const hash = location.hash; + if (hash === previousHash) return; + previousHash = hash; + targetObserver.disconnect(); + const isCommentAnchor = hash === '#halo-comment'; + if ( + isCommentAnchor || + new URLSearchParams(hash.slice(1)).get('halo-comment') + ) { + if (isCommentAnchor) mount(); + targetObserver.observe(parent); + showTarget(); + } + }; + const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { - if (entry.isIntersecting && parent.childElementCount === 0) { - parent.appendChild(commentWidget); - - if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { - parent.animate([{ opacity: 0 }, { opacity: 1 }], { - duration: 300, - fill: 'forwards', - }); - } - } + if (entry.isIntersecting) mount(); }); }); observer.observe(parent as Element); + window.addEventListener('hashchange', onLocationChange); + window.addEventListener('popstate', onLocationChange); + onLocationChange(); } From 9d0d9ad21a8d438f7b97640165d41f584a2eb981 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Tue, 15 Sep 2026 12:45:40 +0800 Subject: [PATCH 4/5] Adapt comment permalinks to Halo 2.27 Core APIs --- README.md | 2 +- dev/component-usage.md | 8 +- packages/comment-widget/package.json | 2 +- .../comment-widget/src/base-comment-item.ts | 7 +- packages/comment-widget/src/comment-detail.ts | 20 ++- packages/comment-widget/src/comment-item.ts | 2 +- packages/comment-widget/src/comment-link.ts | 12 +- packages/comment-widget/src/comment-list.ts | 6 +- .../src/generated/locales/es.ts | 1 + .../src/generated/locales/zh-CN.ts | 1 + .../src/generated/locales/zh-TW.ts | 1 + packages/comment-widget/src/reply-item.ts | 3 +- .../comment-widget/src/utils/comment-link.ts | 8 - .../tests/comment-permalink.browser.test.js | 87 ++++++++- packages/comment-widget/xliff/es.xlf | 4 + packages/comment-widget/xliff/zh-CN.xlf | 4 + packages/comment-widget/xliff/zh-TW.xlf | 4 + pnpm-lock.yaml | 14 +- .../comment/widget/ReplyDetailEndpoint.java | 160 ----------------- .../resources/extensions/role-templates.yaml | 3 - src/main/resources/plugin.yaml | 2 +- .../widget/ReplyDetailEndpointTest.java | 167 ------------------ 22 files changed, 138 insertions(+), 380 deletions(-) delete mode 100644 src/main/java/run/halo/comment/widget/ReplyDetailEndpoint.java delete mode 100644 src/test/java/run/halo/comment/widget/ReplyDetailEndpointTest.java diff --git a/README.md b/README.md index e9c7115..f4f1aab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # plugin-comment-widget -Halo 2.0 的通用评论组件插件,为前台提供完整的评论解决方案。 +适用于 Halo 2.27.0 及以上版本的通用评论组件插件,为前台提供完整的评论解决方案。 ![Cover](./images/cover.png) diff --git a/dev/component-usage.md b/dev/component-usage.md index f02a502..79b5b17 100644 --- a/dev/component-usage.md +++ b/dev/component-usage.md @@ -72,7 +72,7 @@ export default App; ## 评论固定链接 -点击评论或回复的发布时间,可以查看完整时间并复制固定链接。链接基于当前前台页面地址生成,和 `baseUrl` 指向的 API 地址无关: +插件及独立 npm 组件要求 Halo 2.27.0 或更高版本。点击评论或回复的发布时间,可以查看完整时间并复制 Core 返回的顶层 `permalink`。缺少链接时仅显示日期,不提供复制入口,也不自行拼造链接。相对链接在复制时基于当前前台 URL 解析为完整地址,绝对链接保持原样;不会使用 API 的 `baseUrl` 替换前台地址: ```text /archives/example#halo-comment= @@ -88,10 +88,10 @@ export default App; 主题保持原来的挂载方式即可。相同页面内修改 hash 会更新详情;Headless 应用若通过 `history.pushState` 切换 URL,需要由应用通知组件(派发 `hashchange`)或重新挂载。使用 hash 路由的应用需自行协调路由片段,不能直接覆盖其路由 hash。内容页地址变更后的旧链接跳转由站点维护。 -指定回复通过插件公开接口查询: +指定回复通过 Halo Core 公开接口查询: ```text -GET /apis/api.commentwidget.halo.run/v1alpha1/comments/{commentName}/replies/{replyName} +GET /apis/api.halo.run/v1alpha1/comments/{commentName}/reply/{replyName} ``` -接口校验根评论、回复归属和当前访问者的可见性,并返回脱敏展示数据;根评论详情及回复列表继续使用 Halo Core 接口。 +接口校验根评论、回复归属和当前访问者的可见性,并返回脱敏展示数据;根评论详情及回复列表同样使用 Halo Core 接口。网络或服务失败时可以重试;不存在或不可见时提供返回列表入口。不保留旧 Core 的接口回退。 diff --git a/packages/comment-widget/package.json b/packages/comment-widget/package.json index d783d51..8b5268c 100644 --- a/packages/comment-widget/package.json +++ b/packages/comment-widget/package.json @@ -42,7 +42,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@floating-ui/dom": "^1.8.0", - "@halo-dev/api-client": "https://pkg.pr.new/@halo-dev/api-client@7679", + "@halo-dev/api-client": "https://pkg.pr.new/@halo-dev/api-client@5e35f2b", "@lit/context": "^1.1.6", "@lit/localize": "^0.12.2", "@tiptap/core": "3.31.3", diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts index ab26113..650ce16 100644 --- a/packages/comment-widget/src/base-comment-item.ts +++ b/packages/comment-widget/src/base-comment-item.ts @@ -26,10 +26,7 @@ export class BaseCommentItem extends LitElement { creationTime: string | undefined; @property() - commentName = ''; - - @property() - replyName = ''; + permalink?: string; @property({ type: Boolean }) approved: boolean | undefined; @@ -105,7 +102,7 @@ export class BaseCommentItem extends LitElement { ${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html``)} - + ${when(!this.approved, () => html`
${msg('Reviewing')}
`)}
diff --git a/packages/comment-widget/src/comment-detail.ts b/packages/comment-widget/src/comment-detail.ts index d5904cf..bdfde29 100644 --- a/packages/comment-widget/src/comment-detail.ts +++ b/packages/comment-widget/src/comment-detail.ts @@ -26,6 +26,7 @@ export class CommentDetail extends LitElement { @state() private reply?: ReplyVo; @state() private loading = true; @state() private error = ''; + @state() private retryable = false; private requestId = 0; private activeReplyItem?: { closeReplyForm(): void }; @@ -44,6 +45,7 @@ export class CommentDetail extends LitElement { const requestId = ++this.requestId; this.loading = true; this.error = ''; + this.retryable = false; try { const commentName = encodeURIComponent(this.target.commentName); const comment = await ofetch( @@ -62,7 +64,7 @@ export class CommentDetail extends LitElement { } const reply = this.target.replyName ? await ofetch( - `${this.baseUrl}/apis/api.commentwidget.halo.run/v1alpha1/comments/${commentName}/replies/${encodeURIComponent(this.target.replyName)}`, + `${this.baseUrl}/apis/api.halo.run/v1alpha1/comments/${commentName}/reply/${encodeURIComponent(this.target.replyName)}`, { retry: 0 } ) : undefined; @@ -71,10 +73,10 @@ export class CommentDetail extends LitElement { this.reply = reply; } catch (error) { if (requestId !== this.requestId) return; - this.error = - (error as { status?: number }).status === 404 - ? msg('Comment not found or unavailable') - : msg('Failed to load comment, please try again'); + this.retryable = (error as { status?: number }).status !== 404; + this.error = !this.retryable + ? msg('Comment not found or unavailable') + : msg('Failed to load comment, please try again'); } finally { if (requestId === this.requestId) { this.loading = false; @@ -90,6 +92,12 @@ export class CommentDetail extends LitElement { } } + private retry() { + this.tabIndex = -1; + this.focus({ preventScroll: true }); + void this.load(); + } + private returnToList() { this.dispatchEvent( new CustomEvent('comment-list-requested', { @@ -109,7 +117,7 @@ export class CommentDetail extends LitElement { this.activeReplyItem = event.detail; }}> - ${this.loading ? html`` : this.error ? html`

${this.error}

` : keyed(this.requestId, html``)} + ${this.loading ? html`` : this.error ? html`

${this.error}

${this.retryable ? html`` : ''}` : keyed(this.requestId, html``)}
`; } diff --git a/packages/comment-widget/src/comment-item.ts b/packages/comment-widget/src/comment-item.ts index a053f38..d1be5c1 100644 --- a/packages/comment-widget/src/comment-item.ts +++ b/packages/comment-widget/src/comment-item.ts @@ -170,7 +170,7 @@ export class CommentItem extends LitElement { .userDisplayName="${this.comment?.owner.displayName}" .content="${this.comment?.spec.content || ''}" .creationTime="${this.comment?.spec.creationTime}" - .commentName=${this.comment?.metadata.name} + .permalink=${this.comment?.permalink} .approved=${this.comment?.spec.approved} .pinned=${this.comment?.spec.top} .userWebsite=${this.comment?.spec.owner.annotations?.website} diff --git a/packages/comment-widget/src/comment-link.ts b/packages/comment-widget/src/comment-link.ts index ed65026..b7e9d0c 100644 --- a/packages/comment-widget/src/comment-link.ts +++ b/packages/comment-widget/src/comment-link.ts @@ -9,12 +9,10 @@ import { msg } from '@lit/localize'; import { css, html, LitElement } from 'lit'; import { property, state } from 'lit/decorators.js'; import baseStyles from './styles/base'; -import { commentLink } from './utils/comment-link'; import { formatDate, timeAgo } from './utils/date'; export class CommentLink extends LitElement { - @property() commentName = ''; - @property() replyName = ''; + @property() permalink?: string; @property() creationTime = ''; @state() private open = false; @state() private feedback = ''; @@ -66,9 +64,10 @@ export class CommentLink extends LitElement { } private async copy() { + if (!this.permalink) return; try { await navigator.clipboard.writeText( - commentLink(this.commentName, this.replyName) + new URL(this.permalink, location.href).href ); this.feedback = msg('Link copied'); } catch { @@ -78,6 +77,9 @@ export class CommentLink extends LitElement { } override render() { + if (!this.permalink) { + return html``; + } return html` { if (event.key === 'Escape' && this.open) { event.preventDefault(); @@ -93,7 +95,7 @@ export class CommentLink extends LitElement { ? html`