Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# plugin-comment-widget

Halo 2.0 的通用评论组件插件,为前台提供完整的评论解决方案。
适用于 Halo 2.27.0 及以上版本的通用评论组件插件,为前台提供完整的评论解决方案。

![Cover](./images/cover.png)

## 功能特性

- 支持评论和回复,可分别配置分页条数
- 支持评论和回复固定链接,点击发布时间可复制链接,访问链接直接查看对应讨论
- 支持私密评论,限制评论内容的可见范围
- 支持表情选择,可自定义评论框占位符
- 支持上传 JPEG、PNG、GIF、WebP、AVIF 图片,可配置文件大小限制、存储策略和匿名上传权限
Expand Down
26 changes: 26 additions & 0 deletions dev/component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,29 @@ function App() {

export default App;
```

## 评论固定链接

插件及独立 npm 组件要求 Halo 2.27.0 或更高版本。点击评论或回复的发布时间,可以查看完整时间并复制 Core 返回的顶层 `permalink`。缺少链接时仅显示日期,不提供复制入口,也不自行拼造链接。相对链接在复制时基于当前前台 URL 解析为完整地址,绝对链接保持原样;不会使用 API 的 `baseUrl` 替换前台地址:

```text
/archives/example#halo-comment=<commentName>
/archives/example#halo-comment=<commentName>&reply=<replyName>
```

组件初始化时识别上述 hash,直接进入详情模式,不请求主评论列表:

- 评论链接展示根评论并分页加载回复。
- 回复链接展示根评论和指定回复;点击“查看全部回复”后才加载回复列表。
- 点击“返回评论列表”清除定位参数并加载正常列表,浏览器后退可返回详情。
- 根评论必须属于当前组件的 `group`、`kind`、`name`;不存在或不可见的内容不会展示。

主题保持原来的挂载方式即可。相同页面内修改 hash 会更新详情;Headless 应用若通过 `history.pushState` 切换 URL,需要由应用通知组件(派发 `hashchange`)或重新挂载。使用 hash 路由的应用需自行协调路由片段,不能直接覆盖其路由 hash。内容页地址变更后的旧链接跳转由站点维护。

指定回复通过 Halo Core 公开接口查询:

```text
GET /apis/api.halo.run/v1alpha1/comments/{commentName}/reply/{replyName}
```

接口校验根评论、回复归属和当前访问者的可见性,并返回脱敏展示数据;根评论详情及回复列表同样使用 Halo Core 接口。网络或服务失败时可以重试;不存在或不可见时提供返回列表入口。不保留旧 Core 的接口回退。
2 changes: 1 addition & 1 deletion packages/comment-widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions packages/comment-widget/src/base-comment-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +25,9 @@ export class BaseCommentItem extends LitElement {
@property({ type: String })
creationTime: string | undefined;

@property()
permalink?: string;

@property({ type: Boolean })
approved: boolean | undefined;

Expand Down Expand Up @@ -99,9 +102,7 @@ export class BaseCommentItem extends LitElement {

${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html`<commenter-ua-bar .ua=${this.ua}></commenter-ua-bar>`)}

<time class="item-meta-info text-xs text-text-3" title=${formatDate(this.creationTime)}>
${timeAgo(this.creationTime)}
</time>
<comment-link .permalink=${this.permalink} .creationTime=${this.creationTime}></comment-link>

${when(!this.approved, () => html`<div class="item-meta-info text-xs text-text-3">${msg('Reviewing')}</div>`)}
</div>
Expand Down
147 changes: 147 additions & 0 deletions packages/comment-widget/src/comment-detail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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, scrollWhenVisible } from './utils/comment-link';
import type { CommentManagedDetail } from './utils/comment-management';
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 = '';
@state() private retryable = false;
private requestId = 0;
private cancelScroll?: () => void;
private activeReplyItem?: { closeReplyForm(): void };

override connectedCallback() {
super.connectedCallback();
void this.load();
}

override disconnectedCallback() {
++this.requestId;
this.cancelScroll?.();
this.activeReplyItem?.closeReplyForm();
super.disconnectedCallback();
}

private async load() {
this.cancelScroll?.();
const requestId = ++this.requestId;
this.loading = true;
this.error = '';
this.retryable = false;
try {
const commentName = encodeURIComponent(this.target.commentName);
const comment = await ofetch<CommentVo>(
`${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
Comment on lines +61 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the subject version before rendering permalink details

When widgets target two API versions with the same group, kind, and resource name, this check accepts a comment from the other version and renders it in the current widget. The normal list and comment submission both include version, so permalink detail should also consume versionContext and require subject.version === this.version to preserve the same subject boundary.

Useful? React with 👍 / 👎.

) {
++this.requestId;
this.dispatchEvent(new CustomEvent('comment-subject-mismatch'));
return;
}
const reply = this.target.replyName
? await ofetch<ReplyVo>(
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments/${commentName}/reply/${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.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;
await this.updateComplete;
if (
requestId === this.requestId &&
this.isConnected &&
(!this.target.replyName || this.error)
) {
this.cancelScroll = scrollWhenVisible(this, 'start');
}
}
}
}

private retry() {
this.tabIndex = -1;
this.focus({ preventScroll: true });
void this.load();
}

private returnToList() {
this.dispatchEvent(
new CustomEvent('comment-list-requested', {
bubbles: true,
composed: true,
})
);
}

override render() {
return html`<div class="detail mt-5" @comment-managed=${(
event: CustomEvent<CommentManagedDetail>
) => {
if (event.detail.restoreFocus) {
this.tabIndex = -1;
this.focus({ preventScroll: true });
}
void this.load();
}} @reply-form-open=${(event: CustomEvent<{ closeReplyForm(): void }>) => {
event.stopPropagation();
if (this.activeReplyItem !== event.detail)
this.activeReplyItem?.closeReplyForm();
this.activeReplyItem = event.detail;
}}>
<button type="button" class="back text-sm text-primary-1" @click=${this.returnToList}>${msg('Back to comments')}</button>
${this.loading ? html`<loading-block></loading-block>` : this.error ? html`<p role="status" class="text-sm text-text-2 my-3">${this.error}</p>${this.retryable ? html`<button type="button" class="retry back text-sm text-primary-1" @click=${this.retry}>${msg('Retry')}</button>` : ''}` : keyed(this.requestId, html`<comment-item .comment=${this.comment} .detail=${true} .targetReply=${this.reply}></comment-item>`)}
</div>`;
}

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);
22 changes: 16 additions & 6 deletions packages/comment-widget/src/comment-item.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -129,7 +135,7 @@ export class CommentItem extends LitElement {
this.closeReplyForm();
this.renderRoot
.querySelector<HTMLButtonElement>(
this.configMapData?.basic.withReplies
this.detail || this.configMapData?.basic.withReplies
? '.reply-button'
: '.show-replies-button'
)
Expand Down Expand Up @@ -164,6 +170,7 @@ export class CommentItem extends LitElement {
.userDisplayName="${this.comment?.owner.displayName}"
.content="${this.comment?.spec.content || ''}"
.creationTime="${this.comment?.spec.creationTime}"
.permalink=${this.comment?.permalink}
.approved=${this.comment?.spec.approved}
.pinned=${this.comment?.spec.top}
.userWebsite=${this.comment?.spec.owner.annotations?.website}
Expand Down Expand Up @@ -197,7 +204,7 @@ export class CommentItem extends LitElement {
}

${when(
this.configMapData?.basic.withReplies,
this.detail || this.configMapData?.basic.withReplies,
() => html`
<button slot="action" class="reply-button icon-button group" type="button" @click="${this.handleToggleReplyForm}" aria-label=${this.showReplyForm ? msg('Cancel reply') : msg('Add reply')}>
<div class="icon-button-icon ">
Expand All @@ -222,11 +229,14 @@ export class CommentItem extends LitElement {
`
)}
${when(
this.showReplies,
this.detail || this.showReplies,
() => html`<comment-replies
?hidden=${!this.showReplies}
${ref(this.commentRepliesRef)}
.comment="${this.comment}"
.showReplyForm=${this.showReplyForm}
.targetReply=${this.targetReply}
.managedByParent=${this.detail}
></comment-replies>`
)}
</div>
Expand Down
Loading
Loading