Skip to content

Commit

Permalink
SendouQ real(er) time with notifications (#1525)
Browse files Browse the repository at this point in the history
* Initial

* Move code

* More events implemented

* Auto refresh take in account recent revalidates

* Add sound effects

* Add creds

* Settings

* Add error handling

* Add envs
  • Loading branch information
Sendouc authored Oct 18, 2023
1 parent e8d7810 commit 24875c1
Show file tree
Hide file tree
Showing 26 changed files with 391 additions and 47 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=

SKALOP_WS_URL=ws://localhost:5900
SKALOP_SYSTEM_MESSAGE_URL=ws://localhost:5900/system
SKALOP_TOKEN=secret
1 change: 1 addition & 0 deletions app/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const ADMIN_ID = process.env.NODE_ENV === "production" ? 274 : 1;
export const MOD_IDS = [11329];

export const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";
export const SKALOP_TOKEN_HEADER_NAME = "Skalop-Token";

export const CUSTOMIZED_CSS_VARS_NAME = "css";

Expand Down
4 changes: 1 addition & 3 deletions app/features/admin/AdminService.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@ const cleanUpStm = sql.prepare(/*sql*/ `
vacuum
`);

const cleanUp: AdminService["cleanUp"] = () => {
export const cleanUp: AdminService["cleanUp"] = () => {
removeOldLikesStm.run();
removeOldGroupStm.run();
cleanUpStm.run();
};

export { cleanUp };
39 changes: 39 additions & 0 deletions app/features/chat/NotificationService.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { nanoid } from "nanoid";
import invariant from "tiny-invariant";
import { SKALOP_TOKEN_HEADER_NAME } from "~/constants";
import type { ChatMessage } from "./chat-types";

type PartialChatMessage = Pick<
ChatMessage,
"type" | "context" | "room" | "revalidateOnly"
>;
interface NotificationService {
notify: (msg: PartialChatMessage | PartialChatMessage[]) => undefined;
}

invariant(
process.env["SKALOP_SYSTEM_MESSAGE_URL"],
"Missing env var: SKALOP_SYSTEM_MESSAGE_URL",
);
invariant(process.env["SKALOP_TOKEN"], "Missing env var: SKALOP_TOKEN");

export const notify: NotificationService["notify"] = (partialMsg) => {
const msgArr = Array.isArray(partialMsg) ? partialMsg : [partialMsg];

const fullMessages: ChatMessage[] = msgArr.map((partialMsg) => {
return {
id: nanoid(),
timestamp: Date.now(),
room: partialMsg.room,
context: partialMsg.context,
type: partialMsg.type,
revalidateOnly: partialMsg.revalidateOnly,
};
});

return void fetch(process.env["SKALOP_SYSTEM_MESSAGE_URL"]!, {
method: "POST",
body: JSON.stringify(fullMessages),
headers: [[SKALOP_TOKEN_HEADER_NAME, process.env["SKALOP_TOKEN"]!]],
}).catch(console.error);
};
1 change: 1 addition & 0 deletions app/features/chat/chat-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const MESSAGE_MAX_LENGTH = 200;
24 changes: 24 additions & 0 deletions app/features/chat/chat-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export type SystemMessageType =
| "NEW_GROUP"
| "USER_LEFT"
| "MATCH_STARTED"
| "LIKE_RECEIVED"
| "SCORE_REPORTED"
| "SCORE_CONFIRMED"
| "CANCEL_REPORTED"
| "CANCEL_CONFIRMED";

export type SystemMessageContext = {
name: string;
};
export interface ChatMessage {
id: string;
type?: SystemMessageType;
contents?: string;
context?: SystemMessageContext;
revalidateOnly?: boolean;
userId?: number;
timestamp: number;
room: string;
pending?: boolean;
}
20 changes: 20 additions & 0 deletions app/features/chat/chat-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ChatMessage } from "./chat-types";

export function messageTypeToSound(type: ChatMessage["type"]) {
if (type === "LIKE_RECEIVED") return "sq_like";
if (type === "MATCH_STARTED") return "sq_match";
if (type === "NEW_GROUP") return "sq_new-group";

return null;
}

export function soundCodeToLocalStorageKey(soundCode: string) {
return `settings__sound-enabled__${soundCode}`;
}

export function soundEnabled(soundCode: string) {
const localStorageKey = soundCodeToLocalStorageKey(soundCode);
const soundEnabled = localStorage.getItem(localStorageKey);

return !soundEnabled || soundEnabled === "true";
}
108 changes: 90 additions & 18 deletions app/components/Chat.tsx → app/features/chat/components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { Avatar } from "./Avatar";
import { Avatar } from "../../../components/Avatar";
import * as React from "react";
import { SubmitButton } from "./SubmitButton";
import { SubmitButton } from "../../../components/SubmitButton";
import type { User } from "~/db/types";
import { useUser } from "~/modules/auth";
import { nanoid } from "nanoid";
import clsx from "clsx";
import { Button } from "./Button";
import { Button } from "../../../components/Button";
import ReconnectingWebSocket from "reconnecting-websocket";
import invariant from "tiny-invariant";
import { useRootLoaderData } from "~/hooks/useRootLoaderData";
import { useRevalidator } from "@remix-run/react";
import type { ChatMessage } from "../chat-types";
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
import { messageTypeToSound, soundEnabled } from "../chat-utils";
import { soundPath } from "~/utils/urls";

type ChatUser = Pick<User, "discordName" | "discordId" | "discordAvatar"> & {
chatNameColor: string | null;
};

const MESSAGE_MAX_LENGTH = 200;

export interface ChatProps {
users: Record<number, ChatUser>;
rooms: { label: string; code: string }[];
Expand All @@ -29,6 +32,34 @@ export interface ChatProps {
missingUserName?: string;
}

const systemMessageText = (msg: ChatMessage) => {
const name = () => {
if (!msg.context) return "";
return msg.context.name;
};

switch (msg.type) {
case "SCORE_REPORTED": {
return `${name()} reported score`;
}
case "SCORE_CONFIRMED": {
return `${name()} confirmed score. Match is now locked`;
}
case "CANCEL_REPORTED": {
return `${name()} requested canceling the match`;
}
case "CANCEL_CONFIRMED": {
return `${name()} confirmed canceling the match. Match is now locked`;
}
case "USER_LEFT": {
return `${name()} left the group`;
}
default: {
return null;
}
}
};

export function ConnectedChat(props: ChatProps) {
const chat = useChat(props);

Expand Down Expand Up @@ -121,6 +152,17 @@ export function Chat({
ref={messagesContainerRef}
>
{messages.map((msg) => {
const systemMessage = systemMessageText(msg);
if (systemMessage) {
return (
<SystemMessage
key={msg.id}
message={msg}
text={systemMessage}
/>
);
}

const user = msg.userId ? users[msg.userId] : null;
if (!user && !missingUserName) return null;

Expand Down Expand Up @@ -210,16 +252,27 @@ function Message({
);
}

// export type SystemMessageType = "MANAGER_ADDED" | "MANAGER_REMOVED";
export interface ChatMessage {
id: string;
// type?: SystemMessageType;
contents?: string;
// context?: any;
userId?: number;
timestamp: number;
room: string;
pending?: boolean;
function SystemMessage({
message,
text,
}: {
message: ChatMessage;
text: string;
}) {
return (
<li className="chat__message">
<div>
<div className="stack horizontal sm">
<time className="chat__message__time">
{new Date(message.timestamp).toLocaleTimeString()}
</time>
</div>
<div className="chat__message__contents text-xs text-lighter font-semi-bold">
{text}
</div>
</div>
</li>
);
}

export function useChat({
Expand All @@ -229,6 +282,7 @@ export function useChat({
rooms: ChatProps["rooms"];
onNewMessage?: (message: ChatMessage) => void;
}) {
const { revalidate } = useRevalidator();
const rootLoaderData = useRootLoaderData();
const user = useUser();

Expand Down Expand Up @@ -260,7 +314,25 @@ export function useChat({

ws.current.onmessage = (e) => {
const message = JSON.parse(e.data);
const messageArr = Array.isArray(message) ? message : [message];
const messageArr = (
Array.isArray(message) ? message : [message]
) as ChatMessage[];

// something interesting happened
// -> let's run data loaders so they can see it sooner
const isSystemMessage = Boolean(messageArr[0].type);
if (isSystemMessage) {
revalidate();
}

const sound = messageTypeToSound(messageArr[0].type);
if (sound && soundEnabled(sound)) {
void new Audio(soundPath(sound)).play();
}

if (messageArr[0].revalidateOnly) {
return;
}

const isInitialLoad = Array.isArray(message);

Expand All @@ -274,7 +346,7 @@ export function useChat({
if (isInitialLoad) {
setMessages(messageArr);
} else {
onNewMessage?.(message);
if (!isSystemMessage) onNewMessage?.(message);
setMessages((messages) => [...messages, ...messageArr]);
}
};
Expand All @@ -284,7 +356,7 @@ export function useChat({
wsCurrent?.close();
setMessages([]);
};
}, [rooms, onNewMessage, rootLoaderData.skalopUrl]);
}, [rooms, onNewMessage, rootLoaderData.skalopUrl, revalidate]);

React.useEffect(() => {
// ping every minute to keep connection alive
Expand Down
2 changes: 1 addition & 1 deletion app/features/sendouq/components/GroupCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function GroupCard({
hideNote: _hidenote = false,
enableKicking,
}: {
group: Omit<LookingGroup, "createdAt">;
group: Omit<LookingGroup, "createdAt" | "chatCode">;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP";
ownRole?: GroupMemberType["role"];
ownGroup?: boolean;
Expand Down
1 change: 1 addition & 0 deletions app/features/sendouq/q-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type LookingGroup = {
isReplay?: boolean;
isLiked?: boolean;
team?: GroupForMatch["team"];
chatCode: Group["chatCode"];
skillDifference?: ParsedMemento["groups"][number]["skillDifference"];
members?: {
id: number;
Expand Down
12 changes: 12 additions & 0 deletions app/features/sendouq/queries/chatCodeByGroupId.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { sql } from "~/db/sql";

const stm = sql.prepare(/* sql */ `
select
"chatCode"
from "Group"
where "id" = @id
`);

export function chatCodeByGroupId(id: number) {
return stm.pluck().get({ id }) as string | undefined;
}
1 change: 1 addition & 0 deletions app/features/sendouq/queries/findPreparingGroup.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function findPreparingGroup(
id: row.id,
createdAt: row.createdAt,
mapListPreference: row.mapListPreference,
chatCode: null,
inviteCode: row.inviteCode,
members: parseDBJsonArray(row.members).map((member: any) => {
const weapons = parseDBArray(member.weapons);
Expand Down
5 changes: 5 additions & 0 deletions app/features/sendouq/queries/lookingGroups.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const stm = sql.prepare(/* sql */ `
"Group"."createdAt",
"Group"."mapListPreference",
"Group"."inviteCode",
"Group"."chatCode",
"User"."id" as "userId",
"User"."discordId",
"User"."discordName",
Expand Down Expand Up @@ -47,6 +48,7 @@ const stm = sql.prepare(/* sql */ `
"q1"."mapListPreference",
"q1"."inviteCode",
"q1"."createdAt",
"q1"."chatCode",
json_group_array(
json_object(
'id', "q1"."userId",
Expand All @@ -71,10 +73,12 @@ export function findLookingGroups({
minGroupSize,
maxGroupSize,
ownGroupId,
includeChatCode = false,
}: {
minGroupSize?: number;
maxGroupSize?: number;
ownGroupId: number;
includeChatCode?: boolean;
}): LookingGroupWithInviteCode[] {
return stm
.all({ ownGroupId })
Expand All @@ -84,6 +88,7 @@ export function findLookingGroups({
mapListPreference: row.mapListPreference,
inviteCode: row.inviteCode,
createdAt: row.createdAt,
chatCode: includeChatCode ? row.chatCode : null,
members: parseDBJsonArray(row.members).map((member: any) => {
const weapons = parseDBArray(member.weapons);

Expand Down
Loading

0 comments on commit 24875c1

Please sign in to comment.