mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-03 08:38:42 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import * as React from "react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import type { ChatMessage, ChatUser } from "../chat-types";
|
||||
import { Chat, type ChatAdapter } from "./Chat";
|
||||
|
||||
vi.mock("~/features/auth/core/user", () => ({
|
||||
useUser: () => null,
|
||||
}));
|
||||
|
||||
const USERS: Record<number, ChatUser> = {
|
||||
1: {
|
||||
username: "Alice",
|
||||
discordId: "1",
|
||||
discordAvatar: null,
|
||||
pronouns: null,
|
||||
customAvatarUrl: null,
|
||||
chatNameHue: null,
|
||||
},
|
||||
};
|
||||
|
||||
function createMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return {
|
||||
id: "1",
|
||||
userId: 1,
|
||||
contents: "Hello world",
|
||||
timestamp: 1700000000000,
|
||||
room: "room",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderChat(
|
||||
messages: ChatMessage[],
|
||||
props?: { missingUserName?: string },
|
||||
) {
|
||||
const chat: ChatAdapter = {
|
||||
messages,
|
||||
send: () => {},
|
||||
currentRoom: "room",
|
||||
setCurrentRoom: () => {},
|
||||
readyState: "CONNECTED",
|
||||
unseenMessages: new Map(),
|
||||
};
|
||||
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<div style={{ width: 400 }}>
|
||||
<Chat
|
||||
users={USERS}
|
||||
rooms={[]}
|
||||
chat={chat}
|
||||
missingUserName={props?.missingUserName}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
async function renderChatWithControls(initialMessages: ChatMessage[]) {
|
||||
const controls = {
|
||||
addMessage: (_msg: ChatMessage) => {},
|
||||
};
|
||||
|
||||
function ChatHarness() {
|
||||
const [messages, setMessages] = React.useState(initialMessages);
|
||||
controls.addMessage = (msg) => setMessages((prev) => [...prev, msg]);
|
||||
|
||||
const chat: ChatAdapter = {
|
||||
messages,
|
||||
send: () => {},
|
||||
currentRoom: "room",
|
||||
setCurrentRoom: () => {},
|
||||
readyState: "CONNECTED",
|
||||
unseenMessages: new Map(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: 400 }}>
|
||||
<Chat users={USERS} rooms={[]} chat={chat} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createMemoryRouter([{ path: "/", element: <ChatHarness /> }], {
|
||||
initialEntries: ["/"],
|
||||
});
|
||||
|
||||
return { screen: await render(<RouterProvider router={router} />), controls };
|
||||
}
|
||||
|
||||
function manyMessages(count: number) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMessage({ id: String(i + 1), contents: `Message ${i + 1}` }),
|
||||
);
|
||||
}
|
||||
|
||||
function isScrolledToBottom(element: HTMLElement) {
|
||||
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
|
||||
}
|
||||
|
||||
describe("Chat", () => {
|
||||
test("renders messages inside a virtualized listbox", async () => {
|
||||
const screen = await renderChat([
|
||||
createMessage({ id: "1", contents: "First message" }),
|
||||
createMessage({ id: "2", contents: "Second message" }),
|
||||
]);
|
||||
|
||||
await expect.element(screen.getByRole("listbox")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("First message")).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByText("Second message"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("option").elements()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("virtualizes a long list into a scrollable region taller than its viewport", async () => {
|
||||
const screen = await renderChat(
|
||||
Array.from({ length: 100 }, (_, i) =>
|
||||
createMessage({ id: String(i + 1), contents: `Message ${i + 1}` }),
|
||||
),
|
||||
);
|
||||
|
||||
const listbox = screen.getByRole("listbox").element() as HTMLElement;
|
||||
await expect.element(screen.getByRole("listbox")).toBeInTheDocument();
|
||||
|
||||
const scrollContent = listbox.querySelector(
|
||||
":scope > [role=presentation]",
|
||||
) as HTMLElement | null;
|
||||
const messageRow = listbox.querySelector(
|
||||
"[role=option]",
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(scrollContent).not.toBeNull();
|
||||
expect(scrollContent!.offsetHeight).toBeGreaterThan(listbox.clientHeight);
|
||||
expect(getComputedStyle(messageRow!.parentElement!).position).toBe(
|
||||
"absolute",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders system messages", async () => {
|
||||
const screen = await renderChat([
|
||||
createMessage({
|
||||
id: "1",
|
||||
type: "USER_LEFT",
|
||||
contents: undefined,
|
||||
userId: undefined,
|
||||
context: { name: "Bob" },
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect
|
||||
.element(screen.getByText("Bob left the group"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("skips messages with an unknown user when no fallback name is given", async () => {
|
||||
const screen = await renderChat([
|
||||
createMessage({ id: "1", userId: 999, contents: "Ghost message" }),
|
||||
]);
|
||||
|
||||
await expect.element(screen.getByRole("listbox")).toBeInTheDocument();
|
||||
expect(screen.getByRole("option").elements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("renders messages with an unknown user using the fallback name", async () => {
|
||||
const screen = await renderChat(
|
||||
[createMessage({ id: "1", userId: 999, contents: "Ghost message" })],
|
||||
{ missingUserName: "Unknown" },
|
||||
);
|
||||
|
||||
await expect.element(screen.getByText("Ghost message")).toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Unknown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("scrolls to the bottom on initial load", async () => {
|
||||
const { screen } = await renderChatWithControls(manyMessages(50));
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
await expect.element(listbox).toBeInTheDocument();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const element = listbox.element() as HTMLElement;
|
||||
expect(element.scrollHeight).toBeGreaterThan(element.clientHeight);
|
||||
expect(isScrolledToBottom(element)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("auto scrolls when a new message arrives while at the bottom", async () => {
|
||||
const { screen, controls } = await renderChatWithControls(manyMessages(50));
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
|
||||
});
|
||||
|
||||
controls.addMessage(
|
||||
createMessage({
|
||||
id: "new",
|
||||
contents:
|
||||
"A brand new message that is long enough to wrap onto multiple lines in the chat window",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(screen.getByText(/A brand new message/))
|
||||
.toBeInTheDocument();
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("does not auto scroll when scrolled up, shows the new messages button instead", async () => {
|
||||
const { screen, controls } = await renderChatWithControls(manyMessages(50));
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
|
||||
});
|
||||
|
||||
const element = listbox.element() as HTMLElement;
|
||||
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
await vi.waitFor(() => {
|
||||
expect(element.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
controls.addMessage(createMessage({ id: "new", contents: "While away" }));
|
||||
|
||||
await expect.element(screen.getByText("New messages")).toBeInTheDocument();
|
||||
expect(isScrolledToBottom(element)).toBe(false);
|
||||
|
||||
await screen.getByText("New messages").click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(element)).toBe(true);
|
||||
});
|
||||
await expect
|
||||
.element(screen.getByText("New messages"))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the reading position when a new message arrives while scrolled up", async () => {
|
||||
const { screen, controls } = await renderChatWithControls(manyMessages(50));
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
await vi.waitFor(() => {
|
||||
expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true);
|
||||
});
|
||||
|
||||
const element = listbox.element() as HTMLElement;
|
||||
const readingPosition = Math.floor(element.scrollHeight / 2);
|
||||
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
element.scrollTop = readingPosition;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
controls.addMessage(createMessage({ id: "new", contents: "While away" }));
|
||||
|
||||
await expect.element(screen.getByText("New messages")).toBeInTheDocument();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(element.scrollTop).toBe(readingPosition);
|
||||
});
|
||||
});
|
||||
190
apps/web-react/app/features/chat/components/Chat.module.css
Normal file
190
apps/web-react/app/features/chat/components/Chat.module.css
Normal file
@@ -0,0 +1,190 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: var(--s-3) 0 0 0;
|
||||
display: block;
|
||||
height: 310px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.messageInfo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0 var(--s-1-5);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.avatarWrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatarWrapperStaff {
|
||||
& img {
|
||||
border: var(--border-style-accent);
|
||||
border-radius: var(--radius-avatar);
|
||||
}
|
||||
}
|
||||
|
||||
.avatarBadge {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pronounsTag {
|
||||
background-color: var(--color-bg-higher);
|
||||
color: var(--color-text-accent);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageUser {
|
||||
font-weight: var(--weight-semi);
|
||||
font-size: var(--font-sm);
|
||||
color: oklch(from var(--color-text-accent) l c var(--chat-hue));
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageTime {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
|
||||
& > form {
|
||||
padding: var(--s-2);
|
||||
border-top: 1.5px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.sendButton.sendButton {
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-text-accent);
|
||||
color: var(--color-text-inverse);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.messageContents {
|
||||
font-size: var(--font-sm);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.messageContentsPending {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.roomLinkBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-1);
|
||||
margin-block: var(--s-1);
|
||||
}
|
||||
|
||||
.roomQrCode {
|
||||
background-color: white;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.roomLink {
|
||||
color: var(--color-text-accent);
|
||||
text-decoration: underline;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.roomButton {
|
||||
border: 0;
|
||||
border-bottom: var(--border-style);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-high);
|
||||
border-radius: var(--radius-box) var(--radius-box) 0 0;
|
||||
padding: var(--s-1) var(--s-1);
|
||||
border-color: var(--color-bg-high);
|
||||
font-size: var(--font-xs);
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2);
|
||||
display: flex;
|
||||
width: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--weight-bold);
|
||||
flex: 1 1 0px;
|
||||
}
|
||||
|
||||
.roomButtonCurrent {
|
||||
background-color: var(--color-bg-high);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.roomButtonUnseen {
|
||||
color: var(--color-accent);
|
||||
text-shadow: var(--font-2xs);
|
||||
margin-inline-start: var(--s-1);
|
||||
width: 25px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bottomRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-block-start: var(--s-2);
|
||||
}
|
||||
|
||||
.unseenMessages {
|
||||
position: absolute;
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg-higher);
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
bottom: 60px;
|
||||
right: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 25px;
|
||||
width: max-content;
|
||||
|
||||
&:active {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
384
apps/web-react/app/features/chat/components/Chat.tsx
Normal file
384
apps/web-react/app/features/chat/components/Chat.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import clsx from "clsx";
|
||||
import { sub } from "date-fns";
|
||||
import { SendHorizontal } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
ListLayout,
|
||||
Virtualizer,
|
||||
} from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "../../../components/Avatar";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SubmitButton } from "../../../components/SubmitButton";
|
||||
import { useDateTimeFormat } from "../../../hooks/intl/useDateTimeFormat";
|
||||
import { findRoomLinks, MESSAGE_MAX_LENGTH } from "../chat-constants";
|
||||
import { useChatAutoScroll } from "../chat-hooks";
|
||||
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
|
||||
import styles from "./Chat.module.css";
|
||||
|
||||
const MESSAGE_GAP = 8;
|
||||
const ESTIMATED_MESSAGE_HEIGHT = 44;
|
||||
const VIRTUALIZER_LAYOUT_OPTIONS = {
|
||||
gap: MESSAGE_GAP,
|
||||
estimatedRowSize: ESTIMATED_MESSAGE_HEIGHT,
|
||||
};
|
||||
|
||||
export interface ChatAdapter {
|
||||
messages: ChatMessage[];
|
||||
send: (contents: string) => void;
|
||||
currentRoom: string | undefined;
|
||||
setCurrentRoom: (room: string) => void;
|
||||
readyState: "CONNECTING" | "CONNECTED" | "CLOSED";
|
||||
unseenMessages: Map<string, number>;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
users,
|
||||
rooms,
|
||||
className,
|
||||
messagesContainerClassName,
|
||||
hidden = false,
|
||||
chat,
|
||||
disabled,
|
||||
missingUserName,
|
||||
}: Omit<ChatProps, "onNewMessage" | "revalidates"> & {
|
||||
chat: ChatAdapter;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
send,
|
||||
messages,
|
||||
currentRoom,
|
||||
setCurrentRoom,
|
||||
readyState,
|
||||
unseenMessages,
|
||||
} = chat;
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
// can't send empty messages
|
||||
if (inputRef.current!.value.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
send(inputRef.current!.value);
|
||||
inputRef.current!.value = "";
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
const { unseenMessagesInTheRoom, scrollToBottom, resetScroller } =
|
||||
useChatAutoScroll(messages, messagesContainerRef);
|
||||
|
||||
const sendingMessagesDisabled = disabled || readyState !== "CONNECTED";
|
||||
|
||||
const systemMessageText = (msg: ChatMessage) => {
|
||||
const name = () => {
|
||||
if (!msg.context) return "";
|
||||
return msg.context.name;
|
||||
};
|
||||
|
||||
switch (msg.type) {
|
||||
case "SCORE_REPORTED": {
|
||||
return t("common:chat.systemMsg.scoreReported", { name: name() });
|
||||
}
|
||||
case "SCORE_CONFIRMED": {
|
||||
return t("common:chat.systemMsg.scoreConfirmed", { name: name() });
|
||||
}
|
||||
case "CANCEL_REPORTED": {
|
||||
return t("common:chat.systemMsg.cancelReported", { name: name() });
|
||||
}
|
||||
case "CANCEL_CONFIRMED": {
|
||||
return t("common:chat.systemMsg.cancelConfirmed", { name: name() });
|
||||
}
|
||||
case "CANCEL_REFUSED": {
|
||||
return t("common:chat.systemMsg.cancelRefused", { name: name() });
|
||||
}
|
||||
case "USER_LEFT": {
|
||||
return t("common:chat.systemMsg.userLeft", { name: name() });
|
||||
}
|
||||
case "MAP_REPLAYED": {
|
||||
return t("common:chat.systemMsg.mapReplayed", { name: name() });
|
||||
}
|
||||
case "MAP_PICKED": {
|
||||
return t("common:chat.systemMsg.mapPicked", { name: name() });
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderableMessages = messages.filter((msg) => {
|
||||
if (systemMessageText(msg)) return true;
|
||||
|
||||
const user = msg.userId ? users[msg.userId] : null;
|
||||
return Boolean(user) || Boolean(missingUserName);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={clsx(styles.container, className, { hidden })}>
|
||||
{rooms.length > 1 ? (
|
||||
<div className="stack horizontal">
|
||||
{rooms.map((room) => {
|
||||
const unseen = unseenMessages.get(room.code);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={room.code}
|
||||
className={clsx(styles.roomButton, {
|
||||
[styles.roomButtonCurrent]: currentRoom === room.code,
|
||||
})}
|
||||
onPress={() => {
|
||||
setCurrentRoom(room.code);
|
||||
resetScroller();
|
||||
}}
|
||||
>
|
||||
<span className={clsx(styles.roomButtonUnseen, "invisible")} />
|
||||
{room.label}
|
||||
{unseen ? (
|
||||
<span className={styles.roomButtonUnseen}>{unseen}</span>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(styles.roomButtonUnseen, "invisible")}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.inputContainer}>
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={VIRTUALIZER_LAYOUT_OPTIONS}
|
||||
>
|
||||
<ListBox
|
||||
ref={messagesContainerRef}
|
||||
aria-label="Chat messages"
|
||||
selectionMode="none"
|
||||
items={renderableMessages}
|
||||
className={clsx(
|
||||
styles.messages,
|
||||
"scrollbar",
|
||||
messagesContainerClassName,
|
||||
)}
|
||||
>
|
||||
{(msg) => {
|
||||
const systemMessage = systemMessageText(msg);
|
||||
if (systemMessage) {
|
||||
return <SystemMessage message={msg} text={systemMessage} />;
|
||||
}
|
||||
|
||||
const user = msg.userId ? users[msg.userId] : null;
|
||||
|
||||
return (
|
||||
<Message
|
||||
user={user}
|
||||
missingUserName={missingUserName}
|
||||
message={msg}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ListBox>
|
||||
</Virtualizer>
|
||||
{unseenMessagesInTheRoom ? (
|
||||
<SendouButton
|
||||
className={styles.unseenMessages}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
{t("common:chat.newMessages")}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
{disabled ? (
|
||||
<div className="text-xs text-lighter text-center my-4">
|
||||
{t("common:chat.expired")}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="mt-4">
|
||||
<input
|
||||
className="w-full text-xs"
|
||||
ref={inputRef}
|
||||
placeholder={t("common:chat.input.placeholder")}
|
||||
disabled={sendingMessagesDisabled}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
/>{" "}
|
||||
<div className={styles.bottomRow}>
|
||||
{readyState === "CONNECTED" || readyState === "CONNECTING" ? (
|
||||
<div className="text-xxs font-semi-bold text-lighter">
|
||||
{t(
|
||||
readyState === "CONNECTED"
|
||||
? "common:chat.connected"
|
||||
: "common:chat.connecting",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xxs font-semi-bold text-warning">
|
||||
{t("common:chat.disconnected")}
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton
|
||||
className={styles.sendButton}
|
||||
size="small"
|
||||
isDisabled={sendingMessagesDisabled}
|
||||
aria-label={t("common:chat.send")}
|
||||
icon={<SendHorizontal size={16} />}
|
||||
testId="chat-submit-button"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({
|
||||
user,
|
||||
message,
|
||||
missingUserName,
|
||||
}: {
|
||||
user?: ChatUser | null;
|
||||
message: ChatMessage;
|
||||
missingUserName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
className={styles.message}
|
||||
textValue={message.contents ?? user?.username ?? missingUserName ?? ""}
|
||||
>
|
||||
{user ? (
|
||||
<div
|
||||
className={clsx(styles.avatarWrapper, {
|
||||
[styles.avatarWrapperStaff]: user.title,
|
||||
})}
|
||||
>
|
||||
<Avatar user={user} size="xs" />
|
||||
{user.title ? (
|
||||
<span className={styles.avatarBadge}>{user.title}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className={styles.messageInfo}>
|
||||
<div
|
||||
className={styles.messageUser}
|
||||
style={
|
||||
user?.chatNameHue ? { "--chat-hue": user.chatNameHue } : undefined
|
||||
}
|
||||
>
|
||||
{user?.username ?? missingUserName}
|
||||
</div>
|
||||
{user?.pronouns ? (
|
||||
<span className={styles.pronounsTag}>
|
||||
{user.pronouns.subject}/{user.pronouns.object}
|
||||
</span>
|
||||
) : null}
|
||||
{!message.pending ? (
|
||||
<MessageTimestamp timestamp={message.timestamp} />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.messageContents, {
|
||||
[styles.messageContentsPending]: message.pending,
|
||||
})}
|
||||
>
|
||||
{message.contents ? (
|
||||
<MessageContents text={message.contents} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemMessage({
|
||||
message,
|
||||
text,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<ListBoxItem className={styles.message} textValue={text}>
|
||||
<div>
|
||||
<div className="stack horizontal sm">
|
||||
<MessageTimestamp timestamp={message.timestamp} />
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.messageContents,
|
||||
"text-xs text-lighter font-semi-bold",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContents({ text }: { text: string }) {
|
||||
const matches = findRoomLinks(text);
|
||||
|
||||
if (matches.length === 0) return <>{text}</>;
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const [i, match] of matches.entries()) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<span key={i} className={styles.roomLinkBlock}>
|
||||
<QRCodeSVG value={match.url} size={120} className={styles.roomQrCode} />
|
||||
<a
|
||||
href={match.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.roomLink}
|
||||
>
|
||||
{match.url}
|
||||
</a>
|
||||
</span>,
|
||||
);
|
||||
lastIndex = match.index + match.url.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
function MessageTimestamp({ timestamp }: { timestamp: number }) {
|
||||
const { formatter: dateTimeFormatter } = useDateTimeFormat({
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
const { formatter: timeFormatter } = useDateTimeFormat({
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
|
||||
|
||||
return (
|
||||
<time className={styles.messageTime}>
|
||||
{moreThanDayAgo
|
||||
? dateTimeFormatter.format(new Date(timestamp))
|
||||
: timeFormatter.format(new Date(timestamp))}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user