Files
classroomio/apps/dashboard/src/lib/features/course/api/newsfeed.svelte.ts
T
Nwosu EmmanuelandRotimi Best a1c4ee720f feat(newsfeed): Improved UI flow for the course newsfeed (#791)
* feat(newsfeed): improved the newsfeed ui

* feat(newsfeed): updated the ui

* feat(newsfeed): some improvements

* feat(newsfeed): cleanup

* chore(newsfeed): added translations

* fix(newsfeed): fixing comments and reviews from greptile and coderabbit

* fix(newsfeed): fixing comments and reviews from greptile and coderabbit, improved replies to a reply

* fix(newsfeed): fixing reviews

* fix(ui): expose cancelLabel prop in comment-tree-input and pass localized text

---------

Co-authored-by: Rotimi Best <[email protected]>
2026-07-31 00:26:59 +01:00

680 lines
21 KiB
TypeScript

import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CreateNewsfeedCommentRequest,
CreateNewsfeedRequest,
DeleteNewsfeedCommentRequest,
DeleteNewsfeedRequest,
GetNewsfeedCommentsRequest,
GetNewsfeedRequest,
ListNewsfeed,
ListNewsfeedRequest,
NewsfeedCommentsResponse,
ReactToNewsfeedRequest,
UpdateNewsfeedCommentRequest,
UpdateNewsfeedRequest
} from '../utils/types';
import type { TNewsfeedCreate, TNewsfeedReactionUpdate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed';
import {
ZNewsfeedCommentCreate,
ZNewsfeedCommentUpdate,
ZNewsfeedCreate,
ZNewsfeedReactionUpdate,
ZNewsfeedUpdate
} from '@cio/utils/validation/newsfeed';
import { get } from 'svelte/store';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
export type NewsfeedCommentsByFeedId = {
items: NewsfeedCommentsResponse['items'];
totalCount: number;
hasMore: boolean;
isLoading: boolean;
cursor: string | null;
};
/**
* API class for newsfeed operations
*/
export class NewsfeedApi extends BaseApiWithErrors {
feeds = $state<ListNewsfeed>([]);
commentsByFeedId = $state<Record<string, NewsfeedCommentsByFeedId>>({});
repliesByParentId = $state<Record<number, NewsfeedCommentsByFeedId>>({});
isNewFeedModalOpen = $state(false);
/**
* Opens the new feed modal
*/
openNewFeedModal() {
this.isNewFeedModalOpen = true;
}
/**
* Closes the new feed modal
*/
closeNewFeedModal() {
this.isNewFeedModalOpen = false;
}
/**
* Lists newsfeed items for a course
*/
async list(courseId: string) {
await this.execute<ListNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed.$get({
param: { courseId },
query: {}
}),
logContext: 'listing newsfeed',
onSuccess: (response) => {
if (response.data) {
this.feeds = Array.isArray(response.data) ? response.data : response.data.items || [];
for (const feed of this.feeds) {
this.initComments(feed.id, feed.commentCount || 0);
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to list newsfeed');
}
}
});
}
/**
* Gets a newsfeed item by ID
*/
async get(courseId: string, feedId: string) {
await this.execute<GetNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].$get({
param: { courseId, feedId }
}),
logContext: 'fetching newsfeed item',
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to fetch newsfeed item');
}
}
});
}
/**
* Creates a new newsfeed item
*/
async create(courseId: string, fields: TNewsfeedCreate) {
if (isHtmlValueEmpty(fields.content)) {
this.errors = mapZodErrorsToTranslations(
ZNewsfeedCreate.safeParse({ ...fields, content: '', courseId }).error!,
'newsfeed'
);
return;
}
const result = ZNewsfeedCreate.safeParse({ ...fields, courseId });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<CreateNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed.$post({
param: { courseId },
json: result.data
}),
logContext: 'creating newsfeed',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Post created successfully');
const feedsArray = Array.isArray(this.feeds) ? this.feeds : [];
const author = get(profile);
const newFeed = {
...response.data,
authorProfileId: author.id,
authorFullname: author.fullname,
authorUsername: author.username,
authorAvatarUrl: author.avatarUrl,
commentCount: 0
};
this.feeds = [...feedsArray, newFeed];
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to create post');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates a newsfeed item (content and isPinned only)
*/
async update(courseId: string, feedId: string, fields: TNewsfeedUpdate) {
if (fields.content !== undefined && isHtmlValueEmpty(fields.content)) {
this.errors = mapZodErrorsToTranslations(
ZNewsfeedUpdate.safeParse({ ...fields, content: '' }).error!,
'newsfeed'
);
snackbar.error(this.errors.content ?? 'Failed to update post');
return;
}
const result = ZNewsfeedUpdate.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<UpdateNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].$put({
param: { courseId, feedId },
json: result.data
}),
logContext: 'updating newsfeed',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Post updated successfully');
this.feeds = this.feeds.map((feed) => {
if (feed.id !== feedId) {
return feed;
}
return {
...feed,
...response.data,
authorProfileId: feed.authorProfileId,
authorFullname: feed.authorFullname,
authorUsername: feed.authorUsername,
authorAvatarUrl: feed.authorAvatarUrl
};
});
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update post');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Updates a newsfeed item's reaction
*/
async react(courseId: string, feedId: string, reaction: TNewsfeedReactionUpdate['reaction']) {
const result = ZNewsfeedReactionUpdate.safeParse({ reaction });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<ReactToNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].react.$put({
param: { courseId, feedId },
json: result.data
}),
logContext: 'updating newsfeed reaction',
onSuccess: (response) => {
if (response.data) {
this.feeds = this.feeds.map((feed) => {
if (feed.id !== feedId) {
return feed;
}
return {
...feed,
...response.data,
authorProfileId: feed.authorProfileId,
authorFullname: feed.authorFullname,
authorUsername: feed.authorUsername,
authorAvatarUrl: feed.authorAvatarUrl
};
});
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update reaction');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else if ('error' in result) {
this.errors.general = result.error;
snackbar.error(result.error);
}
}
});
}
/**
* Deletes a newsfeed item
*/
async delete(courseId: string, feedId: string) {
await this.execute<DeleteNewsfeedRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].$delete({
param: { courseId, feedId }
}),
logContext: 'deleting newsfeed',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Post deleted successfully');
this.feeds = this.feeds.filter((feed) => feed.id !== feedId);
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete post');
}
}
});
}
/**
* Initialize comment state for a feed
*/
initComments(feedId: string, totalCount: number) {
if (!this.commentsByFeedId[feedId]) {
this.commentsByFeedId[feedId] = {
items: [],
totalCount,
hasMore: totalCount > 0,
isLoading: false,
cursor: null
};
} else {
this.commentsByFeedId[feedId].totalCount = totalCount;
this.commentsByFeedId[feedId].hasMore = totalCount > this.commentsByFeedId[feedId].items.length;
}
}
/**
* Fetch paginated comments for a feed
*/
async getComments(courseId: string, feedId: string, limit = 5) {
if (!this.commentsByFeedId[feedId]) {
this.initComments(feedId, 0);
}
this.commentsByFeedId[feedId].isLoading = true;
await this.execute<GetNewsfeedCommentsRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
param: { courseId, feedId },
query: { limit: String(limit) }
}),
logContext: 'fetching newsfeed comments',
onSuccess: (response) => {
if (response.data) {
this.commentsByFeedId[feedId] = {
items: response.data.items,
totalCount: response.data.totalCount,
hasMore: response.data.hasMore,
isLoading: false,
cursor: response.data.nextCursor
};
}
},
onError: (result) => {
this.commentsByFeedId[feedId].isLoading = false;
if (typeof result === 'string') {
snackbar.error('Failed to fetch comments');
}
}
});
}
/**
* Fetch replies for a specific parent comment
*/
async getReplies(courseId: string, feedId: string, parentId: number, limit = 5) {
if (!this.repliesByParentId[parentId]) {
this.repliesByParentId[parentId] = {
items: [],
totalCount: 0,
hasMore: false,
isLoading: true,
cursor: null
};
} else {
this.repliesByParentId[parentId].isLoading = true;
}
await this.execute<GetNewsfeedCommentsRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
param: { courseId, feedId },
query: { parentId: String(parentId), limit: String(limit) }
}),
logContext: 'fetching newsfeed comment replies',
onSuccess: (response) => {
if (response.data) {
this.repliesByParentId[parentId] = {
items: response.data.items,
totalCount: response.data.totalCount,
hasMore: response.data.hasMore,
isLoading: false,
cursor: response.data.nextCursor
};
}
},
onError: (result) => {
if (this.repliesByParentId[parentId]) {
this.repliesByParentId[parentId].isLoading = false;
}
if (typeof result === 'string') {
snackbar.error('Failed to fetch replies');
}
}
});
}
/**
* Load more replies for a specific parent comment (uses cursor)
*/
async loadMoreReplies(courseId: string, feedId: string, parentId: number, limit = 5) {
const replyState = this.repliesByParentId[parentId];
if (!replyState || !replyState.hasMore || replyState.isLoading || !replyState.cursor) {
return;
}
replyState.isLoading = true;
await this.execute<GetNewsfeedCommentsRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
param: { courseId, feedId },
query: { parentId: String(parentId), cursor: replyState.cursor!, limit: String(limit) }
}),
logContext: 'loading more newsfeed replies',
onSuccess: (response) => {
if (response.data) {
this.repliesByParentId[parentId] = {
items: [...replyState.items, ...response.data.items],
totalCount: response.data.totalCount,
hasMore: response.data.hasMore,
isLoading: false,
cursor: response.data.nextCursor
};
}
},
onError: (result) => {
if (this.repliesByParentId[parentId]) {
this.repliesByParentId[parentId].isLoading = false;
}
if (typeof result === 'string') {
snackbar.error('Failed to load more replies');
}
}
});
}
/**
* Load more comments (uses cursor)
*/
async loadMoreComments(courseId: string, feedId: string, limit = 5) {
const commentState = this.commentsByFeedId[feedId];
if (!commentState || !commentState.hasMore || commentState.isLoading || !commentState.cursor) {
return;
}
commentState.isLoading = true;
await this.execute<GetNewsfeedCommentsRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({
param: { courseId, feedId },
query: { cursor: commentState.cursor!, limit: String(limit) }
}),
logContext: 'loading more newsfeed comments',
onSuccess: (response) => {
if (response.data) {
this.commentsByFeedId[feedId] = {
items: [...commentState.items, ...response.data.items],
totalCount: response.data.totalCount,
hasMore: response.data.hasMore,
isLoading: false,
cursor: response.data.nextCursor
};
}
},
onError: (result) => {
this.commentsByFeedId[feedId].isLoading = false;
if (typeof result === 'string') {
snackbar.error('Failed to load more comments');
}
}
});
}
/**
* Creates a newsfeed comment or reply
*/
async createComment(
courseId: string,
feedId: string,
content: string,
author: { id: string; username: string; fullname: string; avatarUrl: string },
parentId?: number
) {
const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content, parentId });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<CreateNewsfeedCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed[':feedId'].comment.$post({
param: { courseId, feedId },
json: result.data
}),
logContext: 'creating newsfeed comment',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Comment added successfully');
const newComment = {
...response.data,
authorProfileId: author.id,
authorFullname: author.fullname,
authorUsername: author.username,
authorAvatarUrl: author.avatarUrl,
replyCount: 0
};
if (parentId) {
const repliesState = this.repliesByParentId[parentId];
if (repliesState) {
this.repliesByParentId[parentId] = {
...repliesState,
items: [...repliesState.items, newComment],
totalCount: repliesState.totalCount + 1
};
} else {
this.repliesByParentId[parentId] = {
items: [newComment],
totalCount: 1,
hasMore: false,
isLoading: false,
cursor: null
};
}
// Increment parent reply count in local top-level items
const commentState = this.commentsByFeedId[feedId];
if (commentState) {
const updatedItems = commentState.items.map((item) => {
if (item.id === parentId) {
return { ...item, replyCount: (item.replyCount || 0) + 1 };
}
return item;
});
this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems };
}
} else {
const commentState = this.commentsByFeedId[feedId];
if (commentState) {
this.commentsByFeedId[feedId] = {
...commentState,
items: [newComment, ...commentState.items],
totalCount: commentState.totalCount + 1
};
}
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to add comment');
}
}
});
}
/**
* Updates a newsfeed comment
*/
async updateComment(courseId: string, feedId: string, commentId: string, content: string, parentId?: number) {
const result = ZNewsfeedCommentUpdate.safeParse({ content });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed');
return;
}
await this.execute<UpdateNewsfeedCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed.comment[':commentId'].$put({
param: { courseId, commentId },
json: result.data
}),
logContext: 'updating newsfeed comment',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Comment updated successfully');
const numericId = Number(commentId);
if (parentId && this.repliesByParentId[parentId]) {
const replyState = this.repliesByParentId[parentId];
const updatedItems = replyState.items.map((item) => {
if (item.id === numericId) {
return { ...item, content: response.data?.content ?? content };
}
return item;
});
this.repliesByParentId[parentId] = { ...replyState, items: updatedItems };
} else {
const commentState = this.commentsByFeedId[feedId];
if (commentState) {
const updatedItems = commentState.items.map((item) => {
if (item.id === numericId) {
return { ...item, content: response.data?.content ?? content };
}
return item;
});
this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems };
}
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to update comment');
}
}
});
}
/**
* Deletes a newsfeed comment
*/
async deleteComment(courseId: string, feedId: string, commentId: string, parentId?: number) {
await this.execute<DeleteNewsfeedCommentRequest>({
requestFn: () =>
classroomio.course[':courseId'].newsfeed.comment[':commentId'].$delete({
param: { courseId, commentId }
}),
logContext: 'deleting newsfeed comment',
onSuccess: (response) => {
if (response.data) {
snackbar.success('Comment deleted successfully');
const numericId = Number(commentId);
if (parentId && this.repliesByParentId[parentId]) {
const replyState = this.repliesByParentId[parentId];
this.repliesByParentId[parentId] = {
...replyState,
items: replyState.items.filter((item) => String(item.id) !== commentId),
totalCount: Math.max(0, replyState.totalCount - 1)
};
const commentState = this.commentsByFeedId[feedId];
if (commentState) {
const updatedItems = commentState.items.map((item) => {
if (item.id === parentId) {
return { ...item, replyCount: Math.max(0, (item.replyCount || 1) - 1) };
}
return item;
});
this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems };
}
} else {
if (this.repliesByParentId[numericId]) {
delete this.repliesByParentId[numericId];
}
const commentState = this.commentsByFeedId[feedId];
if (commentState) {
this.commentsByFeedId[feedId] = {
...commentState,
items: commentState.items.filter((item) => String(item.id) !== commentId),
totalCount: Math.max(0, commentState.totalCount - 1)
};
}
}
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to delete comment');
}
}
});
}
}
export const newsfeedApi = /* @__PURE__ */ new NewsfeedApi();