feat: Implement collaboration features including user following and avatar management
This commit is contained in:
@@ -8,9 +8,9 @@ import { useActiveUsers, useSocketStore } from "../../../store/store";
|
||||
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Html } from "@react-three/drei";
|
||||
import CollabUserIcon from "../../../functions/collabUserIcon";
|
||||
import { getAvatarColor } from "../../../functions/users/functions/getAvatarColor";
|
||||
import CollabUserIcon from "./collabUserIcon";
|
||||
import useModuleStore from "../../../store/useModuleStore";
|
||||
import { getAvatarColor } from "../functions/getAvatarColor";
|
||||
|
||||
const CamModelsGroup = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -20,13 +20,14 @@ const CamModelsGroup = () => {
|
||||
const { socket } = useSocketStore();
|
||||
const { activeModule } = useModuleStore();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const loader = new GLTFLoader();
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath("three/examples/jsm/libs/draco/gltf/");
|
||||
loader.setDRACOLoader(dracoLoader);
|
||||
|
||||
const [cams, setCams] = useState<any[]>([]);
|
||||
const [models, setModels] = useState<Record<string, { targetPosition: THREE.Vector3; targetRotation: THREE.Euler }>>({});
|
||||
const [models, setModels] = useState<Record<string, { targetPosition: THREE.Vector3; targetRotation: THREE.Euler, target: THREE.Vector3 }>>({});
|
||||
|
||||
const dedupeCams = (cams: any[]) => {
|
||||
const seen = new Set();
|
||||
@@ -102,6 +103,7 @@ const CamModelsGroup = () => {
|
||||
});
|
||||
|
||||
socket.on("cameraUpdateResponse", (data: any) => {
|
||||
|
||||
if (
|
||||
!groupRef.current ||
|
||||
socket.id === data.socketId ||
|
||||
@@ -122,6 +124,11 @@ const CamModelsGroup = () => {
|
||||
data.data.rotation.y,
|
||||
data.data.rotation.z
|
||||
),
|
||||
target: new THREE.Vector3(
|
||||
data.data.target.x,
|
||||
data.data.target.y,
|
||||
data.data.target.z
|
||||
),
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -131,7 +138,7 @@ const CamModelsGroup = () => {
|
||||
socket.off("userDisConnectResponse");
|
||||
socket.off("cameraUpdateResponse");
|
||||
};
|
||||
}, [socket]);
|
||||
}, [email, loader, navigate, setActiveUsers, socket]);
|
||||
|
||||
useFrame(() => {
|
||||
if (!groupRef.current) return;
|
||||
@@ -217,9 +224,11 @@ const CamModelsGroup = () => {
|
||||
position={[-0.015, 0, 0.7]}
|
||||
>
|
||||
<CollabUserIcon
|
||||
userImage={cam.userData.userImage || ""}
|
||||
userImage={cam.userData.userImage ?? ""}
|
||||
userName={cam.userData.userName}
|
||||
color={getAvatarColor(index, cam.userData.userName)}
|
||||
position={cam.position}
|
||||
rotation={cam.rotation}
|
||||
/>
|
||||
</Html>
|
||||
</primitive>
|
||||
|
||||
56
app/src/modules/collaboration/camera/collabUserIcon.tsx
Normal file
56
app/src/modules/collaboration/camera/collabUserIcon.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import CustomAvatar from "../users/Avatar";
|
||||
import { useSelectedUserStore } from "../../../store/useCollabStore";
|
||||
import { useCamMode } from "../../../store/store";
|
||||
|
||||
interface CollabUserIconProps {
|
||||
userName: string;
|
||||
userImage?: string;
|
||||
color: string;
|
||||
position?: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
rotation?: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
}
|
||||
|
||||
const CollabUserIcon: React.FC<CollabUserIconProps> = ({
|
||||
userImage,
|
||||
userName,
|
||||
color,
|
||||
position,
|
||||
rotation,
|
||||
}) => {
|
||||
const { setSelectedUser } = useSelectedUserStore();
|
||||
const { setCamMode } = useCamMode();
|
||||
return (
|
||||
<div className="collab-user-live-container">
|
||||
<button
|
||||
className="user-image-container"
|
||||
onClick={() => {
|
||||
if(!position || !rotation) return;
|
||||
|
||||
// Set the selected user in the store
|
||||
setSelectedUser({ color: color, name: userName, location: { position, rotation } });
|
||||
setCamMode("FollowPerson");
|
||||
}}
|
||||
>
|
||||
{userImage ? (
|
||||
<img className="user-image" src={userImage} alt={userName} />
|
||||
) : (
|
||||
<CustomAvatar name={userName} color={color} />
|
||||
)}
|
||||
</button>
|
||||
<div className="user-name" style={{ backgroundColor: color }}>
|
||||
{userName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollabUserIcon;
|
||||
@@ -1,14 +1,32 @@
|
||||
import React from 'react'
|
||||
import CamModelsGroup from './camera/collabCams'
|
||||
import React, { useEffect } from "react";
|
||||
import CamModelsGroup from "./camera/collabCams";
|
||||
import { useSelectedUserStore } from "../../store/useCollabStore";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import setCameraView from "./functions/setCameraView";
|
||||
import { useCamMode } from "../../store/store";
|
||||
|
||||
const Collaboration = () => {
|
||||
return (
|
||||
<>
|
||||
const Collaboration: React.FC = () => {
|
||||
const { selectedUser } = useSelectedUserStore();
|
||||
const { camMode } = useCamMode();
|
||||
const { camera, controls } = useThree(); // Access R3F camera and controls
|
||||
|
||||
<CamModelsGroup />
|
||||
useEffect(() => {
|
||||
if(camMode !== "FollowPerson") return;
|
||||
// If a user is selected, set the camera view to their location
|
||||
// and update the camera and controls accordingly
|
||||
if (selectedUser?.location) {
|
||||
const { position, rotation } = selectedUser.location;
|
||||
setCameraView({
|
||||
controls,
|
||||
camera,
|
||||
position,
|
||||
rotation,
|
||||
username: selectedUser.name,
|
||||
});
|
||||
}
|
||||
}, [selectedUser, camera, controls, camMode]);
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <CamModelsGroup />;
|
||||
};
|
||||
|
||||
export default Collaboration
|
||||
export default Collaboration;
|
||||
|
||||
56
app/src/modules/collaboration/functions/getAvatarColor.ts
Normal file
56
app/src/modules/collaboration/functions/getAvatarColor.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
const avatarColors: string[] = [
|
||||
"#FF5733", // Vivid Orange
|
||||
"#48ac2a", // Leaf Green
|
||||
"#0050eb", // Bright Blue
|
||||
"#FF33A1", // Hot Pink
|
||||
"#FF8C33", // Sunset Orange
|
||||
"#8C33FF", // Violet Purple
|
||||
"#FF3333", // Fiery Red
|
||||
"#43c06d", // Emerald Green
|
||||
"#A133FF", // Royal Purple
|
||||
"#C70039", // Crimson Red
|
||||
"#900C3F", // Deep Burgundy
|
||||
"#581845", // Plum Purple
|
||||
"#3859AD", // Steel Blue
|
||||
"#08873E", // Forest Green
|
||||
"#E74C3C", // Cherry Red
|
||||
"#00adff", // Sky Blue
|
||||
"#DBAD05", // Golden Yellow
|
||||
"#A13E31", // Brick Red
|
||||
"#94C40E", // Lime Green
|
||||
"#060C47", // Midnight Blue
|
||||
"#2FAFAF", // Teal
|
||||
];
|
||||
|
||||
export function getAvatarColor(index: number, name?: string): string {
|
||||
// Check if the color is already stored in localStorage
|
||||
const localStorageKey = "userAvatarColors";
|
||||
// Check if local storage is available
|
||||
if (name) {
|
||||
let userColors = JSON.parse(localStorage.getItem(localStorageKey) ?? "{}");
|
||||
|
||||
// Check if the user already has an assigned color
|
||||
if (userColors[name]) {
|
||||
return userColors[name];
|
||||
}
|
||||
|
||||
// Find a new color not already assigned
|
||||
const usedColors = Object.values(userColors);
|
||||
const availableColors = avatarColors.filter(color => !usedColors.includes(color));
|
||||
|
||||
// Assign a new color
|
||||
const assignedColor = availableColors.length > 0
|
||||
? availableColors[0]
|
||||
: avatarColors[index % avatarColors.length];
|
||||
|
||||
userColors[name] = assignedColor;
|
||||
|
||||
// Save back to local storage
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(userColors));
|
||||
|
||||
return assignedColor;
|
||||
}
|
||||
|
||||
// Fallback: Assign a color using the index if no name or local storage is unavailable
|
||||
return avatarColors[index % avatarColors.length];
|
||||
}
|
||||
10
app/src/modules/collaboration/functions/getInitials.ts
Normal file
10
app/src/modules/collaboration/functions/getInitials.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const getInitials = (fullName: string): string => {
|
||||
// Extract initials from the name
|
||||
const words = fullName.split(" ");
|
||||
const initials = words
|
||||
.map((word) => word[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
return initials;
|
||||
};
|
||||
46
app/src/modules/collaboration/functions/setCameraView.ts
Normal file
46
app/src/modules/collaboration/functions/setCameraView.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface SetCameraViewProps {
|
||||
controls: any;
|
||||
camera: THREE.Camera;
|
||||
position: THREE.Vector3 | { x: number; y: number; z: number };
|
||||
rotation: THREE.Euler | { x: number; y: number; z: number };
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export default async function setCameraView({
|
||||
controls,
|
||||
camera,
|
||||
position,
|
||||
rotation,
|
||||
username,
|
||||
}: SetCameraViewProps) {
|
||||
if (!controls || !camera) return;
|
||||
|
||||
// Normalize position
|
||||
const newPosition = position instanceof THREE.Vector3
|
||||
? position
|
||||
: new THREE.Vector3(position.x, position.y, position.z);
|
||||
|
||||
// Normalize rotation
|
||||
const newRotation = rotation instanceof THREE.Euler
|
||||
? rotation
|
||||
: new THREE.Euler(rotation.x, rotation.y, rotation.z);
|
||||
|
||||
// Update camera position and rotation
|
||||
// camera.position.copy(newPosition);
|
||||
// camera.rotation.copy(newRotation);
|
||||
|
||||
// If your controls need to update the target, you can optionally adjust it too
|
||||
if (controls.setTarget) {
|
||||
// Setting a basic target slightly forward from new position based on rotation
|
||||
const cameraDirection = new THREE.Vector3(0, 0, -1).applyEuler(newRotation);
|
||||
const targetPosition = new THREE.Vector3().copy(newPosition).add(cameraDirection);
|
||||
|
||||
// controls.setTarget(targetPosition.x, targetPosition.y, targetPosition.z);
|
||||
controls?.setLookAt(...newPosition.toArray(), newPosition.x, 0, newPosition.z, true);
|
||||
}
|
||||
|
||||
// Optionally you can log
|
||||
console.log(`Camera view updated by ${username ?? 'unknown user'}`);
|
||||
}
|
||||
58
app/src/modules/collaboration/users/Avatar.tsx
Normal file
58
app/src/modules/collaboration/users/Avatar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getInitials } from "../functions/getInitials";
|
||||
|
||||
interface AvatarProps {
|
||||
name: string; // Name can be a full name or initials
|
||||
size?: number;
|
||||
textColor?: string;
|
||||
color?: string; // Optional color prop for future use
|
||||
}
|
||||
|
||||
const CustomAvatar: React.FC<AvatarProps> = ({
|
||||
name,
|
||||
size = 100,
|
||||
textColor = "#ffffff",
|
||||
color, // Optional color prop for future use
|
||||
}) => {
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = document.createElement("canvas"); // Create an offscreen canvas
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
const initials = getInitials(name); // Convert name to initials if needed
|
||||
|
||||
// Draw background
|
||||
ctx.fillStyle = color ?? "#323232"; // Use color prop or generate color based on index
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// Draw initials
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `bold ${size / 2}px Arial`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(initials, size / 2, size / 2);
|
||||
|
||||
// Generate image source
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
setImageSrc(dataURL);
|
||||
}
|
||||
}, [color, name, size, textColor]);
|
||||
|
||||
if (!imageSrc) {
|
||||
return null; // Return null while the image is being generated
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="user-image"
|
||||
src={imageSrc}
|
||||
alt="User Avatar"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomAvatar;
|
||||
Reference in New Issue
Block a user