Skip to content

Commit

Permalink
Implements text chat settings (#149)
Browse files Browse the repository at this point in the history
* implements text chat settings

* accounts for only some of the options being passed

* renames options to userNameToColorOptions for readability and reusability
  • Loading branch information
TheCodeTherapy authored Jun 20, 2024
1 parent 5a49f55 commit 26122af
Show file tree
Hide file tree
Showing 9 changed files with 360 additions and 236 deletions.
422 changes: 216 additions & 206 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import {
TweakPane,
VirtualJoystick,
} from "@mml-io/3d-web-client-core";
import { ChatNetworkingClient, FromClientChatMessage, TextChatUI } from "@mml-io/3d-web-text-chat";
import {
ChatNetworkingClient,
FromClientChatMessage,
StringToHslOptions,
TextChatUI,
TextChatUIProps,
} from "@mml-io/3d-web-text-chat";
import {
UserData,
UserNetworkingClient,
Expand Down Expand Up @@ -56,6 +62,8 @@ type MMLDocumentConfiguration = {
export type Networked3dWebExperienceClientConfig = {
sessionToken: string;
chatNetworkAddress?: string;
chatVisibleByDefault?: boolean;
userNameToColorOptions?: StringToHslOptions;
voiceChatAddress?: string;
userNetworkAddress: string;
mmlDocuments?: Array<MMLDocumentConfiguration>;
Expand Down Expand Up @@ -321,11 +329,14 @@ export class Networked3dWebExperienceClient {
}

if (this.textChatUI === null) {
this.textChatUI = new TextChatUI(
this.element,
user.username,
this.sendChatMessageToServer.bind(this),
);
const textChatUISettings: TextChatUIProps = {
holderElement: this.element,
clientname: user.username,
sendMessageToServerMethod: this.sendChatMessageToServer.bind(this),
visibleByDefault: this.config.chatVisibleByDefault,
stringToHslOptions: this.config.userNameToColorOptions,
};
this.textChatUI = new TextChatUI(textChatUISettings);
this.textChatUI.init();
}

Expand Down
39 changes: 30 additions & 9 deletions packages/3d-web-text-chat/src/chat-ui/TextChatUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,36 @@ import { createRoot, Root } from "react-dom/client";

import { ChatUIComponent } from "./components/ChatPanel/TextChatUIComponent";

export type StringToHslOptions = {
hueThresholds?: [number, number][];
saturationThresholds?: [number, number][];
lightnessThresholds?: [number, number][];
};

const DEFAULT_HUE_RANGES: [number, number][] = [[10, 350]];
const DEFAULT_SATURATION_RANGES: [number, number][] = [[60, 100]];
const DEFAULT_LIGHTNESS_RANGES: [number, number][] = [[65, 75]];

export const DEFAULT_HSL_OPTIONS: StringToHslOptions = {
hueThresholds: DEFAULT_HUE_RANGES,
saturationThresholds: DEFAULT_SATURATION_RANGES,
lightnessThresholds: DEFAULT_LIGHTNESS_RANGES,
};

const ForwardedChatUIComponent = forwardRef(ChatUIComponent);

export type ChatUIInstance = {
addMessage: (username: string, message: string) => void;
};

export type TextChatUIProps = {
holderElement: HTMLElement;
clientname: string;
sendMessageToServerMethod: (message: string) => void;
visibleByDefault?: boolean;
stringToHslOptions?: StringToHslOptions;
};

export class TextChatUI {
private root: Root;
private appRef: React.RefObject<ChatUIInstance> = createRef<ChatUIInstance>();
Expand All @@ -22,23 +46,20 @@ export class TextChatUI {

private wrapper = document.createElement("div");

constructor(
private holderElement: HTMLElement,
private clientname: string,
private sendMessageToServerMethod: (message: string) => void,
) {
this.holderElement.appendChild(this.wrapper);
constructor(private config: TextChatUIProps) {
this.config.holderElement.appendChild(this.wrapper);
this.root = createRoot(this.wrapper);
this.sendMessageToServerMethod = sendMessageToServerMethod;
}

init() {
flushSync(() =>
this.root.render(
<ForwardedChatUIComponent
ref={this.appRef}
clientName={this.clientname}
sendMessageToServer={this.sendMessageToServerMethod}
clientName={this.config.clientname}
sendMessageToServer={this.config.sendMessageToServerMethod}
visibleByDefault={this.config.visibleByDefault}
stringToHslOptions={this.config.stringToHslOptions}
/>,
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ import { useClickOutside } from "../../helpers";
import ChatIcon from "../../icons/Chat.svg";
import PinButton from "../../icons/Pin.svg";
import { gradient } from "../../images/gradient";
import { type ChatUIInstance } from "../../TextChatUI";
import { StringToHslOptions, type ChatUIInstance } from "../../TextChatUI";
import { InputBox } from "../Input/InputBox";
import { Messages } from "../Messages/Messages";

import styles from "./TextChatUIComponent.module.css";
type ChatUIProps = {
clientName: string;
sendMessageToServer: (message: string) => void;
visibleByDefault?: boolean;
stringToHslOptions?: StringToHslOptions;
};

const MAX_MESSAGES = 50;
Expand All @@ -29,10 +31,10 @@ export const ChatUIComponent: ForwardRefRenderFunction<ChatUIInstance, ChatUIPro
props: ChatUIProps,
ref,
) => {
const visibleByDefault: boolean = props.visibleByDefault ?? true;
const [messages, setMessages] = useState<Array<{ username: string; message: string }>>([]);

const [isVisible, setIsVisible] = useState(false);
const [isSticky, setSticky] = useState(false);
const [isVisible, setIsVisible] = useState<boolean>(visibleByDefault);
const [isSticky, setSticky] = useState<boolean>(visibleByDefault);
const [isFocused, setIsFocused] = useState(false);
const [isOpenHovered, setOpenHovered] = useState(false);

Expand Down Expand Up @@ -108,6 +110,9 @@ export const ChatUIComponent: ForwardRefRenderFunction<ChatUIInstance, ChatUIPro
});

useEffect(() => {
if (isVisible && isSticky) {
if (chatPanelRef.current) chatPanelRef.current.style.zIndex = "100";
}
setPanelStyle(isVisible || isFocused || isSticky ? styles.fadeIn : styles.fadeOut);
setStickyStyle(
isSticky
Expand Down Expand Up @@ -189,7 +194,7 @@ export const ChatUIComponent: ForwardRefRenderFunction<ChatUIInstance, ChatUIPro
maskSize: "contain",
}}
>
<Messages messages={messages} />
<Messages messages={messages} stringToHslOptions={props.stringToHslOptions} />
</div>
<InputBox
ref={inputBoxRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
}

.chatInput {
font-family: 'Helvetica', 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
font-size: 15px;
flex: 1;
padding: 10px;
border-radius: 8px;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
.messageContainer {
font-family: 'Helvetica', 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
padding: 7px;
overflow-x: hidden;
background-color: rgba(0, 0, 0, 0.7);
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
border-radius: 9px;
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.7);
font-weight: 700;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.9);
direction: ltr;
margin: 12px auto 12px 2px;
width: fit-content;
Expand All @@ -15,5 +13,4 @@

.userName {
color: #cccccc;
font-weight: 900;
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,87 @@
import { FC } from "react";
import { FC, useState, useEffect, useCallback } from "react";

import { DEFAULT_HSL_OPTIONS, StringToHslOptions } from "../../TextChatUI";

import styles from "./Message.module.css";

function ReverseHash(input: string): number {
// Hash has an initial value of 5381. As bit shifting is used, output can be any signed 32 bit Integer.
const stringLength = input.length;
let hash = 5381;

for (let i = stringLength - 1; i >= 0; i--) {
hash = (hash << 5) + hash + input.charCodeAt(i);
}

return hash;
}

function generateValueFromThresholds(hash: number, thresholds: [number, number][]): number {
const selectedThreshold = thresholds[hash % thresholds.length];
const min = Math.min(...selectedThreshold);
const max = Math.max(...selectedThreshold);

const thresholdRange = Math.abs(max - min);
return (hash % thresholdRange) + min;
}

function hslForString(
input: string,
options: StringToHslOptions = DEFAULT_HSL_OPTIONS,
): [number, number, number] {
// Because JS bit shifting only operates on 32-Bit signed Integers,
// in the case of overflow where a negative hash is inappropriate,
// the absolute value has to be taken. This 'halves' our theoretical
// hash distribution. This may require an alternate approach if
// collisions are too frequent.
let hash = Math.abs(ReverseHash("lightness: " + input));

const lightness = options.lightnessThresholds
? generateValueFromThresholds(hash, options.lightnessThresholds)
: generateValueFromThresholds(hash, DEFAULT_HSL_OPTIONS.lightnessThresholds!);

hash = Math.abs(ReverseHash("saturation:" + input));
const saturation = options.saturationThresholds
? generateValueFromThresholds(hash, options.saturationThresholds)
: generateValueFromThresholds(hash, DEFAULT_HSL_OPTIONS.saturationThresholds!);

hash = Math.abs(ReverseHash("hue:" + input));
const hue = options.hueThresholds
? generateValueFromThresholds(hash, options.hueThresholds)
: generateValueFromThresholds(hash, DEFAULT_HSL_OPTIONS.hueThresholds!);

return [hue, saturation, lightness];
}

type MessageProps = {
username: string;
message: string;
stringToHslOptions?: StringToHslOptions;
};

const Message: FC<MessageProps> = ({ username, message }) => {
const Message: FC<MessageProps> = ({ username, message, stringToHslOptions }) => {
const [userColors, setUserColors] = useState<Map<string, string>>(new Map());

const generateColorForUsername = useCallback((): string => {
const [hue, saturation, lightness] = hslForString(username, stringToHslOptions);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}, [stringToHslOptions, username]);

useEffect(() => {
if (!userColors.has(username)) {
const color = generateColorForUsername();
setUserColors(new Map(userColors).set(username, color));
}
}, [username, userColors, generateColorForUsername]);

const userColor = userColors.get(username) || "hsl(0, 0%, 0%)";

return (
<div className={styles.messageContainer}>
<span className={styles.userName}>{username}</span>: {message}
<span className={styles.userName} style={{ color: userColor }}>
{username}
</span>
: {message}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { FC, useEffect, useRef } from "react";

import { StringToHslOptions } from "../../TextChatUI";
import Message from "../Message/Message";

import styles from "./Messages.module.css";

type MessagesProps = {
messages: Array<{ username: string; message: string }>;
stringToHslOptions?: StringToHslOptions;
};

export const Messages: FC<MessagesProps> = ({ messages }) => {
export const Messages: FC<MessagesProps> = ({ messages, stringToHslOptions }) => {
const messagesEndRef = useRef<null | HTMLDivElement>(null);

useEffect(() => {
Expand All @@ -21,7 +23,12 @@ export const Messages: FC<MessagesProps> = ({ messages }) => {
<div className={styles.messagesContainer}>
{" "}
{messages.map((msg, index) => (
<Message key={index} username={msg.username} message={msg.message} />
<Message
key={index}
username={msg.username}
message={msg.message}
stringToHslOptions={stringToHslOptions}
/>
))}
<div ref={messagesEndRef}></div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/3d-web-text-chat/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { TextChatUI } from "./chat-ui/TextChatUI";
export { TextChatUI, TextChatUIProps, type StringToHslOptions } from "./chat-ui/TextChatUI";
export * from "./chat-network/ChatNetworkingServer";
export * from "./chat-network/ChatNetworkingClient";
export * from "./chat-network/ReconnectingWebsocket";
Expand Down

0 comments on commit 26122af

Please sign in to comment.