feat(moderation): add real-time status updates via WebSocket
Broadcast moderation status changes to the author via WebSocket Update UI in real-time for Life Posts, Comments, and Discussions Hide retry moderation button while status is reviewing
This commit is contained in:
@@ -250,7 +250,8 @@
|
|||||||
|
|
||||||
- Notifications 用于让已登录用户接收与自己相关的社区互动和审核结果。
|
- Notifications 用于让已登录用户接收与自己相关的社区互动和审核结果。
|
||||||
- 通知持久化存储,用户离线期间产生的通知会在下次登录后继续可见。
|
- 通知持久化存储,用户离线期间产生的通知会在下次登录后继续可见。
|
||||||
- 通知实时推送可以走 WebSocket;WebSocket 连接使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
- 通知和审核状态实时更新可以走 WebSocket;WebSocket 连接使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
||||||
|
- AI 审核从 `reviewing` 变更为 `approved`、`rejected` 或 `failed` 后,前端当前可见的对应 Life Post、Life Comment 或实体讨论评论状态应通过 WebSocket 直接更新,不要求用户刷新页面。
|
||||||
- 通知范围:
|
- 通知范围:
|
||||||
- Life Post 收到审核通过后的顶层评论时,通知 Life Post 作者。
|
- Life Post 收到审核通过后的顶层评论时,通知 Life Post 作者。
|
||||||
- Life Comment 收到审核通过后的回复时,通知父评论作者。
|
- Life Comment 收到审核通过后的回复时,通知父评论作者。
|
||||||
@@ -373,6 +374,7 @@
|
|||||||
- 审核状态包括:`unreviewed`、`reviewing`、`approved`、`rejected`、`failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。
|
- 审核状态包括:`unreviewed`、`reviewing`、`approved`、`rejected`、`failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。
|
||||||
- 新增或更新审核目标时先进入不可公开状态;只有 AI 审核通过后才进入普通公开讨论列表。
|
- 新增或更新审核目标时先进入不可公开状态;只有 AI 审核通过后才进入普通公开讨论列表。
|
||||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||||
|
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
||||||
- AI 审核会自动识别评论适合的语言区,语言区使用启用状态的 `languages.code`,但不影响系统 UI 语言。
|
- AI 审核会自动识别评论适合的语言区,语言区使用启用状态的 `languages.code`,但不影响系统 UI 语言。
|
||||||
- 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。
|
- 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。
|
||||||
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
@@ -835,6 +837,7 @@ Life Post 可配置:
|
|||||||
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
||||||
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
|
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
|
||||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||||
|
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
||||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
|
|
||||||
API 暴露边界:
|
API 暴露边界:
|
||||||
|
|||||||
@@ -82,7 +82,13 @@ export type NotificationsPage = {
|
|||||||
type NotificationWsMessage =
|
type NotificationWsMessage =
|
||||||
| { type: 'notifications.connected'; unreadCount: number }
|
| { type: 'notifications.connected'; unreadCount: number }
|
||||||
| { type: 'notifications.created'; notification: NotificationItem; unreadCount: number }
|
| { type: 'notifications.created'; notification: NotificationItem; unreadCount: number }
|
||||||
| { type: 'notifications.unread'; unreadCount: number };
|
| { type: 'notifications.unread'; unreadCount: number }
|
||||||
|
| {
|
||||||
|
type: 'moderation.updated';
|
||||||
|
target: NotificationTarget;
|
||||||
|
moderationStatus: NotificationModerationStatus;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultNotificationLimit = 15;
|
const defaultNotificationLimit = 15;
|
||||||
const maxNotificationLimit = 50;
|
const maxNotificationLimit = 50;
|
||||||
@@ -267,6 +273,20 @@ async function publishUnreadCount(userId: number): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishModerationUpdate(
|
||||||
|
userId: number,
|
||||||
|
target: NotificationTarget,
|
||||||
|
moderationStatus: NotificationModerationStatus,
|
||||||
|
moderationLanguageCode: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
broadcastNotificationMessage(userId, {
|
||||||
|
type: 'moderation.updated',
|
||||||
|
target,
|
||||||
|
moderationStatus,
|
||||||
|
moderationLanguageCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function publishInsertedNotification(row: { id: number; recipientUserId: number } | null): Promise<void> {
|
async function publishInsertedNotification(row: { id: number; recipientUserId: number } | null): Promise<void> {
|
||||||
if (row) {
|
if (row) {
|
||||||
await publishNotification(row.id, row.recipientUserId);
|
await publishNotification(row.id, row.recipientUserId);
|
||||||
@@ -539,7 +559,12 @@ export async function createModerationResultNotification(
|
|||||||
status: NotificationModerationStatus
|
status: NotificationModerationStatus
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (target.type === 'life-post') {
|
if (target.type === 'life-post') {
|
||||||
const row = await queryOne<{ id: number; recipientUserId: number }>(
|
const row = await queryOne<{
|
||||||
|
id: number;
|
||||||
|
recipientUserId: number;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
lifePostId: number;
|
||||||
|
}>(
|
||||||
`
|
`
|
||||||
INSERT INTO notifications (
|
INSERT INTO notifications (
|
||||||
recipient_user_id,
|
recipient_user_id,
|
||||||
@@ -553,16 +578,47 @@ export async function createModerationResultNotification(
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND created_by_user_id IS NOT NULL
|
AND created_by_user_id IS NOT NULL
|
||||||
RETURNING id, recipient_user_id AS "recipientUserId"
|
RETURNING
|
||||||
|
id,
|
||||||
|
recipient_user_id AS "recipientUserId",
|
||||||
|
(
|
||||||
|
SELECT ai_moderation_language_code
|
||||||
|
FROM life_posts
|
||||||
|
WHERE id = $1
|
||||||
|
) AS "moderationLanguageCode",
|
||||||
|
life_post_id AS "lifePostId"
|
||||||
`,
|
`,
|
||||||
[target.id, status]
|
[target.id, status]
|
||||||
);
|
);
|
||||||
await publishInsertedNotification(row);
|
await publishInsertedNotification(row);
|
||||||
|
if (row) {
|
||||||
|
await publishModerationUpdate(
|
||||||
|
row.recipientUserId,
|
||||||
|
{
|
||||||
|
type: 'life-post',
|
||||||
|
id: row.lifePostId,
|
||||||
|
path: `/life/${row.lifePostId}`,
|
||||||
|
lifePostId: row.lifePostId,
|
||||||
|
lifeCommentId: null,
|
||||||
|
discussionCommentId: null,
|
||||||
|
entityType: null,
|
||||||
|
entityId: null
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
row.moderationLanguageCode
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.type === 'life-comment') {
|
if (target.type === 'life-comment') {
|
||||||
const row = await queryOne<{ id: number; recipientUserId: number }>(
|
const row = await queryOne<{
|
||||||
|
id: number;
|
||||||
|
recipientUserId: number;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
lifePostId: number;
|
||||||
|
lifeCommentId: number;
|
||||||
|
}>(
|
||||||
`
|
`
|
||||||
INSERT INTO notifications (
|
INSERT INTO notifications (
|
||||||
recipient_user_id,
|
recipient_user_id,
|
||||||
@@ -587,15 +643,48 @@ export async function createModerationResultNotification(
|
|||||||
AND lc.deleted_at IS NULL
|
AND lc.deleted_at IS NULL
|
||||||
AND lp.deleted_at IS NULL
|
AND lp.deleted_at IS NULL
|
||||||
AND lc.created_by_user_id IS NOT NULL
|
AND lc.created_by_user_id IS NOT NULL
|
||||||
RETURNING id, recipient_user_id AS "recipientUserId"
|
RETURNING
|
||||||
|
id,
|
||||||
|
recipient_user_id AS "recipientUserId",
|
||||||
|
(
|
||||||
|
SELECT ai_moderation_language_code
|
||||||
|
FROM life_post_comments
|
||||||
|
WHERE id = $1
|
||||||
|
) AS "moderationLanguageCode",
|
||||||
|
life_post_id AS "lifePostId",
|
||||||
|
life_comment_id AS "lifeCommentId"
|
||||||
`,
|
`,
|
||||||
[target.id, status]
|
[target.id, status]
|
||||||
);
|
);
|
||||||
await publishInsertedNotification(row);
|
await publishInsertedNotification(row);
|
||||||
|
if (row) {
|
||||||
|
await publishModerationUpdate(
|
||||||
|
row.recipientUserId,
|
||||||
|
{
|
||||||
|
type: 'life-comment',
|
||||||
|
id: row.lifeCommentId,
|
||||||
|
path: `/life/${row.lifePostId}`,
|
||||||
|
lifePostId: row.lifePostId,
|
||||||
|
lifeCommentId: row.lifeCommentId,
|
||||||
|
discussionCommentId: null,
|
||||||
|
entityType: null,
|
||||||
|
entityId: null
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
row.moderationLanguageCode
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await queryOne<{ id: number; recipientUserId: number }>(
|
const row = await queryOne<{
|
||||||
|
id: number;
|
||||||
|
recipientUserId: number;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
discussionCommentId: number;
|
||||||
|
entityType: DiscussionEntityType;
|
||||||
|
entityId: number;
|
||||||
|
}>(
|
||||||
`
|
`
|
||||||
INSERT INTO notifications (
|
INSERT INTO notifications (
|
||||||
recipient_user_id,
|
recipient_user_id,
|
||||||
@@ -620,11 +709,38 @@ export async function createModerationResultNotification(
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND created_by_user_id IS NOT NULL
|
AND created_by_user_id IS NOT NULL
|
||||||
RETURNING id, recipient_user_id AS "recipientUserId"
|
RETURNING
|
||||||
|
id,
|
||||||
|
recipient_user_id AS "recipientUserId",
|
||||||
|
(
|
||||||
|
SELECT ai_moderation_language_code
|
||||||
|
FROM entity_discussion_comments
|
||||||
|
WHERE id = $1
|
||||||
|
) AS "moderationLanguageCode",
|
||||||
|
discussion_comment_id AS "discussionCommentId",
|
||||||
|
entity_type AS "entityType",
|
||||||
|
entity_id AS "entityId"
|
||||||
`,
|
`,
|
||||||
[target.id, status]
|
[target.id, status]
|
||||||
);
|
);
|
||||||
await publishInsertedNotification(row);
|
await publishInsertedNotification(row);
|
||||||
|
if (row) {
|
||||||
|
await publishModerationUpdate(
|
||||||
|
row.recipientUserId,
|
||||||
|
{
|
||||||
|
type: 'discussion-comment',
|
||||||
|
id: row.discussionCommentId,
|
||||||
|
path: discussionEntityPath(row.entityType, row.entityId) ?? '/',
|
||||||
|
lifePostId: null,
|
||||||
|
lifeCommentId: null,
|
||||||
|
discussionCommentId: row.discussionCommentId,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
row.moderationLanguageCode
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wsFrame(data: Buffer, opcode = 0x1): Buffer {
|
function wsFrame(data: Buffer, opcode = 0x1): Buffer {
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
getAuthToken,
|
||||||
|
moderationUpdateEvent,
|
||||||
onAuthTokenChange,
|
onAuthTokenChange,
|
||||||
setAuthToken,
|
setAuthToken,
|
||||||
type AiModerationStatus,
|
type AiModerationStatus,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type DiscussionEntityType,
|
type DiscussionEntityType,
|
||||||
type EntityDiscussionComment,
|
type EntityDiscussionComment,
|
||||||
type Language
|
type Language,
|
||||||
|
type ModerationUpdateDetail
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import Skeleton from './Skeleton.vue';
|
import Skeleton from './Skeleton.vue';
|
||||||
|
|
||||||
@@ -176,7 +178,7 @@ function canSeeModeration(comment: EntityDiscussionComment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canRetryModeration(comment: EntityDiscussionComment) {
|
function canRetryModeration(comment: EntityDiscussionComment) {
|
||||||
return !comment.deleted && comment.moderationStatus !== 'approved' && canSeeModeration(comment);
|
return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moderationLabel(status: AiModerationStatus) {
|
function moderationLabel(status: AiModerationStatus) {
|
||||||
@@ -304,6 +306,59 @@ async function retryModeration(comment: EntityDiscussionComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateDiscussionCommentModeration(
|
||||||
|
items: EntityDiscussionComment[],
|
||||||
|
commentId: number,
|
||||||
|
status: AiModerationStatus,
|
||||||
|
languageCode: string | null
|
||||||
|
): boolean {
|
||||||
|
for (const comment of items) {
|
||||||
|
if (comment.id === commentId) {
|
||||||
|
comment.moderationStatus = status;
|
||||||
|
comment.moderationLanguageCode = languageCode;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateDiscussionCommentModeration(comment.replies, commentId, status, languageCode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModerationUpdateEvent(event: Event): event is CustomEvent<ModerationUpdateDetail> {
|
||||||
|
return event instanceof CustomEvent && event.detail?.type === 'moderation.updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModerationUpdate(event: Event) {
|
||||||
|
if (!isModerationUpdateEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { target, moderationStatus, moderationLanguageCode } = event.detail;
|
||||||
|
if (
|
||||||
|
target.type !== 'discussion-comment' ||
|
||||||
|
target.discussionCommentId === null ||
|
||||||
|
target.entityType !== props.entityType ||
|
||||||
|
target.entityId !== Number(props.entityId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = updateDiscussionCommentModeration(
|
||||||
|
comments.value,
|
||||||
|
target.discussionCommentId,
|
||||||
|
moderationStatus,
|
||||||
|
moderationLanguageCode
|
||||||
|
);
|
||||||
|
if (updated) {
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
} else if (moderationStatus === 'approved') {
|
||||||
|
void loadDiscussion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean {
|
function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean {
|
||||||
for (const comment of rows) {
|
for (const comment of rows) {
|
||||||
if (comment.id === id) {
|
if (comment.id === id) {
|
||||||
@@ -361,6 +416,7 @@ watch(activeLanguageCode, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadLanguages();
|
void loadLanguages();
|
||||||
void loadDiscussion();
|
void loadDiscussion();
|
||||||
@@ -370,6 +426,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
removeAuthListener?.();
|
removeAuthListener?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
getAuthToken,
|
||||||
|
moderationUpdateEvent,
|
||||||
notificationWebSocketUrl,
|
notificationWebSocketUrl,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type LifeReactionType,
|
type LifeReactionType,
|
||||||
@@ -140,9 +141,13 @@ async function connectNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('unreadCount' in message) {
|
||||||
unreadCount.value = message.unreadCount;
|
unreadCount.value = message.unreadCount;
|
||||||
|
}
|
||||||
if (message.type === 'notifications.created') {
|
if (message.type === 'notifications.created') {
|
||||||
upsertNotification(message.notification);
|
upsertNotification(message.notification);
|
||||||
|
} else if (message.type === 'moderation.updated') {
|
||||||
|
window.dispatchEvent(new CustomEvent(moderationUpdateEvent, { detail: message }));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid socket payloads are ignored.
|
// Invalid socket payloads are ignored.
|
||||||
|
|||||||
@@ -479,7 +479,17 @@ export interface NotificationWsTicket {
|
|||||||
export type NotificationWsMessage =
|
export type NotificationWsMessage =
|
||||||
| { type: 'notifications.connected'; unreadCount: number }
|
| { type: 'notifications.connected'; unreadCount: number }
|
||||||
| { type: 'notifications.created'; notification: NotificationItem; unreadCount: number }
|
| { type: 'notifications.created'; notification: NotificationItem; unreadCount: number }
|
||||||
| { type: 'notifications.unread'; unreadCount: number };
|
| { type: 'notifications.unread'; unreadCount: number }
|
||||||
|
| {
|
||||||
|
type: 'moderation.updated';
|
||||||
|
target: NotificationTarget;
|
||||||
|
moderationStatus: NotificationModerationStatus;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const moderationUpdateEvent = 'pokopia-moderation-update';
|
||||||
|
|
||||||
|
export type ModerationUpdateDetail = Extract<NotificationWsMessage, { type: 'moderation.updated' }>;
|
||||||
|
|
||||||
export interface RecipeDetail extends Recipe {
|
export interface RecipeDetail extends Recipe {
|
||||||
acquisition_methods: NamedEntity[];
|
acquisition_methods: NamedEntity[];
|
||||||
|
|||||||
@@ -27,13 +27,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
getAuthToken,
|
||||||
|
moderationUpdateEvent,
|
||||||
onAuthTokenChange,
|
onAuthTokenChange,
|
||||||
setAuthToken,
|
setAuthToken,
|
||||||
type AiModerationStatus,
|
type AiModerationStatus,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type LifeComment,
|
type LifeComment,
|
||||||
type LifePost,
|
type LifePost,
|
||||||
type LifeReactionType
|
type LifeReactionType,
|
||||||
|
type ModerationUpdateDetail
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
@@ -272,7 +274,7 @@ function moderationTone(status: AiModerationStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canRetryModeration(currentPost: LifePost) {
|
function canRetryModeration(currentPost: LifePost) {
|
||||||
return currentPost.moderationStatus !== 'approved' && canManage(currentPost);
|
return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost);
|
||||||
}
|
}
|
||||||
|
|
||||||
function replacePost(updatedPost: LifePost) {
|
function replacePost(updatedPost: LifePost) {
|
||||||
@@ -280,6 +282,58 @@ function replacePost(updatedPost: LifePost) {
|
|||||||
commentsTotal.value = updatedPost.commentCount;
|
commentsTotal.value = updatedPost.commentCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateLifeCommentModeration(
|
||||||
|
items: LifeComment[],
|
||||||
|
commentId: number,
|
||||||
|
status: AiModerationStatus,
|
||||||
|
languageCode: string | null
|
||||||
|
): boolean {
|
||||||
|
for (const comment of items) {
|
||||||
|
if (comment.id === commentId) {
|
||||||
|
comment.moderationStatus = status;
|
||||||
|
comment.moderationLanguageCode = languageCode;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModerationUpdateEvent(event: Event): event is CustomEvent<ModerationUpdateDetail> {
|
||||||
|
return event instanceof CustomEvent && event.detail?.type === 'moderation.updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModerationUpdate(event: Event) {
|
||||||
|
if (!isModerationUpdateEvent(event) || !post.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { target, moderationStatus, moderationLanguageCode } = event.detail;
|
||||||
|
if (target.type === 'life-post' && target.lifePostId === post.value.id) {
|
||||||
|
post.value = {
|
||||||
|
...post.value,
|
||||||
|
moderationStatus,
|
||||||
|
moderationLanguageCode
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type !== 'life-comment' || target.lifePostId !== post.value.id || target.lifeCommentId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = updateLifeCommentModeration(comments.value, target.lifeCommentId, moderationStatus, moderationLanguageCode);
|
||||||
|
if (updated) {
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
} else if (moderationStatus === 'approved') {
|
||||||
|
void loadComments(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function retryPostModeration(currentPost: LifePost) {
|
async function retryPostModeration(currentPost: LifePost) {
|
||||||
moderationBusyPostId.value = currentPost.id;
|
moderationBusyPostId.value = currentPost.id;
|
||||||
const nextErrors = { ...moderationErrors.value };
|
const nextErrors = { ...moderationErrors.value };
|
||||||
@@ -558,6 +612,7 @@ watch(locale, () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', closeReactionPickerFromDocument);
|
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
|
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadPost();
|
void loadPost();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthTokenChange(() => {
|
||||||
@@ -569,6 +624,7 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', closeReactionPickerFromDocument);
|
document.removeEventListener('click', closeReactionPickerFromDocument);
|
||||||
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
|
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
|
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
removeAuthListener?.();
|
removeAuthListener?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
getAuthToken,
|
||||||
|
moderationUpdateEvent,
|
||||||
onAuthTokenChange,
|
onAuthTokenChange,
|
||||||
setAuthToken,
|
setAuthToken,
|
||||||
type AiModerationStatus,
|
type AiModerationStatus,
|
||||||
@@ -43,7 +44,8 @@ import {
|
|||||||
type LifeCategory,
|
type LifeCategory,
|
||||||
type LifeComment,
|
type LifeComment,
|
||||||
type LifePost,
|
type LifePost,
|
||||||
type LifeReactionType
|
type LifeReactionType,
|
||||||
|
type ModerationUpdateDetail
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type LifeCommentPageState = {
|
type LifeCommentPageState = {
|
||||||
@@ -574,7 +576,75 @@ function moderationTone(status: AiModerationStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canRetryModeration(post: LifePost) {
|
function canRetryModeration(post: LifePost) {
|
||||||
return post.moderationStatus !== 'approved' && canManage(post);
|
return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLifeCommentModeration(
|
||||||
|
items: LifeComment[],
|
||||||
|
commentId: number,
|
||||||
|
status: AiModerationStatus,
|
||||||
|
languageCode: string | null
|
||||||
|
): boolean {
|
||||||
|
for (const comment of items) {
|
||||||
|
if (comment.id === commentId) {
|
||||||
|
comment.moderationStatus = status;
|
||||||
|
comment.moderationLanguageCode = languageCode;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModerationUpdateEvent(event: Event): event is CustomEvent<ModerationUpdateDetail> {
|
||||||
|
return event instanceof CustomEvent && event.detail?.type === 'moderation.updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModerationUpdate(event: Event) {
|
||||||
|
if (!isModerationUpdateEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { target, moderationStatus, moderationLanguageCode } = event.detail;
|
||||||
|
if (target.type === 'life-post' && target.lifePostId !== null) {
|
||||||
|
const currentPost = posts.value.find((post) => post.id === target.lifePostId);
|
||||||
|
if (!currentPost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPost = {
|
||||||
|
...currentPost,
|
||||||
|
moderationStatus,
|
||||||
|
moderationLanguageCode
|
||||||
|
};
|
||||||
|
if (!matchesCurrentFilters(updatedPost)) {
|
||||||
|
posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type !== 'life-comment' || target.lifePostId === null || target.lifeCommentId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPost = posts.value.find((post) => post.id === target.lifePostId);
|
||||||
|
if (!currentPost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = commentPage(currentPost);
|
||||||
|
const updated = updateLifeCommentModeration(page.items, target.lifeCommentId, moderationStatus, moderationLanguageCode);
|
||||||
|
if (updated) {
|
||||||
|
setCommentPage(currentPost.id, { ...page, items: [...page.items] });
|
||||||
|
} else if (moderationStatus === 'approved' && areCommentsExpanded(currentPost.id)) {
|
||||||
|
void loadComments(currentPost, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryPostModeration(post: LifePost) {
|
async function retryPostModeration(post: LifePost) {
|
||||||
@@ -1040,6 +1110,7 @@ watch(locale, () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', closeReactionPickerFromDocument);
|
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
|
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadLanguages();
|
void loadLanguages();
|
||||||
void loadLifeCategories();
|
void loadLifeCategories();
|
||||||
@@ -1053,6 +1124,7 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', closeReactionPickerFromDocument);
|
document.removeEventListener('click', closeReactionPickerFromDocument);
|
||||||
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
|
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
|
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
disconnectFeedObserver();
|
disconnectFeedObserver();
|
||||||
removeAuthListener?.();
|
removeAuthListener?.();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user