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
99 changes: 96 additions & 3 deletions apps/client/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import {
logout as requestLogout,
updateAppearance,
} from "../auth/AuthClient";
import { blockUser } from "../blocks/BlockClient";
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 { loadConversation, loadUnreadCounts } from "../messaging/DirectMessageClient";
import { createRoom, listRoomTemplates } from "../rooms/RoomClient";
import { ClientLogger } from "../telemetry/ClientLogger";
import { CharacterEditor } from "../ui/CharacterEditor";
Expand Down Expand Up @@ -46,6 +47,7 @@ export function createApp(root: HTMLElement): void {
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
let countdownInterval: ReturnType<typeof setInterval> | undefined;
let reconnecting = false;
const unreadCounts = new Map<string, number>();

shell.className = "app-shell";
stage.className = "game-stage";
Expand Down Expand Up @@ -133,6 +135,26 @@ export function createApp(root: HTMLElement): void {
friendsPanel.showError(message);
}
},
async onBlock(friend) {
if (!user) {
return;
}

try {
await blockUser(friend.id);
await refreshFriends();

if (directMessagePanel.isOpenFor(friend.id)) {
directMessagePanel.hide();
}

status.textContent = `blocked ${friend.username}`;
} catch (error) {
const message = error instanceof Error ? error.message : "Block failed";
status.textContent = message;
friendsPanel.showError(message);
}
},
});

async function ensureGame(): Promise<Game> {
Expand All @@ -159,10 +181,38 @@ export function createApp(root: HTMLElement): void {
roomBrowser.hide();
},
onDirectMessage(message) {
if (!directMessagePanel.append(message) && message.fromUserId !== user?.id) {
const appended = directMessagePanel.append(message);

if (appended) {
if (message.fromUserId !== user?.id) {
clearUnread(message.fromUserId);
}
return;
}

if (message.fromUserId !== user?.id) {
incrementUnread(message.fromUserId);
status.textContent = "new direct message";
}
},
onDirectTyping(message) {
directMessagePanel.setFriendTyping(message.fromUserId, message.isTyping);
},
onDirectRead(message) {
if (message.readerUserId === user?.id) {
clearUnread(message.otherUserId);
} else {
directMessagePanel.markRead(message.messageIds);
}
},
onDirectEdited(message) {
directMessagePanel.updateEdited(message);
},
onDirectDeleted(message) {
if (!directMessagePanel.markDeleted(message.id) && message.fromUserId !== user?.id) {
decrementUnread(message.fromUserId);
}
},
onDisconnected() {
if (!user || !gameStarted) {
return;
Expand Down Expand Up @@ -197,6 +247,19 @@ export function createApp(root: HTMLElement): void {
onSend(friendId, text) {
return game?.sendDirectMessage(friendId, text) ?? false;
},
onTypingChange(friendId, isTyping) {
game?.sendDirectTyping(friendId, isTyping);
},
onRead(friendId) {
game?.markDirectMessagesRead(friendId);
clearUnread(friendId);
},
onEdit(messageId, text) {
return game?.editDirectMessage(messageId, text) ?? false;
},
onDelete(messageId) {
return game?.deleteDirectMessage(messageId) ?? false;
},
});

const disconnectedDialog = new DisconnectedDialog({
Expand Down Expand Up @@ -496,14 +559,44 @@ export function createApp(root: HTMLElement): void {
}

try {
friendsPanel.setFriends(await listFriends());
const [friends, unread] = await Promise.all([listFriends(), loadUnreadCounts()]);
unreadCounts.clear();

for (const item of unread) {
unreadCounts.set(item.friendId, item.count);
}

friendsPanel.setFriends(friends, unreadCounts);
} catch (error) {
const message = error instanceof Error ? error.message : "Friends failed";
status.textContent = message;
friendsPanel.showError(message);
}
}

function incrementUnread(friendId: string): void {
const next = (unreadCounts.get(friendId) ?? 0) + 1;
unreadCounts.set(friendId, next);
friendsPanel.setUnreadCount(friendId, next);
}

function clearUnread(friendId: string): void {
unreadCounts.delete(friendId);
friendsPanel.setUnreadCount(friendId, 0);
}

function decrementUnread(friendId: string): void {
const next = Math.max(0, (unreadCounts.get(friendId) ?? 0) - 1);

if (next === 0) {
clearUnread(friendId);
return;
}

unreadCounts.set(friendId, next);
friendsPanel.setUnreadCount(friendId, next);
}

function clearReconnectSchedule(): void {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
Expand Down
56 changes: 56 additions & 0 deletions apps/client/src/blocks/BlockClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, test } from "bun:test";
import { DEFAULT_AVATAR_APPEARANCE } from "@tilezo/protocol/appearance";
import { blockUser, listBlockedUsers, unblockUser } from "./BlockClient";

const originalFetch = globalThis.fetch;

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

test("lists blocked users", async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
blockedUsers: [
{
id: "user_2",
username: "Kai",
appearance: DEFAULT_AVATAR_APPEARANCE,
blockedAt: "2026-06-13T00:00:00.000Z",
},
],
}),
)) as unknown as typeof fetch;

await expect(listBlockedUsers()).resolves.toEqual([
{
id: "user_2",
username: "Kai",
appearance: DEFAULT_AVATAR_APPEARANCE,
blockedAt: "2026-06-13T00:00:00.000Z",
},
]);
});

test("blocks and unblocks users", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (async (
url: Parameters<typeof fetch>[0],
init: Parameters<typeof fetch>[1],
) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ ok: true }));
}) as unknown as typeof fetch;

await blockUser("user_2");
await unblockUser("user_2");

expect(calls.map((call) => [call.url, call.init?.method])).toEqual([
["http://localhost:3000/blocked-users", "POST"],
["http://localhost:3000/blocked-users/user_2", "DELETE"],
]);
expect(calls[0]?.init?.body).toBe(JSON.stringify({ userId: "user_2" }));
});
});
65 changes: 65 additions & 0 deletions apps/client/src/blocks/BlockClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { AvatarAppearance } from "@tilezo/protocol/appearance";
import { DEFAULT_API_URL } from "../assets";

export type BlockedUserSummary = {
id: string;
username: string;
appearance: AvatarAppearance;
blockedAt: string;
};

export async function listBlockedUsers(): Promise<BlockedUserSummary[]> {
const response = await fetch(`${getApiUrl()}/blocked-users`, { credentials: "include" });
const body = await readJson<
{ blockedUsers?: BlockedUserSummary[] } | { error?: { message?: string } }
>(response);

if (!response.ok) {
throw new Error(body && "error" in body ? body.error?.message : "Blocked users failed");
}

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

export async function blockUser(userId: string): Promise<void> {
const response = await fetch(`${getApiUrl()}/blocked-users`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userId }),
});
const body = await readJson<{ ok?: boolean } | { error?: { message?: string } }>(response);

if (!response.ok) {
throw new Error(body && "error" in body ? body.error?.message : "Block failed");
}
}

export async function unblockUser(userId: string): Promise<void> {
const response = await fetch(`${getApiUrl()}/blocked-users/${encodeURIComponent(userId)}`, {
method: "DELETE",
credentials: "include",
});
const body = await readJson<{ ok?: boolean } | { error?: { message?: string } }>(response);

if (!response.ok) {
throw new Error(body && "error" in body ? body.error?.message : "Unblock failed");
}
}

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;
}
}
40 changes: 40 additions & 0 deletions apps/client/src/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { AvatarAppearance } from "@tilezo/protocol/appearance";
import type {
ClientMessage,
DirectMessage,
DirectMessageDeletedMessage,
DirectMessageEditedMessage,
DirectMessageReadReceiptMessage,
DirectMessageTypingStatusMessage,
PublicRoomSummary,
RoomSnapshotMessage,
} from "@tilezo/protocol/messages";
Expand All @@ -17,6 +21,10 @@ type GameOptions = {
setRooms: (rooms: PublicRoomSummary[]) => void;
onRoomJoined: (snapshot: RoomSnapshotMessage) => void;
onDirectMessage: (message: DirectMessage) => void;
onDirectTyping: (message: DirectMessageTypingStatusMessage) => void;
onDirectRead: (message: DirectMessageReadReceiptMessage) => void;
onDirectEdited: (message: DirectMessageEditedMessage) => void;
onDirectDeleted: (message: DirectMessageDeletedMessage) => void;
onDisconnected: () => void;
};

Expand Down Expand Up @@ -83,6 +91,22 @@ export class Game {
this.options.onDirectMessage(message);
}

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

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

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

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

if (message.type === "error") {
this.options.setStatus(`${message.code}: ${message.message}`);
}
Expand Down Expand Up @@ -143,6 +167,22 @@ export class Game {
return this.sendIfConnected({ type: "dm.send", toUserId, text });
}

sendDirectTyping(toUserId: string, isTyping: boolean): boolean {
return this.sendIfConnected({ type: "dm.typing", toUserId, isTyping });
}

markDirectMessagesRead(friendId: string): boolean {
return this.sendIfConnected({ type: "dm.read", friendId });
}

editDirectMessage(messageId: string, text: string): boolean {
return this.sendIfConnected({ type: "dm.edit", messageId, text });
}

deleteDirectMessage(messageId: string): boolean {
return this.sendIfConnected({ type: "dm.delete", messageId });
}

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
18 changes: 17 additions & 1 deletion apps/client/src/messaging/DirectMessageClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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";
import { loadConversation, loadUnreadCounts } from "./DirectMessageClient";

const originalFetch = globalThis.fetch;
type FetchArgs = Parameters<typeof fetch>;
Expand Down Expand Up @@ -46,4 +46,20 @@ describe("loadConversation", () => {

await expect(loadConversation("user_2")).rejects.toThrow("only message your friends");
});

test("loads unread counts", async () => {
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({ unread: [{ friendId: "user_2", count: 3 }] });
}) as unknown as typeof fetch;

await expect(loadUnreadCounts()).resolves.toEqual([{ friendId: "user_2", count: 3 }]);
expect(requests).toEqual([
{
url: `${DEFAULT_API_URL}/direct-messages/unread`,
init: { credentials: "include" },
},
]);
});
});
Loading
Loading