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

Custom display name & chat bubbles (#125) #187

Merged
merged 7 commits into from
Jan 31, 2025
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
2 changes: 1 addition & 1 deletion .github/actions/npm-install-build-and-cache/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ runs:
run: npm run build

- name: Upload build artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: |
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/retrieve-deps-and-build/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ runs:
using: "composite"
steps:
- name: Download build artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: build-artifacts

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
"@mml-io/3d-web-text-chat": "^0.20.0",
"@mml-io/3d-web-user-networking": "^0.20.0",
"@mml-io/3d-web-voice-chat": "^0.20.0",
"@mml-io/mml-web": "0.19.0",
"@mml-io/mml-web-runner": "0.19.0",
"@mml-io/mml-web-threejs-standalone": "0.19.0",
"@mml-io/networked-dom-document": "0.19.0",
"@mml-io/mml-web": "0.19.1",
"@mml-io/mml-web-runner": "0.19.1",
"@mml-io/mml-web-threejs-standalone": "0.19.1",
"@mml-io/networked-dom-document": "0.19.1",
"three": "0.163.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"dependencies": {
"@example/local-only-multi-user-3d-web-experience-client": "^0.20.0",
"@mml-io/3d-web-experience-server": "^0.20.0",
"@mml-io/networked-dom-server": "0.19.0",
"@mml-io/networked-dom-server": "0.19.1",
"chokidar": "^3.6.0",
"cors": "^2.8.5",
"express": "4.19.2",
Expand Down
2 changes: 1 addition & 1 deletion example/multi-user-3d-web-experience/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@mml-io/3d-web-experience-server": "^0.20.0",
"@mml-io/3d-web-text-chat": "^0.20.0",
"@mml-io/3d-web-user-networking": "^0.20.0",
"@mml-io/networked-dom-server": "0.19.0",
"@mml-io/networked-dom-server": "0.19.1",
"chokidar": "^3.6.0",
"express": "4.19.2",
"express-ws": "5.0.2",
Expand Down
361 changes: 189 additions & 172 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import { AvatarSelectionUIComponent } from "./components/AvatarPanel/AvatarSecti

const ForwardedAvatarSelectionUIComponent = forwardRef(AvatarSelectionUIComponent);

export type CustomAvatar = AvatarType & {
isCustomAvatar?: boolean;
};

export type AvatarSelectionUIProps = {
holderElement: HTMLElement;
visibleByDefault?: boolean;
sendMessageToServerMethod: (avatar: CustomAvatar) => void;
displayName: string;
characterDescription: AvatarType;
allowCustomDisplayName: boolean;
sendIdentityUpdateToServer: (displayName: string, characterDescription: AvatarType) => void;
} & AvatarConfiguration;

export class AvatarSelectionUI {
Expand All @@ -29,8 +28,14 @@ export class AvatarSelectionUI {
this.root = createRoot(this.wrapper);
}

private onUpdateUserAvatar = (avatar: CustomAvatar) => {
this.config.sendMessageToServerMethod(avatar);
private onUpdateUserAvatar = (avatar: AvatarType) => {
this.config.characterDescription = avatar;
this.config.sendIdentityUpdateToServer(this.config.displayName, avatar);
};

private onUpdateDisplayName = (displayName: string) => {
this.config.displayName = displayName;
this.config.sendIdentityUpdateToServer(displayName, this.config.characterDescription);
};

public updateAvatarConfig(avatarConfig: AvatarConfiguration) {
Expand All @@ -41,15 +46,27 @@ export class AvatarSelectionUI {
this.init();
}

public updateAllowCustomDisplayName(allowCustomDisplayName: boolean) {
this.config = {
...this.config,
allowCustomDisplayName,
};
this.init();
}

init() {
flushSync(() =>
this.root.render(
<ForwardedAvatarSelectionUIComponent
ref={this.appRef}
onUpdateUserAvatar={this.onUpdateUserAvatar}
onUpdateDisplayName={this.onUpdateDisplayName}
visibleByDefault={this.config.visibleByDefault}
displayName={this.config.displayName}
characterDescription={this.config.characterDescription}
availableAvatars={this.config.availableAvatars}
allowCustomAvatars={this.config.allowCustomAvatars}
allowCustomAvatars={this.config.allowCustomAvatars || false}
allowCustomDisplayName={this.config.allowCustomDisplayName || false}
/>,
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import React, {
useState,
} from "react";

import { CustomAvatar } from "../../AvatarSelectionUI";
import { AvatarType } from "../../AvatarType";
import AvatarIcon from "../../icons/Avatar.svg";

Expand All @@ -16,7 +15,13 @@ type AvatarSelectionUIProps = {
onUpdateUserAvatar: (avatar: AvatarType) => void;
visibleByDefault?: boolean;
availableAvatars: AvatarType[];
allowCustomAvatars?: boolean;

characterDescription: AvatarType;
allowCustomAvatars: boolean;

displayName: string;
allowCustomDisplayName: boolean;
onUpdateDisplayName: (displayNameValue: string) => void;
};

enum CustomAvatarType {
Expand All @@ -34,19 +39,24 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
) => {
const visibleByDefault: boolean = props.visibleByDefault ?? false;
const [isVisible, setIsVisible] = useState<boolean>(visibleByDefault);
const [selectedAvatar, setSelectedAvatar] = useState<CustomAvatar | undefined>(undefined);
const [selectedAvatar, setSelectedAvatar] = useState<AvatarType | undefined>(
props.characterDescription,
);
const [customAvatarType, setCustomAvatarType] = useState<CustomAvatarType>(
CustomAvatarType.mmlUrl,
);
const [customAvatarValue, setCustomAvatarValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const [displayNameValue, setDisplayNameValue] = useState<string>(props.displayName);
const displayNameRef = useRef<HTMLInputElement>(null);

const handleRootClick = (e: MouseEvent) => {
e.stopPropagation();
};

const selectAvatar = (avatar: CustomAvatar) => {
const selectAvatar = (avatar: AvatarType) => {
setSelectedAvatar(avatar);
props.onUpdateUserAvatar(avatar);
};
Expand All @@ -55,18 +65,42 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
setCustomAvatarValue(e.target.value);
};

const handleDisplayNameChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setDisplayNameValue(e.target.value);
};

const setDisplayName = () => {
if (!displayNameValue) {
return;
}
props.onUpdateDisplayName(displayNameValue);
};

const addCustomAvatar = () => {
if (!customAvatarValue) {
return;
}

const newSelectedAvatar = {
mmlCharacterString: customAvatarType === CustomAvatarType.mml ? customAvatarValue : undefined,
mmlCharacterUrl: customAvatarType === CustomAvatarType.mmlUrl ? customAvatarValue : undefined,
meshFileUrl:
customAvatarType === CustomAvatarType.meshFileUrl ? customAvatarValue : undefined,
isCustomAvatar: true,
} as CustomAvatar;
let newSelectedAvatar: AvatarType;
switch (customAvatarType) {
case CustomAvatarType.mml:
newSelectedAvatar = {
mmlCharacterString: customAvatarValue,
};
break;
case CustomAvatarType.mmlUrl:
newSelectedAvatar = {
mmlCharacterUrl: customAvatarValue,
};
break;
case CustomAvatarType.meshFileUrl:
newSelectedAvatar = {
meshFileUrl: customAvatarValue,
};
break;
}

setSelectedAvatar(newSelectedAvatar);
props.onUpdateUserAvatar(newSelectedAvatar);
Expand All @@ -76,6 +110,20 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
e.stopPropagation();
};

const handleAvatarInputKeyPress = (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.stopPropagation();
if (e.key === "Enter") {
addCustomAvatar();
}
};

const handleDisplayNameKeyPress = (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.stopPropagation();
if (e.key === "Enter") {
setDisplayName();
}
};

const handleTypeSwitch = (type: CustomAvatarType) => {
setCustomAvatarType(type);
setCustomAvatarValue("");
Expand All @@ -92,10 +140,16 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
}
};

if (!props.availableAvatars.length && !props.allowCustomAvatars) {
if (
!props.availableAvatars.length &&
!props.allowCustomAvatars &&
!props.allowCustomDisplayName
) {
return null;
}

let recognizedAvatar = false;

return (
<>
<div className={styles.menuButton} onClick={handleRootClick}>
Expand All @@ -112,21 +166,45 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
</div>
{isVisible && (
<div className={`${styles.avatarSelectionContainer}`}>
{!!props.availableAvatars.length && (
<div className={styles.avatarSelectionUi}>
<div className={styles.avatarSelectionUiHeader}>
<h2>Choose your avatar</h2>
{props.allowCustomDisplayName && (
<div className={styles.displayNameSection}>
<div className={styles.sectionHeading}>Display Name</div>
<div className={styles.displayNameInputSection}>
<input
ref={displayNameRef}
className={styles.input}
value={displayNameValue}
onKeyDown={handleDisplayNameKeyPress}
onChange={handleDisplayNameChange}
placeholder={"Enter your display name"}
/>
<button
className={styles.setButton}
disabled={!displayNameValue}
type="button"
onClick={setDisplayName}
>
Set
</button>
</div>
<div className={styles.avatarSelectionUiContent}>
</div>
)}
{!!props.availableAvatars.length && (
<div className={styles.avatarSelectionSection}>
<div className={styles.sectionHeading}>Choose your Avatar</div>
<div className={styles.avatarSelectionUi}>
{props.availableAvatars.map((avatar, index) => {
const isSelected =
!selectedAvatar?.isCustomAvatar &&
((selectedAvatar?.meshFileUrl &&
(selectedAvatar?.meshFileUrl &&
selectedAvatar?.meshFileUrl === avatar.meshFileUrl) ||
(selectedAvatar?.mmlCharacterUrl &&
selectedAvatar?.mmlCharacterUrl === avatar.mmlCharacterUrl) ||
(selectedAvatar?.mmlCharacterString &&
selectedAvatar?.mmlCharacterString === avatar.mmlCharacterString));
(selectedAvatar?.mmlCharacterUrl &&
selectedAvatar?.mmlCharacterUrl === avatar.mmlCharacterUrl) ||
(selectedAvatar?.mmlCharacterString &&
selectedAvatar?.mmlCharacterString === avatar.mmlCharacterString);

if (isSelected) {
recognizedAvatar = true;
}

return (
<div
Expand All @@ -137,9 +215,18 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
<div className={styles.avatarSelectionUiAvatarImgContainer}>
{isSelected && <SelectedPill />}
{avatar.thumbnailUrl ? (
<img src={avatar.thumbnailUrl} alt={avatar.name} />
<img
className={styles.avatarSelectionUiAvatarImage}
src={avatar.thumbnailUrl}
alt={avatar.name}
/>
) : (
<div>No Image Available</div>
<div className={styles.avatarSelectionNoImage}>
<img
alt={avatar.name}
src={`data:image/svg+xml;utf8,${encodeURIComponent(AvatarIcon)}`}
/>
</div>
)}
<p>{avatar.name}</p>
<span className={styles.tooltipText}>{avatar.name}</span>
Expand All @@ -152,8 +239,7 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
)}
{props.allowCustomAvatars && (
<div className={styles.customAvatarSection}>
{!!props.availableAvatars.length && <hr />}
<h2>Custom Avatar Section</h2>
<div className={styles.sectionHeading}>Custom Avatar</div>
<input
type="radio"
id="html"
Expand Down Expand Up @@ -181,12 +267,12 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
checked={customAvatarType === CustomAvatarType.meshFileUrl}
/>
<label htmlFor="glb">Mesh URL</label>
{selectedAvatar?.isCustomAvatar && <SelectedPill />}
{!recognizedAvatar && <SelectedPill />}
<div className={styles.customAvatarInputSection}>
{customAvatarType === CustomAvatarType.mml ? (
<textarea
ref={textareaRef}
className={styles.customAvatarInput}
className={styles.input}
value={customAvatarValue}
onChange={handleInputChange}
onKeyDown={handleKeyPress}
Expand All @@ -196,14 +282,19 @@ export const AvatarSelectionUIComponent: ForwardRefRenderFunction<any, AvatarSel
) : (
<input
ref={inputRef}
className={styles.customAvatarInput}
className={styles.input}
value={customAvatarValue}
onKeyDown={handleKeyPress}
onKeyDown={handleAvatarInputKeyPress}
onChange={handleInputChange}
placeholder={getPlaceholderByType(customAvatarType)}
/>
)}
<button disabled={!customAvatarValue} type="button" onClick={addCustomAvatar}>
<button
className={styles.setButton}
disabled={!customAvatarValue}
type="button"
onClick={addCustomAvatar}
>
Set
</button>
</div>
Expand Down
Loading