Skip to content

Commit 4a7fccd

Browse files
committed
feat: update chat data contracts
1 parent 87d5af3 commit 4a7fccd

5 files changed

Lines changed: 208 additions & 55 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"test": "playwright test",
1616
"format": "biome check --write",
1717
"check": "run-p 'check:*'",
18+
"check:schema-contract": "bun test ./scripts/schema_contract.test.ts",
1819
"check:tsc": "tsc",
1920
"check:eslint": "eslint src",
2021
"check:script": "uv run basedpyright scripts",

schema.prisma

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,41 @@ model user {
1818
}
1919

2020
model friend {
21-
id String @id
2221
owner_id String
22+
id String
2323
name String
2424
avatar Bytes?
2525
bio String
26+
27+
@@id([owner_id, id])
2628
}
2729

2830
model chat_message {
29-
timestamp DateTime @id @default(now())
30-
owner_id String
31-
chat_user_id String
32-
content String
31+
id String @id
32+
owner_id String
33+
chat_user_id String
34+
sender_id String
35+
content String
36+
status String @default("sent")
37+
created_at DateTime @default(now())
38+
sort_at BigInt
39+
retry_count Int @default(0)
40+
error_message String?
41+
42+
@@index([owner_id, chat_user_id, sort_at])
43+
@@index([owner_id, status])
44+
}
45+
46+
model friend_request {
47+
id String @id
48+
owner_id String
49+
remote_id String
50+
direction String
51+
status String @default("pending")
52+
created_at DateTime @default(now())
53+
sort_at BigInt
54+
error_message String?
55+
56+
@@unique([owner_id, remote_id, direction])
57+
@@index([owner_id, status, sort_at])
3358
}

scripts/schema_contract.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { readFileSync } from "node:fs";
3+
4+
const schema = readFileSync("schema.prisma", "utf-8");
5+
const endpointTypes = readFileSync("src/lib/endpoint/types.ts", "utf-8");
6+
7+
const modelBody = (name: string) => {
8+
const match = schema.match(new RegExp(`model ${name} \\{([\\s\\S]*?)\\n\\}`));
9+
if (!match) {
10+
throw new Error(`model ${name} was not found`);
11+
}
12+
return match[1];
13+
};
14+
15+
describe("database schema contract", () => {
16+
test("friend rows are scoped by local owner and remote id", () => {
17+
const friend = modelBody("friend");
18+
19+
expect(friend).not.toContain("id String @id");
20+
expect(friend).toContain("@@id([owner_id, id])");
21+
});
22+
23+
test("chat messages include stable ids, delivery state, and retry metadata", () => {
24+
const chatMessage = modelBody("chat_message");
25+
26+
expect(chatMessage).toContain("id");
27+
expect(chatMessage).toContain("@id");
28+
expect(chatMessage).toContain("sender_id");
29+
expect(chatMessage).toContain("status");
30+
expect(chatMessage).toContain("created_at");
31+
expect(chatMessage).toContain("sort_at");
32+
expect(chatMessage).toContain("retry_count");
33+
expect(chatMessage).toContain("error_message");
34+
});
35+
36+
test("friend requests are persisted for inbound and outbound flows", () => {
37+
const friendRequest = modelBody("friend_request");
38+
39+
expect(friendRequest).toContain("owner_id");
40+
expect(friendRequest).toContain("remote_id");
41+
expect(friendRequest).toContain("direction");
42+
expect(friendRequest).toContain("status");
43+
expect(friendRequest).toContain("created_at");
44+
expect(friendRequest).toContain(
45+
"@@unique([owner_id, remote_id, direction])",
46+
);
47+
});
48+
});
49+
50+
describe("TypeScript endpoint contract", () => {
51+
test("messages expose id, delivery status, sortable time, and retry metadata", () => {
52+
expect(endpointTypes).toContain("export type MessageStatus");
53+
expect(endpointTypes).toContain("id: string");
54+
expect(endpointTypes).toContain("owner_id: string");
55+
expect(endpointTypes).toContain("chat_user_id: string");
56+
expect(endpointTypes).toContain("sender_id: string");
57+
expect(endpointTypes).toContain("status: MessageStatus");
58+
expect(endpointTypes).toContain("sort_at: number");
59+
expect(endpointTypes).toContain("retry_count: number");
60+
expect(endpointTypes).toContain("error_message?: string | null");
61+
});
62+
63+
test("friend requests have shared status and direction types", () => {
64+
expect(endpointTypes).toContain("export type FriendRequestStatus");
65+
expect(endpointTypes).toContain("export type FriendRequestDirection");
66+
expect(endpointTypes).toContain("export type FriendRequest =");
67+
});
68+
});

src/components/ui/chat_bar.tsx

Lines changed: 80 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ import { QueryBuilder } from "~/lib/query_builder";
88
import { MainContext, ShellContext, use_context } from "../context";
99
import Image from "../widgets/image";
1010

11+
const messageStatusText = (message: Message | undefined) => {
12+
if (message?.status === "sending") return "发送中";
13+
if (message?.status === "failed") return message.error_message ?? "发送失败";
14+
return "已发送";
15+
};
16+
17+
const messageTimeText = (message: Message | undefined) => {
18+
if (!message) return "";
19+
return new Date(message.created_at).toLocaleTimeString([], {
20+
hour: "2-digit",
21+
minute: "2-digit",
22+
});
23+
};
24+
1125
export default function ChatBar(props: { chat_user: User }) {
1226
const shell_store = use_context(ShellContext);
1327
const main_store = use_context(MainContext);
@@ -17,9 +31,21 @@ export default function ChatBar(props: { chat_user: User }) {
1731
if (!u) throw new Error("用户不存在");
1832
return await main_store.sqlite.query<Message>(
1933
QueryBuilder.selectFrom("chat_message")
20-
.select(["chat_user_id", "timestamp", "content"])
34+
.select([
35+
"id",
36+
"owner_id",
37+
"chat_user_id",
38+
"sender_id",
39+
"content",
40+
"status",
41+
"created_at",
42+
"sort_at",
43+
"retry_count",
44+
"error_message",
45+
])
2146
.where("owner_id", "=", u.id)
2247
.where("chat_user_id", "=", props.chat_user.id)
48+
.orderBy("sort_at", "asc")
2349
.compile(),
2450
);
2551
});
@@ -70,56 +96,61 @@ export default function ChatBar(props: { chat_user: User }) {
7096
}}
7197
>
7298
<For each={message_list_virtualizer.getVirtualItems()}>
73-
{(v) => (
74-
<div
75-
class={twMerge(
76-
"chat p-0",
77-
messages()?.at(v.index)?.sender_id === props.chat_user.id
78-
? "chat-start"
79-
: "chat-end",
80-
)}
81-
>
82-
<div class="chat-image avatar">
83-
<Show
84-
keyed
85-
when={
86-
messages()?.at(v.index)?.sender_id ===
87-
props.chat_user.id
88-
? props.chat_user.avatar
89-
: user()?.avatar
90-
}
91-
fallback={
92-
<UserIcon class="size-10 rounded-full bg-base-300" />
93-
}
94-
>
95-
{(avatar) => (
96-
<Image class="size-10 rounded-full" image={avatar} />
97-
)}
98-
</Show>
99-
</div>
100-
<div class="chat-header items-center">
101-
<span
102-
class={twMerge(
103-
"text-sm text-base-content font-bold",
104-
messages()?.at(v.index)?.sender_id ===
105-
props.chat_user.id
106-
? undefined
107-
: "order-1",
108-
)}
109-
>
110-
{messages()?.at(v.index)?.sender_id ===
111-
props.chat_user.id
112-
? props.chat_user.name
113-
: user()?.name}
99+
{(v) => {
100+
const message = () => messages()?.at(v.index);
101+
const is_remote_message = () =>
102+
message()?.sender_id === props.chat_user.id;
103+
return (
104+
<div
105+
class={twMerge(
106+
"chat p-0",
107+
is_remote_message() ? "chat-start" : "chat-end",
108+
)}
109+
>
110+
<div class="chat-image avatar">
111+
<Show
112+
keyed
113+
when={
114+
is_remote_message()
115+
? props.chat_user.avatar
116+
: user()?.avatar
117+
}
118+
fallback={
119+
<UserIcon class="size-10 rounded-full bg-base-300" />
120+
}
121+
>
122+
{(avatar) => (
123+
<Image
124+
class="size-10 rounded-full"
125+
image={avatar}
126+
/>
127+
)}
128+
</Show>
129+
</div>
130+
<div class="chat-header items-center">
131+
<span
132+
class={twMerge(
133+
"text-sm text-base-content font-bold",
134+
is_remote_message() ? undefined : "order-1",
135+
)}
136+
>
137+
{is_remote_message()
138+
? props.chat_user.name
139+
: user()?.name}
140+
</span>
141+
<time class="text-base-content/60">
142+
{messageTimeText(message())}
143+
</time>
144+
</div>
145+
<span class="border rounded-box bg-base-100 border-neutral-200 p-2">
146+
{message()?.content}
114147
</span>
115-
<time class="text-base-content/60">12:45</time>
148+
<div class="chat-footer text-base-content/60">
149+
{messageStatusText(message())}
150+
</div>
116151
</div>
117-
<span class="border rounded-box bg-base-100 border-neutral-200 p-2">
118-
{messages()?.at(v.index)?.content}
119-
</span>
120-
<div class="chat-footer text-base-content/60">已读</div>
121-
</div>
122-
)}
152+
);
153+
}}
123154
</For>
124155
</div>
125156
</div>

src/lib/endpoint/types.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,36 @@ export type RelayConfig = {
1313
quic_port: number;
1414
};
1515

16+
export type MessageStatus = "sending" | "sent" | "failed";
17+
1618
export type Message = {
19+
id: string;
20+
owner_id: string;
21+
chat_user_id: string;
1722
sender_id: string;
18-
timestamp: number;
1923
content: string;
24+
status: MessageStatus;
25+
created_at: string;
26+
sort_at: number;
27+
retry_count: number;
28+
error_message?: string | null;
29+
};
30+
31+
export type FriendRequestStatus =
32+
| "pending"
33+
| "accepted"
34+
| "rejected"
35+
| "failed";
36+
37+
export type FriendRequestDirection = "incoming" | "outgoing";
38+
39+
export type FriendRequest = {
40+
id: string;
41+
owner_id: string;
42+
remote_id: string;
43+
direction: FriendRequestDirection;
44+
status: FriendRequestStatus;
45+
created_at: string;
46+
sort_at: number;
47+
error_message?: string | null;
2048
};

0 commit comments

Comments
 (0)