Compare commits

...

1 Commits

Author SHA1 Message Date
Trisha Lim
bab918ce69 show avatars for online users 2025-04-10 17:39:52 +07:00
4 changed files with 165 additions and 53 deletions

View File

@@ -45,7 +45,7 @@ function App() {
<footer className="fixed bottom-4 right-4 flex items-center gap-4">
<input
type="text"
value={getName(me?.profile?.name, me?.sessionID)}
defaultValue={getName(me?.profile?.name, me?.sessionID)[0]}
onChange={(e) => {
if (!me?.profile) return;
me.profile.name = e.target.value;

View File

@@ -1,19 +1,13 @@
import { useAccount } from "jazz-react";
import { CoFeedEntry, co } from "jazz-tools";
import { CursorMoveEvent, useCanvas } from "../hooks/useCanvas";
import { Cursor as CursorType, ViewBox } from "../types";
import { centerOfBounds } from "../utils/centerOfBounds";
import { getColor } from "../utils/getColor";
import { getName } from "../utils/getName";
import { Boundary } from "./Boundary";
import { CanvasBackground } from "./CanvasBackground";
import { CanvasDemoContent } from "./CanvasDemoContent";
import { SessionCursors } from "./Container.tsx";
import { Cursor } from "./Cursor";
const OLD_CURSOR_AGE_SECONDS = Number(
import.meta.env.VITE_OLD_CURSOR_AGE_SECONDS,
);
const DEBUG = import.meta.env.VITE_DEBUG === "true";
// For debugging purposes, we can set a fixed bounds
@@ -26,13 +20,12 @@ const debugBounds: ViewBox = {
interface CanvasProps {
remoteCursors: CoFeedEntry<co<CursorType>>[];
sessionCursors: SessionCursors;
onCursorMove: (move: CursorMoveEvent) => void;
name: string;
}
function Canvas({ remoteCursors, onCursorMove, name }: CanvasProps) {
const { me } = useAccount();
function Canvas({ sessionCursors, onCursorMove, name }: CanvasProps) {
const {
svgProps,
isDragging,
@@ -56,33 +49,22 @@ function Canvas({ remoteCursors, onCursorMove, name }: CanvasProps) {
<CanvasDemoContent />
{DEBUG && <Boundary bounds={bounds} />}
{remoteCursors.map((entry) => {
if (
entry.tx.sessionID === me?.sessionID ||
(OLD_CURSOR_AGE_SECONDS &&
entry.madeAt < new Date(Date.now() - 1000 * OLD_CURSOR_AGE_SECONDS))
) {
return null;
}
const name = getName(entry.by?.profile?.name, entry.tx.sessionID);
const color = getColor(entry.tx.sessionID);
const age = new Date().getTime() - new Date(entry.madeAt).getTime();
return (
<Cursor
key={entry.tx.sessionID}
position={entry.value.position}
color={color}
isDragging={false}
isRemote={true}
name={name}
age={age}
centerOfBounds={center}
bounds={bounds}
/>
);
})}
{sessionCursors.map(
({ entry, isCurrentSession, name, age, color }) =>
!isCurrentSession && (
<Cursor
key={entry.tx.sessionID}
position={entry.value.position}
color={color}
isDragging={false}
isRemote={true}
name={name}
age={age}
centerOfBounds={center}
bounds={bounds}
/>
),
)}
{isMouseOver ? (
<Cursor

View File

@@ -1,29 +1,153 @@
import { useAccount, useCoState } from "jazz-react";
import { ID } from "jazz-tools";
import { CoFeedEntry, co } from "jazz-tools";
import { useMemo, useState } from "react";
import { CursorFeed } from "../schema";
import { Cursor as CursorType } from "../types";
import { getColor } from "../utils/getColor.ts";
import { getName } from "../utils/getName";
import Canvas from "./Canvas";
const OLD_CURSOR_AGE_SECONDS = Number(
import.meta.env.VITE_OLD_CURSOR_AGE_SECONDS,
);
export type SessionCursors = Array<{
name: string;
initial: string;
color: string;
age: number;
entry: CoFeedEntry<co<CursorType>>;
isCurrentSession: boolean;
}>;
function Avatar({
initial,
color,
title,
}: { title?: string; initial: string; color: string }) {
return (
<span
title={title}
className="size-6 text-xs font-medium bg-white inline-flex items-center justify-center rounded-full border-2"
style={{ color, borderColor: color }}
>
{initial}
</span>
);
}
/** A higher order component that wraps the canvas. */
function Container({ cursorFeedID }: { cursorFeedID: ID<CursorFeed> }) {
const { me } = useAccount();
const cursors = useCoState(CursorFeed, cursorFeedID, { resolve: true });
const [showAllAvatars, setShowAllAvatars] = useState(false);
const sessionCursors: SessionCursors = useMemo(
() =>
Object.values(cursors?.perSession ?? {})
// remove stale cursors
.filter(
(entry) =>
entry.tx.sessionID === me?.sessionID ||
(OLD_CURSOR_AGE_SECONDS &&
entry.madeAt <
new Date(Date.now() - 1000 * OLD_CURSOR_AGE_SECONDS)),
)
// set names and colors
.map((entry) => {
const [name, initial] = getName(
entry.by?.profile?.name,
entry.tx.sessionID,
);
const color = getColor(entry.tx.sessionID);
const age = new Date().getTime() - new Date(entry.madeAt).getTime();
return {
name,
initial,
color,
age,
entry,
isCurrentSession: entry.tx.sessionID === me?.sessionID,
};
})
.sort((a, b) => {
if (a.entry.by?.isMe) return -1;
if (b.entry.by?.isMe) return 1;
return a.age - b.age;
}),
[cursors],
);
const sessionAvatars = useMemo(
() => sessionCursors.slice(0, 5),
[sessionCursors],
);
const hiddenSessionAvatars = useMemo(
() => sessionCursors.slice(5),
[sessionCursors],
);
return (
<Canvas
onCursorMove={(move) => {
if (!(cursors && me)) return;
<>
<div className="absolute top-4 right-4 bg-white p-2 rounded-lg shadow">
<div className="flex items-center gap-1">
{sessionAvatars.map(({ name, initial, color, entry }) => (
<Avatar
key={entry.tx.sessionID}
title={name}
initial={initial}
color={color}
/>
))}
cursors.push({
position: {
x: move.position.x,
y: move.position.y,
},
});
}}
remoteCursors={Object.values(cursors?.perSession ?? {})}
name={getName(me?.profile?.name, me?.sessionID)}
/>
{hiddenSessionAvatars.length > 0 && (
<button
type="button"
onClick={() => setShowAllAvatars(!showAllAvatars)}
className="text-stone-500 bg-white px-2 rounded hover:bg-stone-100"
>
show {showAllAvatars ? "less" : "more"}
</button>
)}
</div>
{showAllAvatars && (
<ul className="space-y-1 mt-2">
{sessionCursors.map(({ name, initial, color, entry }) => (
<li
style={{ color }}
key={entry.tx.sessionID}
className="text-sm flex gap-1"
>
<Avatar
key={entry.tx.sessionID}
initial={initial}
color={color}
/>
{name} {entry.by?.isMe ? "(you)" : ""}
</li>
))}
</ul>
)}
</div>
<Canvas
onCursorMove={(move) => {
if (!(cursors && me)) return;
cursors.push({
position: {
x: move.position.x,
y: move.position.y,
},
});
}}
remoteCursors={Object.values(cursors?.perSession ?? {})}
sessionCursors={sessionCursors}
name={getName(me?.profile?.name, me?.sessionID)[0]}
/>
</>
);
}

View File

@@ -25,7 +25,13 @@ const animals = [
* @returns A psuedo-random username.
*/
export function getRandomUsername(str: string) {
return `Anonymous ${animals[Math.abs(str.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % animals.length]}`;
const animal =
animals[
Math.abs(
str.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0),
) % animals.length
];
return [`Anonymous ${animal}`, animal[0].toUpperCase()];
}
/**
@@ -40,5 +46,5 @@ export function getName(
) {
if (name === "Anonymous user" || !name || !id)
return getRandomUsername(id ?? "");
return name;
return [name, name[0].toUpperCase()];
}