diff --git a/app/components/Placeholder.module.css b/app/components/Placeholder.module.css
new file mode 100644
index 000000000..2ba624aab
--- /dev/null
+++ b/app/components/Placeholder.module.css
@@ -0,0 +1,3 @@
+.placeholder {
+ height: 100vh;
+}
diff --git a/app/components/Placeholder.tsx b/app/components/Placeholder.tsx
new file mode 100644
index 000000000..d41c8da14
--- /dev/null
+++ b/app/components/Placeholder.tsx
@@ -0,0 +1,6 @@
+import styles from "./Placeholder.module.css";
+
+/** Renders a blank placeholder component that can be used while content is loading. Better than returning null because it keeps the footer down where it belongs. */
+export function Placeholder() {
+ return
;
+}
diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx
index 940cd8110..2a9440922 100644
--- a/app/features/build-analyzer/routes/analyzer.tsx
+++ b/app/features/build-analyzer/routes/analyzer.tsx
@@ -86,6 +86,7 @@ import {
import "../analyzer.css";
import * as R from "remeda";
import { SendouSwitch } from "~/components/elements/Switch";
+import { Placeholder } from "~/components/Placeholder";
import { WeaponSelect } from "~/components/WeaponSelect";
import { logger } from "~/utils/logger";
@@ -117,7 +118,7 @@ export default function BuildAnalyzerShell() {
const isMounted = useIsMounted();
if (!isMounted) {
- return null;
+ return ;
}
return ;
diff --git a/app/features/chat/chat-hooks.ts b/app/features/chat/chat-hooks.ts
index 8552a1c15..1df882f35 100644
--- a/app/features/chat/chat-hooks.ts
+++ b/app/features/chat/chat-hooks.ts
@@ -1,6 +1,13 @@
+import { useRevalidator } from "@remix-run/react";
+import { nanoid } from "nanoid";
+import { WebSocket } from "partysocket";
import React from "react";
+import invariant from "~/utils/invariant";
+import { logger } from "~/utils/logger";
+import { soundPath } from "~/utils/urls";
import { useUser } from "../auth/core/user";
-import type { ChatMessage } from "./chat-types";
+import type { ChatMessage, ChatProps } from "./chat-types";
+import { messageTypeToSound, soundEnabled, soundVolume } from "./chat-utils";
// increasing this = scrolling happens even when scrolled more upwards
const THRESHOLD = 100;
@@ -69,3 +76,211 @@ export function useChatAutoScroll(
scrollToBottom,
};
}
+
+// TODO: should contain unseen messages logic, now it's duplicated
+export function useChat({
+ rooms,
+ onNewMessage,
+ revalidates = true,
+ connected = true,
+}: {
+ /** Which chat rooms to join. */
+ rooms: ChatProps["rooms"];
+ /** Callback function when a new chat message is received. Note: not fired for system messages. */
+ onNewMessage?: (message: ChatMessage) => void;
+ /** If false, skips revalidating on new message. Can be used if more fine grained control is needed regarding when the revalidation happens to e.g. preserve local state. Defaults to true. */
+ revalidates?: boolean;
+ /** If true, the chat is connected to the server. Defaults to true. */
+ connected?: boolean;
+}) {
+ const { revalidate } = useRevalidator();
+ const shouldRevalidate = React.useRef();
+ const user = useUser();
+
+ const [messages, setMessages] = React.useState([]);
+ const [readyState, setReadyState] = React.useState<
+ "CONNECTING" | "CONNECTED" | "CLOSED"
+ >("CONNECTING");
+ const [sentMessage, setSentMessage] = React.useState();
+ const [currentRoom, setCurrentRoom] = React.useState(
+ rooms[0]?.code,
+ );
+
+ const ws = React.useRef();
+ const lastSeenMessagesByRoomId = React.useRef