mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-29 02:46:25 +02:00
Add live sync hooks for messages and comments
This commit is contained in:
parent
73ff261194
commit
dd781fa3d5
2 changed files with 231 additions and 0 deletions
77
surfsense_web/hooks/use-chat-messages-live.ts
Normal file
77
surfsense_web/hooks/use-chat-messages-live.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useShape } from "@electric-sql/react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
|
import type { RawMessage } from "@/contracts/types/chat-messages.types";
|
||||||
|
import type { Membership } from "@/contracts/types/members.types";
|
||||||
|
import type { MessageRecord } from "@/lib/chat/thread-persistence";
|
||||||
|
|
||||||
|
const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member info for building author data - derived from Membership
|
||||||
|
*/
|
||||||
|
type MemberInfo = Pick<Membership, "user_display_name" | "user_avatar_url">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get live chat messages for real-time sync.
|
||||||
|
* Uses Electric SQL for messages + membersAtom (API) for author info.
|
||||||
|
*/
|
||||||
|
export function useChatMessagesLive(threadId: number | null) {
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: messagesData,
|
||||||
|
isLoading: messagesLoading,
|
||||||
|
isError: messagesError,
|
||||||
|
error: messagesErrorDetails,
|
||||||
|
} = useShape<RawMessage>({
|
||||||
|
url: `${ELECTRIC_URL}/v1/shape`,
|
||||||
|
params: {
|
||||||
|
table: "new_chat_messages",
|
||||||
|
where: `thread_id = ${threadId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { data: membersData, isLoading: membersLoading } = useAtomValue(membersAtom);
|
||||||
|
|
||||||
|
|
||||||
|
const messages = useMemo<MessageRecord[]>(() => {
|
||||||
|
if (!messagesData) return [];
|
||||||
|
|
||||||
|
// Build member lookup map
|
||||||
|
const memberMap = new Map<string, MemberInfo>();
|
||||||
|
if (membersData) {
|
||||||
|
for (const member of membersData) {
|
||||||
|
memberMap.set(member.user_id, {
|
||||||
|
user_display_name: member.user_display_name,
|
||||||
|
user_avatar_url: member.user_avatar_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform raw messages to MessageRecord with author info
|
||||||
|
return [...messagesData].map((msg): MessageRecord => {
|
||||||
|
const author = msg.author_id ? memberMap.get(msg.author_id) : null;
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
thread_id: msg.thread_id,
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
author_id: msg.author_id,
|
||||||
|
author_display_name: author?.user_display_name ?? null,
|
||||||
|
author_avatar_url: author?.user_avatar_url ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [messagesData, membersData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
isLoading: messagesLoading || membersLoading,
|
||||||
|
isError: messagesError,
|
||||||
|
error: messagesError ? messagesErrorDetails : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
154
surfsense_web/hooks/use-comments-live.ts
Normal file
154
surfsense_web/hooks/use-comments-live.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useShape } from "@electric-sql/react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
import type { Comment, CommentReply, Author } from "@/contracts/types/chat-comments.types";
|
||||||
|
import type { Membership } from "@/contracts/types/members.types";
|
||||||
|
import type { RawComment } from "@/contracts/types/chat-comments.types";
|
||||||
|
|
||||||
|
const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133";
|
||||||
|
|
||||||
|
// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN)
|
||||||
|
const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member info for building author objects - derived from Membership
|
||||||
|
*/
|
||||||
|
type MemberInfo = Pick<Membership, "user_display_name" | "user_avatar_url" | "user_email">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render mentions in content by replacing @[uuid] with @{DisplayName}
|
||||||
|
*/
|
||||||
|
function renderMentions(content: string, memberMap: Map<string, MemberInfo>): string {
|
||||||
|
return content.replace(MENTION_PATTERN, (match, uuid) => {
|
||||||
|
const member = memberMap.get(uuid);
|
||||||
|
if (member?.user_display_name) {
|
||||||
|
return `@{${member.user_display_name}}`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get live comments for a specific message.
|
||||||
|
* Uses Electric SQL for comments + membersAtom (API) for author info.
|
||||||
|
* Returns data matching the existing Comment type.
|
||||||
|
*/
|
||||||
|
export function useCommentsLive(messageId: number | null) {
|
||||||
|
const {
|
||||||
|
data: commentsData,
|
||||||
|
isLoading: commentsLoading,
|
||||||
|
isError: commentsError,
|
||||||
|
error: commentsErrorDetails,
|
||||||
|
} = useShape<RawComment>({
|
||||||
|
url: `${ELECTRIC_URL}/v1/shape`,
|
||||||
|
params: {
|
||||||
|
table: "chat_comments",
|
||||||
|
where: `message_id = ${messageId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: membersData, isLoading: membersLoading } = useAtomValue(membersAtom);
|
||||||
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
const { data: myAccess } = useAtomValue(myAccessAtom);
|
||||||
|
|
||||||
|
const comments = useMemo<Comment[]>(() => {
|
||||||
|
if (!commentsData) return [];
|
||||||
|
|
||||||
|
// Build member lookup map
|
||||||
|
const memberMap = new Map<string, MemberInfo>();
|
||||||
|
if (membersData) {
|
||||||
|
for (const member of membersData) {
|
||||||
|
memberMap.set(member.user_id, {
|
||||||
|
user_display_name: member.user_display_name,
|
||||||
|
user_avatar_url: member.user_avatar_url,
|
||||||
|
user_email: member.user_email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = currentUser?.id;
|
||||||
|
const isOwnerOrAdmin = myAccess?.is_owner ?? false;
|
||||||
|
|
||||||
|
// Build author object from member data
|
||||||
|
const buildAuthor = (authorId: string | null): Author | null => {
|
||||||
|
if (!authorId) return null;
|
||||||
|
const member = memberMap.get(authorId);
|
||||||
|
if (!member) return null;
|
||||||
|
return {
|
||||||
|
id: authorId,
|
||||||
|
display_name: member.user_display_name ?? null,
|
||||||
|
avatar_url: member.user_avatar_url ?? null,
|
||||||
|
email: member.user_email ?? "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transform raw comment to CommentReply
|
||||||
|
const transformToReply = (raw: RawComment): CommentReply => {
|
||||||
|
const isEdited = raw.created_at !== raw.updated_at;
|
||||||
|
const isAuthor = currentUserId === raw.author_id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
content: raw.content,
|
||||||
|
content_rendered: renderMentions(raw.content, memberMap),
|
||||||
|
author: buildAuthor(raw.author_id),
|
||||||
|
created_at: raw.created_at,
|
||||||
|
updated_at: raw.updated_at,
|
||||||
|
is_edited: isEdited,
|
||||||
|
can_edit: isAuthor,
|
||||||
|
can_delete: isAuthor || isOwnerOrAdmin,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separate top-level comments and replies
|
||||||
|
const topLevelRaw: RawComment[] = [];
|
||||||
|
const repliesMap = new Map<number, RawComment[]>();
|
||||||
|
|
||||||
|
for (const raw of commentsData) {
|
||||||
|
if (raw.parent_id === null) {
|
||||||
|
topLevelRaw.push(raw);
|
||||||
|
} else {
|
||||||
|
const replies = repliesMap.get(raw.parent_id) || [];
|
||||||
|
replies.push(raw);
|
||||||
|
repliesMap.set(raw.parent_id, replies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform top-level comments to Comment type
|
||||||
|
const transformToComment = (raw: RawComment): Comment => {
|
||||||
|
const isEdited = raw.created_at !== raw.updated_at;
|
||||||
|
const isAuthor = currentUserId === raw.author_id;
|
||||||
|
const rawReplies = repliesMap.get(raw.id) || [];
|
||||||
|
const replies = rawReplies.map(transformToReply);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
message_id: raw.message_id,
|
||||||
|
content: raw.content,
|
||||||
|
content_rendered: renderMentions(raw.content, memberMap),
|
||||||
|
author: buildAuthor(raw.author_id),
|
||||||
|
created_at: raw.created_at,
|
||||||
|
updated_at: raw.updated_at,
|
||||||
|
is_edited: isEdited,
|
||||||
|
can_edit: isAuthor,
|
||||||
|
can_delete: isAuthor || isOwnerOrAdmin,
|
||||||
|
reply_count: replies.length,
|
||||||
|
replies,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return topLevelRaw.map(transformToComment);
|
||||||
|
}, [commentsData, membersData, currentUser?.id, myAccess?.is_owner]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
comments,
|
||||||
|
commentCount: commentsData?.length ?? 0,
|
||||||
|
isLoading: commentsLoading || membersLoading,
|
||||||
|
isError: commentsError,
|
||||||
|
error: commentsError ? commentsErrorDetails : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue