Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions FOLLOW_UPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@

The first bot pass adds scripted server-authoritative room occupants with movement and chat broadcasts. Before enabling AI-backed conversations, add provider configuration, request timeouts, moderation and rate-limit boundaries, fallback canned responses, and tests that prove rooms continue to work when the provider is unavailable.

## Direct Friend Messaging
## Direct Friend Messaging — implemented

The first friends pass supports persisted friendships, online/offline presence, avatar display, removal, and joining a friend's current room. Before adding a message action, define whether Tilezo wants direct messages or room-scoped whispers, then add privacy rules, persistence expectations, rate limits, and client/server tests.
Tilezo ships **friend-gated direct messages** (not room-scoped whispers): a `dm.send`
WebSocket message routes through `DirectMessageService`, which checks that the two users
are mutual friends, persists the message (`direct_messages` table), and publishes a
`dm.message` to the recipient's and sender's per-user topics (`user:<id>`) for realtime
delivery to every open tab. History is available via `GET /friends/:id/messages` and is
also friend-gated. Sends are rate-limited (`dm` token bucket) and the text is sanitized and
length-bounded by the protocol schema. Covered by protocol/service/store/router unit tests,
a real-Postgres integration test, and a two-user browser test.

Not yet built: read receipts/unread badges, typing indicators in DMs, message deletion or
editing, and blocking. A blocked-users list would gate `DirectMessageService.send` the same
way the friendship check does today.
35 changes: 35 additions & 0 deletions apps/client/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import {
logout as requestLogout,
updateAppearance,
} from "../auth/AuthClient";
import type { FriendSummary } from "../friends/FriendClient";
import { addFriend, listFriends, removeFriend } from "../friends/FriendClient";
import type { Game } from "../game/Game";
import { loadConversation } from "../messaging/DirectMessageClient";
import { createRoom, listRoomTemplates } from "../rooms/RoomClient";
import { ClientLogger } from "../telemetry/ClientLogger";
import { CharacterEditor } from "../ui/CharacterEditor";
import { ChatPanel } from "../ui/ChatPanel";
import { CreateRoomDialog } from "../ui/CreateRoomDialog";
import { DirectMessagePanel } from "../ui/DirectMessagePanel";
import { DisconnectedDialog } from "../ui/DisconnectedDialog";
import { FriendsPanel } from "../ui/FriendsPanel";
import { LoginForm } from "../ui/LoginForm";
Expand Down Expand Up @@ -109,6 +112,9 @@ export function createApp(root: HTMLElement): void {
status.textContent = "joining friend";
game?.joinRoom(roomId);
},
onMessage(friend) {
void openConversation(friend);
},
onRefresh() {
void refreshFriends();
},
Expand Down Expand Up @@ -152,6 +158,11 @@ export function createApp(root: HTMLElement): void {
roomBrowser.setCurrentRoom(snapshot.roomId);
roomBrowser.hide();
},
onDirectMessage(message) {
if (!directMessagePanel.append(message) && message.fromUserId !== user?.id) {
status.textContent = "new direct message";
}
},
onDisconnected() {
if (!user || !gameStarted) {
return;
Expand All @@ -165,6 +176,12 @@ export function createApp(root: HTMLElement): void {
return game;
}

const directMessagePanel = new DirectMessagePanel({
onSend(friendId, text) {
game?.sendDirectMessage(friendId, text);
},
});

const disconnectedDialog = new DisconnectedDialog({
onRetry() {
void reconnectAfterDisconnect("resume");
Expand Down Expand Up @@ -321,6 +338,7 @@ export function createApp(root: HTMLElement): void {
createRoomDialog.element,
roomBrowser.element,
friendsPanel.element,
directMessagePanel.element,
chat.element,
disconnectedDialog.element,
);
Expand Down Expand Up @@ -432,6 +450,23 @@ export function createApp(root: HTMLElement): void {
}
}

async function openConversation(friend: FriendSummary): Promise<void> {
if (!user) {
return;
}

status.textContent = "loading messages";

try {
const history = await loadConversation(friend.id);
directMessagePanel.open(friend, history, user.id);
friendsPanel.hide();
status.textContent = `messaging ${friend.username}`;
} catch (error) {
status.textContent = error instanceof Error ? error.message : "Could not open messages";
}
}

async function refreshFriends(): Promise<void> {
if (!user) {
return;
Expand Down
10 changes: 10 additions & 0 deletions apps/client/src/game/Game.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AvatarAppearance } from "@tilezo/protocol/appearance";
import type {
ClientMessage,
DirectMessage,
PublicRoomSummary,
RoomSnapshotMessage,
} from "@tilezo/protocol/messages";
Expand All @@ -15,6 +16,7 @@ type GameOptions = {
setStatus: (status: string) => void;
setRooms: (rooms: PublicRoomSummary[]) => void;
onRoomJoined: (snapshot: RoomSnapshotMessage) => void;
onDirectMessage: (message: DirectMessage) => void;
onDisconnected: () => void;
};

Expand Down Expand Up @@ -74,6 +76,10 @@ export class Game {
this.options.chat.addMessage(message.username, message.text, message.sentAt);
}

if (message.type === "dm.message") {
this.options.onDirectMessage(message);
}

if (message.type === "error") {
this.options.setStatus(`${message.code}: ${message.message}`);
}
Expand Down Expand Up @@ -126,6 +132,10 @@ export class Game {
this.sendIfConnected({ type: "avatar.appearance.update", appearance });
}

sendDirectMessage(toUserId: string, text: string): boolean {
return this.sendIfConnected({ type: "dm.send", toUserId, text });
}

async reconnect(): Promise<void> {
// Drop stale avatars before reconnecting so the player does not see a frozen copy of
// the previous room while the server re-sends a snapshot (or, in edge cases where no
Expand Down
49 changes: 49 additions & 0 deletions apps/client/src/messaging/DirectMessageClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { DirectMessage } from "@tilezo/protocol/messages";
import { DEFAULT_API_URL } from "../assets";
import { loadConversation } from "./DirectMessageClient";

const originalFetch = globalThis.fetch;
type FetchArgs = Parameters<typeof fetch>;

describe("loadConversation", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});

test("loads conversation history with the session cookie", async () => {
const messages: DirectMessage[] = [
{
type: "dm.message",
id: "dm_1",
fromUserId: "user_1",
toUserId: "user_2",
text: "hi",
sentAt: "2026-06-13T00:00:00.000Z",
},
];
const requests: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (async (url: FetchArgs[0], init?: FetchArgs[1]) => {
requests.push({ url: String(url), init });
return Response.json({ messages });
}) as unknown as typeof fetch;

await expect(loadConversation("user_2")).resolves.toEqual(messages);
expect(requests).toEqual([
{
url: `${DEFAULT_API_URL}/friends/user_2/messages`,
init: { credentials: "include" },
},
]);
});

test("throws the server error message on failure", async () => {
globalThis.fetch = (async () =>
Response.json(
{ error: { message: "You can only message your friends" } },
{ status: 400 },
)) as unknown as typeof fetch;

await expect(loadConversation("user_2")).rejects.toThrow("only message your friends");
});
});
36 changes: 36 additions & 0 deletions apps/client/src/messaging/DirectMessageClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { DirectMessage } from "@tilezo/protocol/messages";
import { DEFAULT_API_URL } from "../assets";

export type { DirectMessage };

export async function loadConversation(friendId: string): Promise<DirectMessage[]> {
const response = await fetch(`${getApiUrl()}/friends/${encodeURIComponent(friendId)}/messages`, {
credentials: "include",
});
const body = await readJson<{ messages?: DirectMessage[] } | { error?: { message?: string } }>(
response,
);

if (!response.ok) {
throw new Error(body && "error" in body ? body.error?.message : "Could not load messages");
}

return Array.isArray((body as { messages?: unknown }).messages)
? (body as { messages: DirectMessage[] }).messages
: [];
}

function getApiUrl(): string {
const runtimeConfigured =
typeof window === "undefined" ? undefined : window.TILEZO_CONFIG?.PUBLIC_API_URL;
const buildConfigured = typeof process === "undefined" ? undefined : process.env.PUBLIC_API_URL;
return runtimeConfigured ?? buildConfigured ?? DEFAULT_API_URL;
}

async function readJson<T>(response: Response): Promise<T | undefined> {
try {
return (await response.json()) as T;
} catch {
return undefined;
}
}
60 changes: 60 additions & 0 deletions apps/client/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,66 @@ select {
right: 20px;
}

.dm-panel {
position: absolute;
top: 86px;
right: 20px;
z-index: 6;
display: grid;
width: min(380px, calc(100vw - 40px));
max-height: calc(100vh - 118px);
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
border: 2px solid #1d2324;
border-radius: 8px;
background: #eeeade;
box-shadow:
inset 0 0 0 2px #ffffff,
7px 7px 0 rgba(31, 45, 47, 0.32);
pointer-events: auto;
}

.dm-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
overflow-y: auto;
}

.dm-message {
max-width: 78%;
padding: 6px 10px;
border-radius: 10px;
font-size: 13px;
line-height: 1.35;
word-break: break-word;
}

.dm-message-theirs {
align-self: flex-start;
background: #d7d2c4;
color: #1f2d2f;
}

.dm-message-mine {
align-self: flex-end;
background: #2f6f5f;
color: #fafaf5;
}

.dm-form {
display: flex;
gap: 8px;
padding: 12px;
border-top: 2px solid #d2ccbd;
}

.dm-form input {
flex: 1;
min-width: 0;
}

.room-browser-header {
display: flex;
align-items: center;
Expand Down
Loading
Loading