Add user presence to the frontend

This commit is contained in:
Eric Zhang 2021-06-04 14:34:06 -05:00
parent 56184b569b
commit 8def441972
6 changed files with 298 additions and 12 deletions

20
package-lock.json generated
View file

@ -16,7 +16,8 @@
"react-dom": "^17.0.2",
"react-icons": "^4.2.0",
"react-scripts": "4.0.3",
"rustpad-wasm": "file:./rustpad-wasm/pkg"
"rustpad-wasm": "file:./rustpad-wasm/pkg",
"use-local-storage-state": "^10.0.0"
},
"devDependencies": {
"@types/react": "^17.0.8",
@ -20550,6 +20551,17 @@
}
}
},
"node_modules/use-local-storage-state": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/use-local-storage-state/-/use-local-storage-state-10.0.0.tgz",
"integrity": "sha512-NCab0oYOMZA8oT9y4OE7tMT6JS21SiyPsTjZdapnyvHe7bVFlIMSp6LaiuHBdS1OvduuLtG+pX/duFIBkd0PCA==",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/use-sidecar": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.5.tgz",
@ -38658,6 +38670,12 @@
"integrity": "sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==",
"requires": {}
},
"use-local-storage-state": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/use-local-storage-state/-/use-local-storage-state-10.0.0.tgz",
"integrity": "sha512-NCab0oYOMZA8oT9y4OE7tMT6JS21SiyPsTjZdapnyvHe7bVFlIMSp6LaiuHBdS1OvduuLtG+pX/duFIBkd0PCA==",
"requires": {}
},
"use-sidecar": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.5.tgz",

View file

@ -19,7 +19,8 @@
"react-dom": "^17.0.2",
"react-icons": "^4.2.0",
"react-scripts": "4.0.3",
"rustpad-wasm": "file:./rustpad-wasm/pkg"
"rustpad-wasm": "file:./rustpad-wasm/pkg",
"use-local-storage-state": "^10.0.0"
},
"devDependencies": {
"@types/react": "^17.0.8",

View file

@ -18,18 +18,20 @@ import {
useToast,
} from "@chakra-ui/react";
import {
VscAccount,
VscChevronRight,
VscCircleFilled,
VscFolderOpened,
VscGist,
VscRemote,
} from "react-icons/vsc";
import useStorage from "use-local-storage-state";
import Editor from "@monaco-editor/react";
import { editor } from "monaco-editor/esm/vs/editor/editor.api";
import raw from "raw.macro";
import Rustpad from "./rustpad";
import Rustpad, { UserInfo } from "./rustpad";
import languages from "./languages.json";
import animals from "./animals.json";
import User from "./User";
set_panic_hook();
@ -43,11 +45,22 @@ const wsUri =
window.location.host +
`/api/socket/${id}`;
function generateName() {
return "Anonymous " + animals[Math.floor(Math.random() * animals.length)];
}
function generateHue() {
return Math.floor(Math.random() * 360);
}
function App() {
const toast = useToast();
const [language, setLanguage] = useState("plaintext");
const [connection, setConnection] =
useState<"connected" | "disconnected" | "desynchronized">("disconnected");
const [users, setUsers] = useStorage<Record<number, UserInfo>>("users", {});
const [name, setName] = useStorage("name", generateName);
const [hue, setHue] = useState(generateHue);
const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
const rustpad = useRef<Rustpad>();
@ -75,13 +88,20 @@ function App() {
setLanguage(language);
}
},
onChangeUsers: setUsers,
});
return () => {
rustpad.current?.dispose();
rustpad.current = undefined;
};
}
}, [editor, toast]);
}, [editor, toast, setUsers]);
useEffect(() => {
if (connection === "connected") {
rustpad.current?.setInfo({ name, hue });
}
}, [connection, name, hue]);
function handleChangeLanguage(language: string) {
setLanguage(language);
@ -209,12 +229,16 @@ function App() {
<Heading mt={4} mb={1.5} size="sm">
Active Users
</Heading>
<Stack mb={1.5} fontSize="sm">
<HStack p={2} rounded="md" _hover={{ bgColor: "gray.200" }}>
<Icon as={VscAccount} />
<Text fontWeight="medium">Anonymous Bear</Text>
<Text>(you)</Text>
</HStack>
<Stack spacing={0} mb={1.5} fontSize="sm">
<User
info={{ name, hue }}
isMe
onChangeName={(name) => name.length > 0 && setName(name)}
onChangeColor={() => setHue(generateHue())}
/>
{Object.entries(users).map(([id, info]) => (
<User key={id} info={info} />
))}
</Stack>
<Heading mt={4} mb={1.5} size="sm">
@ -248,7 +272,7 @@ function App() {
mt={2}
onClick={handleLoadSample}
>
Read the code
See the code
</Button>
</Container>
</Flex>

89
src/User.tsx Normal file
View file

@ -0,0 +1,89 @@
import {
Button,
ButtonGroup,
HStack,
Icon,
Input,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverFooter,
PopoverHeader,
PopoverTrigger,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { useRef } from "react";
import { FaPalette } from "react-icons/fa";
import { VscAccount } from "react-icons/vsc";
import { UserInfo } from "./rustpad";
type UserProps = {
info: UserInfo;
isMe?: boolean;
onChangeName?: (name: string) => unknown;
onChangeColor?: () => unknown;
};
function User({ info, isMe = false, onChangeName, onChangeColor }: UserProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const nameColor = `hsl(${info.hue}, 90%, 15%)`;
return (
<Popover
placement="right"
isOpen={isOpen}
onClose={onClose}
initialFocusRef={inputRef}
>
<PopoverTrigger>
<HStack
p={2}
rounded="md"
_hover={{ bgColor: "gray.200", cursor: "pointer" }}
onClick={() => isMe && onOpen()}
>
<Icon as={VscAccount} />
<Text fontWeight="medium" color={nameColor}>
{info.name}
</Text>
{isMe && <Text>(you)</Text>}
</HStack>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader fontWeight="semibold">Update Info</PopoverHeader>
<PopoverArrow />
<PopoverCloseButton />
<PopoverBody>
<Input
ref={inputRef}
mb={2}
value={info.name}
maxLength={25}
onChange={(event) => onChangeName?.(event.target.value)}
/>
<Button
size="sm"
w="100%"
leftIcon={<FaPalette />}
onClick={onChangeColor}
>
Change Color
</Button>
</PopoverBody>
<PopoverFooter d="flex" justifyContent="flex-end">
<ButtonGroup size="sm">
<Button colorScheme="blue" onClick={onClose}>
Done
</Button>
</ButtonGroup>
</PopoverFooter>
</PopoverContent>
</Popover>
);
}
export default User;

116
src/animals.json Normal file
View file

@ -0,0 +1,116 @@
[
"Alligator",
"Ant",
"Anteater",
"Antelope",
"Arctic Fox",
"Armadillo",
"Badger",
"Bat",
"Beaver",
"Bee",
"Beetle",
"Black Bear",
"Buffalo",
"Butterfly",
"Camel",
"Cat",
"Chameleon",
"Cheetah",
"Chicken",
"Cicada",
"Clam",
"Cockatoo",
"Cockroach",
"Cow",
"Coyote",
"Crab",
"Cricket",
"Crow",
"Deer",
"Dog",
"Dolphin",
"Donkey",
"Dove",
"Dragonfly",
"Duck",
"Eagle",
"Eel",
"Elephant",
"Ferret",
"Fish",
"Fly",
"Fox",
"Frog",
"Gazelle",
"Goat",
"Grasshopper",
"Grizzly Bear",
"Groundhog",
"Guinea Pig",
"Hedgehog",
"Hen",
"Hippopotamus",
"Horse",
"Hummingbird",
"Hyena",
"Koala",
"Leopard",
"Lion",
"Llama",
"Lobster",
"Lynx",
"Meerkat",
"Mole",
"Moose",
"Moth",
"Mouse",
"Octopus",
"Orangutan",
"Orca",
"Ostrich",
"Owl",
"Panda Bear",
"Panther",
"Parrot",
"Penguin",
"Pig",
"Pigeon",
"Polar Bear",
"Rabbit",
"Raccoon",
"Reindeer",
"Robin",
"Sea Lion",
"Sea Otter",
"Seagull",
"Seahorse",
"Seal",
"Shark",
"Sheep",
"Shrimp",
"Slug",
"Snail",
"Snake",
"Sparrow",
"Squid",
"Squirrel",
"Starfish",
"Swan",
"Tiger",
"Turkey",
"Turtle",
"Wallaby",
"Walrus",
"Wasp",
"Water Buffalo",
"Weasel",
"Weaver",
"Whale",
"Wildcat",
"Wilddog",
"Wolf",
"Wolverine",
"Wombat",
"Woodpecker"
]

View file

@ -9,9 +9,16 @@ export type RustpadOptions = {
readonly onDisconnected?: () => unknown;
readonly onDesynchronized?: () => unknown;
readonly onChangeLanguage?: (language: string) => unknown;
readonly onChangeUsers?: (users: Record<number, UserInfo>) => unknown;
readonly reconnectInterval?: number;
};
/** A user currently editing the document. */
export type UserInfo = {
readonly name: string;
readonly hue: number;
};
/** Browser client for Rustpad. */
class Rustpad {
private ws?: WebSocket;
@ -28,6 +35,8 @@ class Rustpad {
private revision: number = 0;
private outstanding?: OpSeq;
private buffer?: OpSeq;
private users: Record<number, UserInfo> = {};
private myInfo?: UserInfo;
// Intermittent local editor state
private lastValue: string = "";
@ -72,6 +81,12 @@ class Rustpad {
return this.ws !== undefined;
}
/** Set the user's information. */
setInfo(info: UserInfo) {
this.myInfo = info;
this.sendInfo();
}
/**
* Attempts a WebSocket connection.
*
@ -91,6 +106,8 @@ class Rustpad {
this.connecting = false;
this.ws = ws;
this.options.onConnected?.();
this.users = {};
this.options.onChangeUsers?.(this.users);
if (this.outstanding) {
this.sendOperation(this.outstanding);
}
@ -138,6 +155,17 @@ class Rustpad {
}
} else if (msg.Language !== undefined) {
this.options.onChangeLanguage?.(msg.Language);
} else if (msg.UserInfo !== undefined) {
const { id, info } = msg.UserInfo;
if (id !== this.me) {
this.users = { ...this.users };
if (info) {
this.users[id] = info;
} else {
delete this.users[id];
}
this.options.onChangeUsers?.(this.users);
}
}
}
@ -183,6 +211,12 @@ class Rustpad {
this.ws?.send(`{"Edit":{"revision":${this.revision},"operation":${op}}}`);
}
private sendInfo() {
if (this.myInfo) {
this.ws?.send(`{"ClientInfo":${JSON.stringify(this.myInfo)}}`);
}
}
// The following functions are based on Firepad's monaco-adapter.js
private applyOperation(operation: OpSeq) {
@ -281,6 +315,10 @@ type ServerMsg = {
operations: UserOperation[];
};
Language?: string;
UserInfo?: {
id: number;
info: UserInfo | null;
};
};
export default Rustpad;