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:
2026-05-04 10:54:21 +08:00
parent a25f1661b5
commit 3d6188748d
7 changed files with 335 additions and 16 deletions

View File

@@ -8,13 +8,15 @@ import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../
import {
api,
getAuthToken,
moderationUpdateEvent,
onAuthTokenChange,
setAuthToken,
type AiModerationStatus,
type AuthUser,
type DiscussionEntityType,
type EntityDiscussionComment,
type Language
type Language,
type ModerationUpdateDetail
} from '../services/api';
import Skeleton from './Skeleton.vue';
@@ -176,7 +178,7 @@ function canSeeModeration(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) {
@@ -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 {
for (const comment of rows) {
if (comment.id === id) {
@@ -361,6 +416,7 @@ watch(activeLanguageCode, () => {
});
onMounted(() => {
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser();
void loadLanguages();
void loadDiscussion();
@@ -370,6 +426,7 @@ onMounted(() => {
});
onUnmounted(() => {
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
removeAuthListener?.();
});
</script>

View File

@@ -17,6 +17,7 @@ import {
import {
api,
getAuthToken,
moderationUpdateEvent,
notificationWebSocketUrl,
type AuthUser,
type LifeReactionType,
@@ -140,9 +141,13 @@ async function connectNotifications() {
return;
}
unreadCount.value = message.unreadCount;
if ('unreadCount' in message) {
unreadCount.value = message.unreadCount;
}
if (message.type === 'notifications.created') {
upsertNotification(message.notification);
} else if (message.type === 'moderation.updated') {
window.dispatchEvent(new CustomEvent(moderationUpdateEvent, { detail: message }));
}
} catch {
// Invalid socket payloads are ignored.

View File

@@ -479,7 +479,17 @@ export interface NotificationWsTicket {
export type NotificationWsMessage =
| { type: 'notifications.connected'; 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 {
acquisition_methods: NamedEntity[];

View File

@@ -27,13 +27,15 @@ import {
import {
api,
getAuthToken,
moderationUpdateEvent,
onAuthTokenChange,
setAuthToken,
type AiModerationStatus,
type AuthUser,
type LifeComment,
type LifePost,
type LifeReactionType
type LifeReactionType,
type ModerationUpdateDetail
} from '../services/api';
const { locale, t } = useI18n();
@@ -272,7 +274,7 @@ function moderationTone(status: AiModerationStatus) {
}
function canRetryModeration(currentPost: LifePost) {
return currentPost.moderationStatus !== 'approved' && canManage(currentPost);
return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost);
}
function replacePost(updatedPost: LifePost) {
@@ -280,6 +282,58 @@ function replacePost(updatedPost: LifePost) {
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) {
moderationBusyPostId.value = currentPost.id;
const nextErrors = { ...moderationErrors.value };
@@ -558,6 +612,7 @@ watch(locale, () => {
onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser();
void loadPost();
removeAuthListener = onAuthTokenChange(() => {
@@ -569,6 +624,7 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('click', closeReactionPickerFromDocument);
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
removeAuthListener?.();
});
</script>

View File

@@ -34,6 +34,7 @@ import {
import {
api,
getAuthToken,
moderationUpdateEvent,
onAuthTokenChange,
setAuthToken,
type AiModerationStatus,
@@ -43,7 +44,8 @@ import {
type LifeCategory,
type LifeComment,
type LifePost,
type LifeReactionType
type LifeReactionType,
type ModerationUpdateDetail
} from '../services/api';
type LifeCommentPageState = {
@@ -574,7 +576,75 @@ function moderationTone(status: AiModerationStatus) {
}
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) {
@@ -1040,6 +1110,7 @@ watch(locale, () => {
onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser();
void loadLanguages();
void loadLifeCategories();
@@ -1053,6 +1124,7 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('click', closeReactionPickerFromDocument);
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
disconnectFeedObserver();
removeAuthListener?.();
});