Add live sync hooks for messages and comments

This commit is contained in:
CREDO23 2026-01-21 17:58:35 +02:00
parent 73ff261194
commit dd781fa3d5
2 changed files with 231 additions and 0 deletions

View 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,
};
}

View 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,
};
}