Pārlūkot izejas kodu

Add user presence to the frontend

Eric Zhang 4 gadi atpakaļ
vecāks
revīzija
8def441972
6 mainītis faili ar 298 papildinājumiem un 12 dzēšanām
  1. 19 1
      package-lock.json
  2. 2 1
      package.json
  3. 34 10
      src/App.tsx
  4. 89 0
      src/User.tsx
  5. 116 0
      src/animals.json
  6. 38 0
      src/rustpad.ts

+ 19 - 1
package-lock.json

@@ -16,7 +16,8 @@
         "react-dom": "^17.0.2",
         "react-dom": "^17.0.2",
         "react-icons": "^4.2.0",
         "react-icons": "^4.2.0",
         "react-scripts": "4.0.3",
         "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": {
       "devDependencies": {
         "@types/react": "^17.0.8",
         "@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": {
     "node_modules/use-sidecar": {
       "version": "1.0.5",
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.5.tgz",
       "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.5.tgz",
@@ -38658,6 +38670,12 @@
       "integrity": "sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==",
       "integrity": "sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==",
       "requires": {}
       "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": {
     "use-sidecar": {
       "version": "1.0.5",
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.5.tgz",
       "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.5.tgz",

+ 2 - 1
package.json

@@ -19,7 +19,8 @@
     "react-dom": "^17.0.2",
     "react-dom": "^17.0.2",
     "react-icons": "^4.2.0",
     "react-icons": "^4.2.0",
     "react-scripts": "4.0.3",
     "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": {
   "devDependencies": {
     "@types/react": "^17.0.8",
     "@types/react": "^17.0.8",

+ 34 - 10
src/App.tsx

@@ -18,18 +18,20 @@ import {
   useToast,
   useToast,
 } from "@chakra-ui/react";
 } from "@chakra-ui/react";
 import {
 import {
-  VscAccount,
   VscChevronRight,
   VscChevronRight,
   VscCircleFilled,
   VscCircleFilled,
   VscFolderOpened,
   VscFolderOpened,
   VscGist,
   VscGist,
   VscRemote,
   VscRemote,
 } from "react-icons/vsc";
 } from "react-icons/vsc";
+import useStorage from "use-local-storage-state";
 import Editor from "@monaco-editor/react";
 import Editor from "@monaco-editor/react";
 import { editor } from "monaco-editor/esm/vs/editor/editor.api";
 import { editor } from "monaco-editor/esm/vs/editor/editor.api";
 import raw from "raw.macro";
 import raw from "raw.macro";
-import Rustpad from "./rustpad";
+import Rustpad, { UserInfo } from "./rustpad";
 import languages from "./languages.json";
 import languages from "./languages.json";
+import animals from "./animals.json";
+import User from "./User";
 
 
 set_panic_hook();
 set_panic_hook();
 
 
@@ -43,11 +45,22 @@ const wsUri =
   window.location.host +
   window.location.host +
   `/api/socket/${id}`;
   `/api/socket/${id}`;
 
 
+function generateName() {
+  return "Anonymous " + animals[Math.floor(Math.random() * animals.length)];
+}
+
+function generateHue() {
+  return Math.floor(Math.random() * 360);
+}
+
 function App() {
 function App() {
   const toast = useToast();
   const toast = useToast();
   const [language, setLanguage] = useState("plaintext");
   const [language, setLanguage] = useState("plaintext");
   const [connection, setConnection] =
   const [connection, setConnection] =
     useState<"connected" | "disconnected" | "desynchronized">("disconnected");
     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 [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
   const rustpad = useRef<Rustpad>();
   const rustpad = useRef<Rustpad>();
 
 
@@ -75,13 +88,20 @@ function App() {
             setLanguage(language);
             setLanguage(language);
           }
           }
         },
         },
+        onChangeUsers: setUsers,
       });
       });
       return () => {
       return () => {
         rustpad.current?.dispose();
         rustpad.current?.dispose();
         rustpad.current = undefined;
         rustpad.current = undefined;
       };
       };
     }
     }
-  }, [editor, toast]);
+  }, [editor, toast, setUsers]);
+
+  useEffect(() => {
+    if (connection === "connected") {
+      rustpad.current?.setInfo({ name, hue });
+    }
+  }, [connection, name, hue]);
 
 
   function handleChangeLanguage(language: string) {
   function handleChangeLanguage(language: string) {
     setLanguage(language);
     setLanguage(language);
@@ -209,12 +229,16 @@ function App() {
             <Heading mt={4} mb={1.5} size="sm">
             <Heading mt={4} mb={1.5} size="sm">
               Active Users
               Active Users
             </Heading>
             </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>
             </Stack>
 
 
             <Heading mt={4} mb={1.5} size="sm">
             <Heading mt={4} mb={1.5} size="sm">
@@ -248,7 +272,7 @@ function App() {
               mt={2}
               mt={2}
               onClick={handleLoadSample}
               onClick={handleLoadSample}
             >
             >
-              Read the code
+              See the code
             </Button>
             </Button>
           </Container>
           </Container>
         </Flex>
         </Flex>

+ 89 - 0
src/User.tsx

@@ -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 - 0
src/animals.json

@@ -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"
+]

+ 38 - 0
src/rustpad.ts

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