Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements text chat settings #149

Merged
merged 3 commits into from
Jun 20, 2024
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
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
Loading