2026-04-22 19:17:54 +02:00
|
|
|
import { on } from "node:events";
|
2025-08-28 18:27:07 +02:00
|
|
|
import { TRPCError, tracked } from "@trpc/server";
|
2025-08-07 14:50:40 +02:00
|
|
|
import { z } from "zod/v4";
|
2026-04-22 19:17:54 +02:00
|
|
|
import type { MessagesMessageid } from "~/schemas/public/Messages";
|
2025-08-04 17:45:44 +02:00
|
|
|
import {
|
2026-04-22 19:17:54 +02:00
|
|
|
adminProcedure,
|
|
|
|
|
createTRPCRouter,
|
|
|
|
|
protectedProcedure,
|
|
|
|
|
publicProcedure,
|
|
|
|
|
} from "~/server/api/trpc";
|
2025-08-28 18:27:07 +02:00
|
|
|
import { db } from "~/server/db";
|
|
|
|
|
import { add } from "~/server/services/chat.service";
|
2026-04-22 19:17:54 +02:00
|
|
|
import {
|
|
|
|
|
editMessage,
|
|
|
|
|
setReadMessage,
|
|
|
|
|
} from "~/server/services/messages.service";
|
|
|
|
|
import { zAsyncIterable } from "~/server/utils/zAsyncIterable";
|
|
|
|
|
import {
|
|
|
|
|
MessageDataSchema,
|
|
|
|
|
MessageUpdateEventSchema,
|
|
|
|
|
SidebarUpdateSchema,
|
|
|
|
|
TypingDataSchema,
|
|
|
|
|
zChatId,
|
|
|
|
|
zMessageId,
|
|
|
|
|
zUserId,
|
|
|
|
|
} from "~/server/utils/zod_types";
|
|
|
|
|
import { getKeydbClient, getSubClient } from "~/utils/keydb";
|
|
|
|
|
|
|
|
|
|
const getChatChannel = (chatId: string) => `chat:${chatId}:messages`;
|
|
|
|
|
const getTypingChannel = (chatId: string) => `chat:${chatId}:typing`;
|
|
|
|
|
const getEventsChannel = (chatId: string) => `chat:${chatId}:events`;
|
|
|
|
|
const getTypingKey = (chatId: string) => `typing:${chatId}`;
|
|
|
|
|
|
|
|
|
|
const SIDEBAR_CHANNEL = "sidebar_updates";
|
|
|
|
|
|
|
|
|
|
type SidebarUpdate = z.infer<typeof SidebarUpdateSchema>;
|
|
|
|
|
export type Message = z.infer<typeof MessageDataSchema>;
|
|
|
|
|
export type TypingData = z.infer<typeof TypingDataSchema>;
|
|
|
|
|
|
2025-08-04 17:45:44 +02:00
|
|
|
export const chatSSERouter = createTRPCRouter({
|
2026-04-22 19:17:54 +02:00
|
|
|
createChat: protectedProcedure
|
2025-08-28 18:27:07 +02:00
|
|
|
.input(z.object({ userId: zUserId }))
|
|
|
|
|
.mutation(async ({ input }) => {
|
|
|
|
|
const newChat = await add({
|
|
|
|
|
db,
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
});
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
return newChat.chatid;
|
|
|
|
|
}),
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2026-04-22 19:17:54 +02:00
|
|
|
getChat: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
chatId: zChatId,
|
|
|
|
|
cursor: zMessageId.nullish(),
|
|
|
|
|
take: z.number().min(1).max(50).nullish(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ input, ctx }) => {
|
|
|
|
|
try {
|
|
|
|
|
const didUpdate = await setReadMessage({
|
|
|
|
|
chatId: input.chatId,
|
|
|
|
|
db,
|
|
|
|
|
reader: ctx.session.id,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (didUpdate) {
|
|
|
|
|
const kdb = getKeydbClient();
|
|
|
|
|
if (kdb) {
|
|
|
|
|
await kdb.publish(
|
|
|
|
|
getEventsChannel(input.chatId),
|
|
|
|
|
JSON.stringify({ eventType: "read_receipt", data: null }),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const take = input.take ?? 20;
|
|
|
|
|
const cursor = input.cursor;
|
2025-08-04 18:59:33 +02:00
|
|
|
|
2026-04-22 19:17:54 +02:00
|
|
|
let qry = db
|
|
|
|
|
.selectFrom("messages")
|
|
|
|
|
.innerJoin("users", "users.id", "messages.sender")
|
|
|
|
|
.select([
|
|
|
|
|
"messages.message",
|
|
|
|
|
"messages.messageid",
|
|
|
|
|
"messages.chatid",
|
|
|
|
|
(eb) =>
|
|
|
|
|
eb
|
|
|
|
|
.cast<string>(
|
|
|
|
|
eb.fn("to_char", [
|
|
|
|
|
"messages.time",
|
|
|
|
|
eb.val("YYYY-MM-DD HH24:MI:SS"),
|
|
|
|
|
]),
|
|
|
|
|
"text",
|
|
|
|
|
)
|
|
|
|
|
.as("time"),
|
|
|
|
|
"messages.sender",
|
|
|
|
|
"messages.isread",
|
|
|
|
|
"users.nome as sender_nome",
|
|
|
|
|
"users.isAdmin as sender_isAdmin",
|
|
|
|
|
])
|
|
|
|
|
.where("users.nome", "is not", null)
|
|
|
|
|
.where("messages.chatid", "=", input.chatId)
|
|
|
|
|
.orderBy("messages.messageid", "desc")
|
|
|
|
|
.limit(take + 1);
|
|
|
|
|
|
|
|
|
|
if (cursor) {
|
|
|
|
|
qry = qry.where("messages.messageid", "<=", cursor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const page = await qry.execute();
|
|
|
|
|
|
|
|
|
|
const items = page.reverse();
|
|
|
|
|
let nextCursor: typeof cursor | null = null;
|
|
|
|
|
if (items.length > take) {
|
|
|
|
|
const prev = items.shift();
|
|
|
|
|
nextCursor = prev?.messageid ?? null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
items,
|
|
|
|
|
nextCursor,
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
|
|
|
message: `Error while fetching messages: ${(e as Error).message}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
sendMessage: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
chatId: zChatId,
|
|
|
|
|
message: z.string(),
|
|
|
|
|
sender: zUserId,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ input }) => {
|
|
|
|
|
const newMessage = await db
|
|
|
|
|
.insertInto("messages")
|
|
|
|
|
.values({
|
|
|
|
|
chatid: input.chatId,
|
|
|
|
|
message: input.message,
|
|
|
|
|
sender: input.sender,
|
|
|
|
|
time: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.returningAll()
|
|
|
|
|
.executeTakeFirstOrThrow(
|
|
|
|
|
() => new Error("Failed to insert message into database"),
|
2025-08-28 18:27:07 +02:00
|
|
|
);
|
2026-04-22 19:17:54 +02:00
|
|
|
|
|
|
|
|
const userInfos = await db
|
|
|
|
|
.selectFrom("users")
|
|
|
|
|
.select(["nome as sender_nome", "isAdmin as sender_isAdmin"])
|
|
|
|
|
.where("id", "=", input.sender)
|
|
|
|
|
.executeTakeFirst();
|
|
|
|
|
|
|
|
|
|
if (!userInfos) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "NOT_FOUND",
|
|
|
|
|
message: "User not found or no name",
|
|
|
|
|
});
|
2025-08-28 18:27:07 +02:00
|
|
|
}
|
2026-04-22 19:17:54 +02:00
|
|
|
const payload: Message = {
|
|
|
|
|
...newMessage,
|
|
|
|
|
...userInfos,
|
|
|
|
|
time: newMessage.time.toISOString(),
|
|
|
|
|
};
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2026-04-22 19:17:54 +02:00
|
|
|
const kdb = getKeydbClient();
|
|
|
|
|
const msgChannel = getChatChannel(input.chatId);
|
|
|
|
|
const typingChannel = getTypingChannel(input.chatId);
|
|
|
|
|
const typingKey = getTypingKey(input.chatId);
|
|
|
|
|
if (kdb) {
|
|
|
|
|
await kdb.publish(msgChannel, JSON.stringify(payload));
|
|
|
|
|
const member = JSON.stringify({
|
|
|
|
|
userId: input.sender,
|
|
|
|
|
username: userInfos.sender_nome,
|
|
|
|
|
});
|
|
|
|
|
await kdb.zrem(typingKey, member);
|
|
|
|
|
const typingList = await kdb.zrange(typingKey, 0, -1);
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2026-04-22 19:17:54 +02:00
|
|
|
await kdb.publish(
|
|
|
|
|
typingChannel,
|
|
|
|
|
JSON.stringify(typingList.map((m) => JSON.parse(m))),
|
2025-08-28 18:27:07 +02:00
|
|
|
);
|
2026-04-22 19:17:54 +02:00
|
|
|
const sidebarPayload: SidebarUpdate = {
|
|
|
|
|
chatId: input.chatId,
|
|
|
|
|
lastMessage: input.message,
|
|
|
|
|
senderName: userInfos.sender_nome,
|
|
|
|
|
};
|
|
|
|
|
await kdb.publish(SIDEBAR_CHANNEL, JSON.stringify(sidebarPayload));
|
2025-08-28 18:27:07 +02:00
|
|
|
}
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2026-04-22 19:17:54 +02:00
|
|
|
return payload;
|
|
|
|
|
}),
|
|
|
|
|
deleteMessage: adminProcedure
|
|
|
|
|
.input(z.object({ chatId: zChatId, messageId: zMessageId }))
|
|
|
|
|
.mutation(async ({ input }) => {
|
|
|
|
|
try {
|
|
|
|
|
await db
|
|
|
|
|
.deleteFrom("messages")
|
|
|
|
|
.where("messageid", "=", input.messageId)
|
|
|
|
|
.execute();
|
|
|
|
|
|
|
|
|
|
const kdb = getKeydbClient();
|
|
|
|
|
if (kdb) {
|
|
|
|
|
await kdb.publish(
|
|
|
|
|
getEventsChannel(input.chatId),
|
|
|
|
|
JSON.stringify({ eventType: "delete", data: input.messageId }),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Failed to delete message:", err);
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
|
|
|
message: "Failed to delete message",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
editMessage: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({ chatId: zChatId, message: z.string(), messageId: zMessageId }),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ input }) => {
|
|
|
|
|
try {
|
|
|
|
|
await editMessage({
|
|
|
|
|
db,
|
|
|
|
|
message: input.message,
|
|
|
|
|
messageId: input.messageId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const kdb = getKeydbClient();
|
|
|
|
|
if (kdb) {
|
|
|
|
|
await kdb.publish(
|
|
|
|
|
getEventsChannel(input.chatId),
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
eventType: "edit",
|
|
|
|
|
data: { messageId: input.messageId, message: input.message },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Failed to edit message:", err);
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
|
|
|
message: "Failed to edit message",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
onMessageUpdates: publicProcedure
|
|
|
|
|
.input(z.object({ chatId: zChatId.nullable() }))
|
|
|
|
|
.output(
|
|
|
|
|
zAsyncIterable({
|
|
|
|
|
yield: MessageUpdateEventSchema,
|
|
|
|
|
tracked: false,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.subscription(async function* ({ input, signal }) {
|
|
|
|
|
if (!input.chatId) return;
|
|
|
|
|
const sub = getSubClient();
|
|
|
|
|
if (!sub) return; // Build-time safety
|
|
|
|
|
|
|
|
|
|
const channel = getEventsChannel(input.chatId);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await sub.subscribe(channel);
|
|
|
|
|
const iterable = on(sub, "message");
|
|
|
|
|
|
|
|
|
|
for await (const [chan, message] of iterable) {
|
|
|
|
|
if (chan === channel) {
|
|
|
|
|
if (signal?.aborted) break; // Extra safety check
|
|
|
|
|
const parsed = MessageUpdateEventSchema.safeParse(
|
|
|
|
|
JSON.parse(message),
|
|
|
|
|
);
|
|
|
|
|
if (parsed.success) {
|
|
|
|
|
yield parsed.data;
|
|
|
|
|
} else {
|
|
|
|
|
console.warn(
|
|
|
|
|
"Received invalid message update event:",
|
|
|
|
|
parsed.error,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
await sub.unsubscribe(channel);
|
|
|
|
|
await sub.quit();
|
|
|
|
|
}
|
2025-08-28 18:27:07 +02:00
|
|
|
}),
|
2026-04-22 19:17:54 +02:00
|
|
|
onSidebarUpdate: publicProcedure
|
|
|
|
|
.output(
|
|
|
|
|
zAsyncIterable({
|
|
|
|
|
yield: SidebarUpdateSchema,
|
|
|
|
|
tracked: false,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.subscription(async function* () {
|
|
|
|
|
const sub = getSubClient();
|
|
|
|
|
if (!sub) return;
|
|
|
|
|
try {
|
|
|
|
|
await sub.subscribe(SIDEBAR_CHANNEL);
|
|
|
|
|
const iterable = on(sub, "message");
|
|
|
|
|
for await (const [_chan, message] of iterable) {
|
|
|
|
|
const parsed = SidebarUpdateSchema.safeParse(JSON.parse(message));
|
|
|
|
|
if (parsed.success) {
|
|
|
|
|
yield parsed.data;
|
|
|
|
|
} else {
|
|
|
|
|
console.warn("Received invalid message event:", parsed.error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
await sub.unsubscribe(SIDEBAR_CHANNEL);
|
|
|
|
|
await sub.quit();
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
toggleTyping: publicProcedure
|
2025-08-28 18:27:07 +02:00
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
chatId: zChatId,
|
2026-04-22 19:17:54 +02:00
|
|
|
isTyping: z.boolean(),
|
|
|
|
|
userId: zUserId,
|
|
|
|
|
username: z.string(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ input }) => {
|
|
|
|
|
const kdb = getKeydbClient();
|
|
|
|
|
if (!kdb) return;
|
|
|
|
|
|
|
|
|
|
const key = getTypingKey(input.chatId);
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const member = JSON.stringify({
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
username: input.username,
|
|
|
|
|
});
|
|
|
|
|
if (input.isTyping) {
|
|
|
|
|
await kdb.zadd(key, now, member);
|
|
|
|
|
} else {
|
|
|
|
|
await kdb.zrem(key, member);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cleanup: Remove users who haven't pinged in 6 seconds
|
|
|
|
|
await kdb.zremrangebyscore(key, "-inf", now - 6000);
|
|
|
|
|
|
|
|
|
|
const typingList = await kdb.zrange(key, 0, -1);
|
|
|
|
|
|
|
|
|
|
await kdb.publish(
|
|
|
|
|
getTypingChannel(input.chatId),
|
|
|
|
|
JSON.stringify(typingList.map((m) => JSON.parse(m))),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
}),
|
|
|
|
|
onMessage: publicProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
chatId: zChatId.nullable(),
|
|
|
|
|
lastEventId: z.string().nullish(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.output(
|
|
|
|
|
zAsyncIterable({
|
|
|
|
|
yield: MessageDataSchema,
|
|
|
|
|
tracked: true,
|
2025-08-28 18:27:07 +02:00
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.subscription(async function* ({ input, signal }) {
|
2026-04-22 19:17:54 +02:00
|
|
|
if (!input.chatId) return;
|
|
|
|
|
const sub = getSubClient();
|
|
|
|
|
if (!sub) return; // Build-time safety
|
|
|
|
|
|
|
|
|
|
const channel = getChatChannel(input.chatId);
|
|
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
try {
|
2026-04-22 19:17:54 +02:00
|
|
|
// ON CONNECT
|
|
|
|
|
await sub.subscribe(channel);
|
|
|
|
|
const iterable = on(sub, "message");
|
|
|
|
|
|
|
|
|
|
let lastMessageId: MessagesMessageid | null =
|
|
|
|
|
(() => {
|
|
|
|
|
if (!input.lastEventId) return null;
|
|
|
|
|
|
|
|
|
|
const parsed = zMessageId.safeParse(input.lastEventId);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return parsed.data;
|
|
|
|
|
})() ?? null;
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2026-04-22 19:17:54 +02:00
|
|
|
const newMessagesSinceLast = await db
|
|
|
|
|
.selectFrom("messages")
|
|
|
|
|
.innerJoin("users", "users.id", "messages.sender")
|
|
|
|
|
.select([
|
|
|
|
|
"messages.message",
|
|
|
|
|
"messages.messageid",
|
|
|
|
|
"messages.chatid",
|
|
|
|
|
(eb) =>
|
|
|
|
|
eb
|
|
|
|
|
.cast<string>(
|
|
|
|
|
eb.fn("to_char", [
|
|
|
|
|
"messages.time",
|
|
|
|
|
eb.val("YYYY-MM-DD HH24:MI:SS"),
|
|
|
|
|
]),
|
|
|
|
|
"text",
|
|
|
|
|
)
|
|
|
|
|
.as("time"),
|
|
|
|
|
"messages.sender",
|
|
|
|
|
"messages.isread",
|
|
|
|
|
"users.nome as sender_nome",
|
|
|
|
|
"users.isAdmin as sender_isAdmin",
|
|
|
|
|
])
|
|
|
|
|
.where("messages.chatid", "=", input.chatId)
|
|
|
|
|
.where("messages.messageid", ">", lastMessageId)
|
|
|
|
|
.orderBy("messages.messageid", "asc")
|
|
|
|
|
.execute();
|
|
|
|
|
|
|
|
|
|
function* maybeYield(msg: Message) {
|
|
|
|
|
if (msg.chatid !== input.chatId) {
|
|
|
|
|
// ignore posts from other channels - the event emitter can emit from other channels
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (lastMessageId && msg.messageid <= lastMessageId) {
|
|
|
|
|
// ignore posts that we've already sent - happens if there is a race condition between the query and the event emitter
|
|
|
|
|
return;
|
2025-08-28 18:27:07 +02:00
|
|
|
}
|
2026-04-22 19:17:54 +02:00
|
|
|
|
|
|
|
|
yield tracked(msg.messageid, msg);
|
|
|
|
|
|
|
|
|
|
// update the cursor so that we don't send this post again
|
|
|
|
|
lastMessageId = msg.messageid;
|
2025-08-28 18:27:07 +02:00
|
|
|
}
|
2026-04-22 19:17:54 +02:00
|
|
|
for (const msg of newMessagesSinceLast) {
|
|
|
|
|
yield* maybeYield(msg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for await (const [chan, message] of iterable) {
|
|
|
|
|
if (chan === channel) {
|
|
|
|
|
if (signal?.aborted) break; // Extra safety check
|
|
|
|
|
const parsed = MessageDataSchema.safeParse(JSON.parse(message));
|
|
|
|
|
if (parsed.success) {
|
|
|
|
|
yield* maybeYield(parsed.data);
|
|
|
|
|
} else {
|
|
|
|
|
console.warn("Received invalid chat event:", parsed.error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
await sub.unsubscribe(channel);
|
|
|
|
|
await sub.quit();
|
2025-08-28 18:27:07 +02:00
|
|
|
}
|
|
|
|
|
}),
|
2026-04-22 19:17:54 +02:00
|
|
|
onTyping: publicProcedure
|
2025-08-29 16:18:32 +02:00
|
|
|
.input(
|
2026-04-22 19:17:54 +02:00
|
|
|
z.object({
|
|
|
|
|
chatId: zChatId.nullable(),
|
|
|
|
|
username: z.string(),
|
|
|
|
|
userId: zUserId,
|
|
|
|
|
}),
|
2025-08-29 16:18:32 +02:00
|
|
|
)
|
2026-04-22 19:17:54 +02:00
|
|
|
.output(
|
|
|
|
|
zAsyncIterable({
|
|
|
|
|
yield: TypingDataSchema,
|
|
|
|
|
tracked: false,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.subscription(async function* ({ input, signal }) {
|
|
|
|
|
if (!input.chatId) return;
|
|
|
|
|
const sub = getSubClient();
|
|
|
|
|
const kdb = getKeydbClient();
|
|
|
|
|
if (!sub || !kdb) return; // Build-time safety
|
|
|
|
|
|
|
|
|
|
const channel = getTypingChannel(input.chatId);
|
|
|
|
|
const typingKey = getTypingKey(input.chatId);
|
|
|
|
|
|
2025-08-29 16:18:32 +02:00
|
|
|
try {
|
2026-04-22 19:17:54 +02:00
|
|
|
// ON CONNECT
|
|
|
|
|
await sub.subscribe(channel);
|
|
|
|
|
const iterable = on(sub, "message");
|
|
|
|
|
|
|
|
|
|
const current = await kdb.zrange(typingKey, 0, -1);
|
|
|
|
|
|
|
|
|
|
const parsed = TypingDataSchema.safeParse(
|
|
|
|
|
current.map((m) => JSON.parse(m)),
|
|
|
|
|
);
|
|
|
|
|
if (parsed.success) {
|
|
|
|
|
yield parsed.data;
|
2025-08-29 16:18:32 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 19:17:54 +02:00
|
|
|
for await (const [chan, message] of iterable) {
|
|
|
|
|
if (chan === channel) {
|
|
|
|
|
if (signal?.aborted) break; // Extra safety check
|
|
|
|
|
const parsed = TypingDataSchema.safeParse(JSON.parse(message));
|
|
|
|
|
if (parsed.success) {
|
|
|
|
|
yield parsed.data;
|
|
|
|
|
} else {
|
|
|
|
|
console.warn("Received invalid chat event:", parsed.error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
// ON DISCONNECT (Tab closed, refreshed, or network dropped)
|
|
|
|
|
const member = JSON.stringify({
|
|
|
|
|
userId: input.userId,
|
|
|
|
|
username: input.username,
|
2025-08-29 16:18:32 +02:00
|
|
|
});
|
2026-04-22 19:17:54 +02:00
|
|
|
await kdb.zrem(typingKey, member);
|
|
|
|
|
const remainingT = await kdb.zrange(typingKey, 0, -1);
|
|
|
|
|
await kdb.publish(
|
|
|
|
|
channel,
|
|
|
|
|
JSON.stringify(remainingT.map((m) => JSON.parse(m))),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await sub.unsubscribe(channel);
|
|
|
|
|
await sub.quit();
|
2025-08-29 16:18:32 +02:00
|
|
|
}
|
|
|
|
|
}),
|
2026-04-22 19:17:54 +02:00
|
|
|
setRead: protectedProcedure
|
|
|
|
|
.input(z.object({ chatId: zChatId }))
|
|
|
|
|
.mutation(async ({ input, ctx }) => {
|
|
|
|
|
const didUpdate = await setReadMessage({
|
|
|
|
|
chatId: input.chatId,
|
|
|
|
|
db,
|
|
|
|
|
reader: ctx.session.id,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (didUpdate) {
|
|
|
|
|
const kdb = getKeydbClient();
|
|
|
|
|
if (kdb) {
|
|
|
|
|
await kdb.publish(
|
|
|
|
|
getEventsChannel(input.chatId),
|
|
|
|
|
JSON.stringify({ eventType: "read_receipt", data: null }),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}),
|
2025-08-04 17:45:44 +02:00
|
|
|
});
|