reroute chat
This commit is contained in:
@@ -115,7 +115,16 @@ class ChatStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// create
|
// create
|
||||||
if (!this.messages.some((m) => m.id === record.id)) {
|
// If this is our own optimistic temp (same clientId), replace it with
|
||||||
|
// the server-confirmed record instead of appending a duplicate.
|
||||||
|
const tempIdx = this.messages.findIndex(
|
||||||
|
(m) => m.clientId && record.clientId && m.clientId === record.clientId
|
||||||
|
);
|
||||||
|
if (tempIdx !== -1) {
|
||||||
|
this.messages = this.messages
|
||||||
|
.map((m, i) => (i === tempIdx ? record : m))
|
||||||
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
|
} else if (!this.messages.some((m) => m.id === record.id)) {
|
||||||
this.messages = [...this.messages, record].sort((a, b) =>
|
this.messages = [...this.messages, record].sort((a, b) =>
|
||||||
a.createdAt.localeCompare(b.createdAt)
|
a.createdAt.localeCompare(b.createdAt)
|
||||||
);
|
);
|
||||||
@@ -160,52 +169,42 @@ class ChatStore {
|
|||||||
this.typingTimers.delete(actorId);
|
this.typingTimers.delete(actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Writes via proxy ──
|
// ── Writes via SvelteKit server (forwards session/device auth) ──
|
||||||
|
|
||||||
private async chatFetch(method: string, path: string, body?: unknown) {
|
private async serverChat(payload: Record<string, unknown>) {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const res = await fetch('/chat', {
|
||||||
if (this.actorType === 'member' && this.deviceToken) {
|
method: 'POST',
|
||||||
headers['x-device-token'] = this.deviceToken;
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers['x-device-famid'] = this.famId;
|
body: JSON.stringify(payload)
|
||||||
}
|
|
||||||
const res = await fetch(path, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: body ? JSON.stringify(body) : undefined
|
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
|
if (!res.ok) throw new Error(data.error || 'chat request failed');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(content: string) {
|
async send(content: string) {
|
||||||
const text = content.trim();
|
const text = content.trim();
|
||||||
if (!text || !this.famId) return;
|
if (!text || !this.famId) return;
|
||||||
|
const clientId = crypto.randomUUID();
|
||||||
const temp: ChatMessage = {
|
const temp: ChatMessage = {
|
||||||
id: 'temp-' + Date.now(),
|
id: 'temp-' + clientId,
|
||||||
famId: this.famId,
|
famId: this.famId,
|
||||||
authorType: this.actorType,
|
authorType: this.actorType,
|
||||||
authorId: this.actorId,
|
authorId: this.actorId,
|
||||||
authorName: this.actorName,
|
authorName: this.actorName,
|
||||||
authorColor: this.actorColor,
|
authorColor: this.actorColor,
|
||||||
content: text,
|
content: text,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString(),
|
||||||
|
clientId
|
||||||
};
|
};
|
||||||
this.messages = [...this.messages, temp];
|
this.messages = [...this.messages, temp];
|
||||||
this.lastSeenAt = Date.now();
|
this.lastSeenAt = Date.now();
|
||||||
// Signal we stopped typing (message sent).
|
// Signal we stopped typing (message sent).
|
||||||
this.setTyping(false).catch(() => {});
|
this.setTyping(false).catch(() => {});
|
||||||
try {
|
try {
|
||||||
const saved = await this.chatFetch('POST', `/api/chat/${this.famId}/messages`, {
|
// Server writes to PB; the real record arrives via SSE and
|
||||||
content: text
|
// overwrites this optimistic temp (matched by clientId) in onMessage.
|
||||||
});
|
await this.serverChat({ action: 'send', famId: this.famId, content: text, clientId });
|
||||||
// SSE may have already delivered the real record via onMessage, so
|
|
||||||
// drop both the temp and any pre-existing copy of the saved id to
|
|
||||||
// avoid duplicate keys in the keyed each block.
|
|
||||||
this.messages = this.messages
|
|
||||||
.filter((m) => m.id !== temp.id && m.id !== saved.id)
|
|
||||||
.concat([{ ...saved, authorName: temp.authorName, authorColor: temp.authorColor }])
|
|
||||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.messages = this.messages.filter((m) => m.id !== temp.id);
|
this.messages = this.messages.filter((m) => m.id !== temp.id);
|
||||||
console.error('Chat send failed:', e);
|
console.error('Chat send failed:', e);
|
||||||
@@ -220,7 +219,7 @@ class ChatStore {
|
|||||||
if (typing && now - this.typingThrottle < 1500) return;
|
if (typing && now - this.typingThrottle < 1500) return;
|
||||||
this.typingThrottle = now;
|
this.typingThrottle = now;
|
||||||
try {
|
try {
|
||||||
await this.chatFetch('POST', `/api/chat/${this.famId}/typing`, { typing });
|
await this.serverChat({ action: 'typing', famId: this.famId, typing });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Chat typing failed:', e);
|
console.error('Chat typing failed:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export interface ChatMessage {
|
|||||||
authorColor?: string;
|
authorColor?: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
clientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TypingRow {
|
export interface TypingRow {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { PROXY_URL } from '$app/env/public';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const HONO_URL = PROXY_URL;
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
action: 'send' | 'typing';
|
||||||
|
famId?: string;
|
||||||
|
content?: string;
|
||||||
|
clientId?: string;
|
||||||
|
typing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const body = (await event.request.json().catch(() => null)) as Body | null;
|
||||||
|
if (!body || !body.action) return json({ error: 'missing action' }, 400);
|
||||||
|
|
||||||
|
const session = event.locals.session;
|
||||||
|
const deviceToken = event.cookies.get('device_token') || '';
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
let famId = body.famId || '';
|
||||||
|
|
||||||
|
if (session?.famId && session?.userId) {
|
||||||
|
// Admin (parent) — trust the verified session server-side.
|
||||||
|
headers['x-session-famid'] = session.famId;
|
||||||
|
headers['x-session-userid'] = session.userId;
|
||||||
|
famId = session.famId;
|
||||||
|
} else if (deviceToken) {
|
||||||
|
// Member (child) — forward the device token; the proxy re-validates.
|
||||||
|
headers['x-device-token'] = deviceToken;
|
||||||
|
headers['x-device-famid'] = famId;
|
||||||
|
} else {
|
||||||
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!famId) return json({ error: 'famId required' }, 400);
|
||||||
|
|
||||||
|
const path = body.action === 'typing' ? `/api/chat/${famId}/typing` : `/api/chat/${famId}/messages`;
|
||||||
|
const payload =
|
||||||
|
body.action === 'typing'
|
||||||
|
? { typing: Boolean(body.typing) }
|
||||||
|
: { content: body.content || '', clientId: body.clientId || '' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${HONO_URL}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) return json({ error: data.error || 'chat request failed' }, res.status);
|
||||||
|
return json(data);
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'chat request failed' }, 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-26
@@ -2346,31 +2346,6 @@ async function resolveChatActor(c: any) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cookieHeader = c.req.header("cookie") || "";
|
|
||||||
const cookieMatch = cookieHeader.match(/session=([^;]+)/);
|
|
||||||
if (cookieMatch) {
|
|
||||||
try {
|
|
||||||
const s = JSON.parse(decodeURIComponent(cookieMatch[1]));
|
|
||||||
if (s?.famId && s?.userId) {
|
|
||||||
const admins = await pb.getList(
|
|
||||||
"fam_admins",
|
|
||||||
`famId = '${s.famId}' && userId = '${s.userId}'`,
|
|
||||||
);
|
|
||||||
const admin = admins.items?.[0];
|
|
||||||
if (admin) {
|
|
||||||
return {
|
|
||||||
famId: s.famId,
|
|
||||||
actor: {
|
|
||||||
id: admin.id,
|
|
||||||
type: "admin",
|
|
||||||
name: admin.name,
|
|
||||||
color: admin.color,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
const famId = c.req.header("x-device-famid");
|
const famId = c.req.header("x-device-famid");
|
||||||
const deviceToken = c.req.header("x-device-token");
|
const deviceToken = c.req.header("x-device-token");
|
||||||
if (famId && deviceToken) {
|
if (famId && deviceToken) {
|
||||||
@@ -2399,7 +2374,7 @@ app.post("/api/chat/:famId/messages", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const auth = await resolveChatActor(c);
|
const auth = await resolveChatActor(c);
|
||||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
const { content } = await c.req.json();
|
const { content, clientId } = await c.req.json();
|
||||||
if (!content || !content.trim()) {
|
if (!content || !content.trim()) {
|
||||||
return c.json({ error: "content required" }, 400);
|
return c.json({ error: "content required" }, 400);
|
||||||
}
|
}
|
||||||
@@ -2411,6 +2386,7 @@ app.post("/api/chat/:famId/messages", async (c) => {
|
|||||||
authorColor: auth.actor.color,
|
authorColor: auth.actor.color,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
clientId: clientId ? String(clientId).slice(0, 64) : "",
|
||||||
});
|
});
|
||||||
return c.json(record);
|
return c.json(record);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+13
-7
@@ -1451,6 +1451,7 @@ export async function migrate(): Promise<void> {
|
|||||||
{ name: "authorColor", type: "text", required: false },
|
{ name: "authorColor", type: "text", required: false },
|
||||||
{ name: "content", type: "text", required: true },
|
{ name: "content", type: "text", required: true },
|
||||||
{ name: "createdAt", type: "date", required: false },
|
{ name: "createdAt", type: "date", required: false },
|
||||||
|
{ name: "clientId", type: "text", required: false, max: 64 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -1459,6 +1460,7 @@ export async function migrate(): Promise<void> {
|
|||||||
// older stores may predate it. Chat filters/sorts on createdAt, so
|
// older stores may predate it. Chat filters/sorts on createdAt, so
|
||||||
// ensure the field exists even if the collection was created without it.
|
// ensure the field exists even if the collection was created without it.
|
||||||
const msgsFields = msgsCol.fields?.map((f: any) => f.name) || [];
|
const msgsFields = msgsCol.fields?.map((f: any) => f.name) || [];
|
||||||
|
let msgsChanged = false;
|
||||||
if (!msgsFields.includes("createdAt")) {
|
if (!msgsFields.includes("createdAt")) {
|
||||||
console.log(" ↳ adding missing createdAt field to messages...");
|
console.log(" ↳ adding missing createdAt field to messages...");
|
||||||
msgsCol.fields.push({
|
msgsCol.fields.push({
|
||||||
@@ -1468,14 +1470,18 @@ export async function migrate(): Promise<void> {
|
|||||||
min: "",
|
min: "",
|
||||||
max: "",
|
max: "",
|
||||||
});
|
});
|
||||||
await updateCollection("messages", {
|
msgsChanged = true;
|
||||||
listRule: "",
|
|
||||||
viewRule: "",
|
|
||||||
fields: msgsCol.fields,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await updateCollection("messages", { listRule: "", viewRule: "" });
|
|
||||||
}
|
}
|
||||||
|
if (!msgsFields.includes("clientId")) {
|
||||||
|
console.log(" ↳ adding missing clientId field to messages...");
|
||||||
|
msgsCol.fields.push({ name: "clientId", type: "text", required: false, max: 64 });
|
||||||
|
msgsChanged = true;
|
||||||
|
}
|
||||||
|
await updateCollection("messages", {
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
fields: msgsCol.fields,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9b. chat_typing (transient presence rows, one per actor)
|
// 9b. chat_typing (transient presence rows, one per actor)
|
||||||
|
|||||||
Reference in New Issue
Block a user