) => {
+ hideModal();
+ onChange(event.currentTarget.dataset.value ?? '');
+ },
+ [hideModal, onChange],
+ );
+
+ return (
+ <>
+
+ {disabled ? null : (
+
+ {message}
+
+ {values.map((value, key) =>
+ value === currentValue ? (
+
+ ) : (
+
+ ),
+ )}
+
+
+ )}
+ >
+ );
+};
diff --git a/src/client/components/header/Auth.tsx b/src/client/components/header/Auth.tsx
new file mode 100644
index 0000000..5cdbba7
--- /dev/null
+++ b/src/client/components/header/Auth.tsx
@@ -0,0 +1,20 @@
+import {Login} from './Login';
+import {Logout} from './Logout';
+import React from 'react';
+import {User} from './User';
+import {useUiUsername} from '../../stores/UiStore';
+
+export const Auth = () => {
+ return (
+
+ {useUiUsername() ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/src/client/components/header/Header.tsx b/src/client/components/header/Header.tsx
new file mode 100644
index 0000000..bdb2430
--- /dev/null
+++ b/src/client/components/header/Header.tsx
@@ -0,0 +1,10 @@
+import {Auth} from './Auth';
+import {Logo} from './Logo';
+import React from 'react';
+
+export const Header = () => (
+
+);
diff --git a/src/client/components/header/Login.tsx b/src/client/components/header/Login.tsx
new file mode 100644
index 0000000..8b23be6
--- /dev/null
+++ b/src/client/components/header/Login.tsx
@@ -0,0 +1,56 @@
+import {GITHUB_LOGIN, TEST_LOGIN} from '../../../config';
+import {
+ useGitHubUserLoginCallback,
+ useTestUserLoginCallback,
+} from '../../stores/UserStore';
+import React from 'react';
+import {useModal} from '../common/Modal';
+import {useUiOnline} from '../../stores/UiStore';
+
+export const Login = () => {
+ const [Modal, showModal, hideModal] = useModal();
+
+ const loginAsAlice = useTestUserLoginCallback('Alice');
+ const loginAsBob = useTestUserLoginCallback('Bob');
+ const loginAsCarol = useTestUserLoginCallback('Carol');
+ const loginWithGitHub = useGitHubUserLoginCallback();
+
+ return (
+ <>
+
+
+ {TEST_LOGIN ? (
+ <>
+ Login with one of these test accounts.
+
+
+
+
+
+ >
+ ) : null}
+ {TEST_LOGIN && GITHUB_LOGIN ?
: null}
+ {GITHUB_LOGIN ? (
+ <>
+ Login with your GitHub account.
+
+ >
+ ) : null}
+
+ >
+ );
+};
diff --git a/src/client/components/header/Logo.tsx b/src/client/components/header/Logo.tsx
new file mode 100644
index 0000000..1c4f842
--- /dev/null
+++ b/src/client/components/header/Logo.tsx
@@ -0,0 +1,16 @@
+import {APP_NAME} from '../../../config';
+import React from 'react';
+
+export const Logo = () => (
+
+
+
{APP_NAME}
+
+
+);
diff --git a/src/client/components/header/Logout.tsx b/src/client/components/header/Logout.tsx
new file mode 100644
index 0000000..2d0d4a0
--- /dev/null
+++ b/src/client/components/header/Logout.tsx
@@ -0,0 +1,20 @@
+import {ConfirmButton} from '../common/ConfirmButton';
+import React from 'react';
+import {useUiOnline} from '../../stores/UiStore';
+import {useUserLogoutCallback} from '../../stores/UserStore';
+
+const WARNING =
+ 'Are you sure you want to logout? ' +
+ 'You will lose access to your cloud rooms.';
+const WARNING_OFFLINE =
+ ' You are also offline, so any local changes to cloud stores will be lost.';
+
+export const Logout = () => (
+
+);
diff --git a/src/client/components/header/User.tsx b/src/client/components/header/User.tsx
new file mode 100644
index 0000000..70bf42e
--- /dev/null
+++ b/src/client/components/header/User.tsx
@@ -0,0 +1,35 @@
+import {
+ EditableUsernameView,
+ useUserName,
+ useUserProvider,
+ useUserProviderUsername,
+} from '../../stores/UserStore';
+import React from 'react';
+import {useModal} from '../common/Modal';
+
+export const User = () => {
+ const provider = useUserProvider();
+ const providerUsername = useUserProviderUsername();
+ const name = useUserName();
+
+ const [Modal, showModal, hideModal] = useModal();
+
+ return (
+ <>
+
+
+ Edit your user's nickname for this app.
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/client/components/main/Main.tsx b/src/client/components/main/Main.tsx
new file mode 100644
index 0000000..699b103
--- /dev/null
+++ b/src/client/components/main/Main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import {Room} from './room/Room';
+import {Sidebar} from './sidebar/Sidebar';
+
+export const Main = () => (
+
+
+
+
+);
diff --git a/src/client/components/main/room/Room.tsx b/src/client/components/main/room/Room.tsx
new file mode 100644
index 0000000..750a09a
--- /dev/null
+++ b/src/client/components/main/room/Room.tsx
@@ -0,0 +1,48 @@
+import {
+ CREATING,
+ LOCAL,
+ useRoomState,
+ useRoomStore,
+ useRoomType,
+} from '../../../stores/rooms';
+import {useUiOnline, useUiRoomId, useUiUsername} from '../../../stores/UiStore';
+import {FORBIDDEN} from '../../../common';
+import React from 'react';
+import {RoomBody} from './RoomBody';
+import {RoomHeader} from './RoomHeader';
+import {RoomJoin} from '../sidebar/RoomJoin';
+import {useHasValues} from 'tinybase/debug/ui-react';
+
+export const Room = () => {
+ const roomId = useUiRoomId();
+ const roomType = useRoomType(roomId);
+ const roomStore = useRoomStore(roomId);
+ const roomIsReady = useHasValues(roomStore);
+ const roomState = useRoomState(roomType ?? LOCAL, roomId);
+
+ const online = useUiOnline();
+ const username = useUiUsername();
+
+ return (
+
+ {roomType ? (
+ roomIsReady && roomState != CREATING && roomState != FORBIDDEN ? (
+ <>
+
+
+ >
+ ) : null
+ ) : (
+ <>
+
Please choose or create a room from the sidebar
+ {roomId && online && username ? (
+ <>
+
Or would you like to try and join room {roomId}?
+
+ >
+ ) : null}
+ >
+ )}
+
+ );
+};
diff --git a/src/client/components/main/room/RoomBody.tsx b/src/client/components/main/room/RoomBody.tsx
new file mode 100644
index 0000000..c7f884b
--- /dev/null
+++ b/src/client/components/main/room/RoomBody.tsx
@@ -0,0 +1,16 @@
+import {Provider} from 'tinybase/debug/ui-react';
+import React from 'react';
+import {Shapes} from '../../../_shapes/Shapes';
+import {useRoomStore} from '../../../stores/rooms';
+
+export const RoomBody = ({roomId}: {readonly roomId: string}) => {
+ const roomStore = useRoomStore(roomId);
+
+ return (
+
+ );
+};
diff --git a/src/client/components/main/room/RoomHeader.tsx b/src/client/components/main/room/RoomHeader.tsx
new file mode 100644
index 0000000..47247c2
--- /dev/null
+++ b/src/client/components/main/room/RoomHeader.tsx
@@ -0,0 +1,19 @@
+import {EditableRoomNameView} from '../../../stores/RoomStore';
+import React from 'react';
+import {RoomManage} from './manage/RoomManage';
+import {RoomType} from '../../../stores/rooms';
+
+export const RoomHeader = ({
+ roomType,
+ roomId,
+}: {
+ readonly roomType: RoomType;
+ readonly roomId: string;
+}) => {
+ return (
+
+ );
+};
diff --git a/src/client/components/main/room/manage/RoomLeave.tsx b/src/client/components/main/room/manage/RoomLeave.tsx
new file mode 100644
index 0000000..179a532
--- /dev/null
+++ b/src/client/components/main/room/manage/RoomLeave.tsx
@@ -0,0 +1,36 @@
+import {
+ RoomType,
+ useRoomsLeaveCallback,
+ useRoomsSetTopRoomIdCallback,
+} from '../../../../stores/rooms';
+import {ConfirmButton} from '../../../common/ConfirmButton';
+import React from 'react';
+
+const LEAVE_ROOM_LABEL = 'Leave';
+const LEAVE_ROOM_WARNING =
+ 'Are you sure you want to leave this room? ' +
+ 'You will lose access and all the data within it.';
+
+export const RoomLeave = ({
+ roomType,
+ roomId,
+}: {
+ readonly roomType: RoomType;
+ readonly roomId: string;
+}) => {
+ const handleConfirm = useRoomsLeaveCallback(
+ roomType!,
+ roomId,
+ useRoomsSetTopRoomIdCallback(),
+ );
+
+ return (
+
+ );
+};
diff --git a/src/client/components/main/room/manage/RoomManage.tsx b/src/client/components/main/room/manage/RoomManage.tsx
new file mode 100644
index 0000000..02b3115
--- /dev/null
+++ b/src/client/components/main/room/manage/RoomManage.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import {RoomLeave} from './RoomLeave';
+import {RoomSetType} from './RoomSetType';
+import {RoomSetVisibility} from './RoomSetVisibility';
+import {RoomType} from '../../../../stores/rooms';
+
+export const RoomManage = ({
+ roomType,
+ roomId,
+}: {
+ readonly roomType: RoomType;
+ readonly roomId: string;
+}) => (
+
+
+
+
+
+);
diff --git a/src/client/components/main/room/manage/RoomSetType.tsx b/src/client/components/main/room/manage/RoomSetType.tsx
new file mode 100644
index 0000000..6042f25
--- /dev/null
+++ b/src/client/components/main/room/manage/RoomSetType.tsx
@@ -0,0 +1,52 @@
+import {
+ CLOUD,
+ LOCAL,
+ RoomType,
+ getRoomOtherType,
+ useRoomSetTypeCallback,
+} from '../../../../stores/rooms';
+import {CLOUD_DESCRIPTION, LOCAL_DESCRIPTION} from '../../../../common';
+import {
+ useUiSetRoomIdCallback,
+ useUiUsername,
+} from '../../../../stores/UiStore';
+import React from 'react';
+import {SelectButton} from '../../../common/ToggleButton';
+
+export const RoomSetType = ({
+ roomType,
+ roomId,
+}: {
+ readonly roomType: RoomType;
+ readonly roomId: string;
+}) => {
+ const disabled = !useUiUsername();
+
+ const buttonTitle =
+ `This is a ${roomType} room. ` +
+ (roomType == LOCAL ? LOCAL_DESCRIPTION : CLOUD_DESCRIPTION) +
+ (disabled ? '' : ' Click to change type.');
+ const message =
+ `Change the type of room from ${roomType} to ${getRoomOtherType(
+ roomType,
+ )}? ` + (roomType == LOCAL ? CLOUD_DESCRIPTION : LOCAL_DESCRIPTION);
+ const handleChange = useRoomSetTypeCallback(
+ roomType,
+ roomId,
+ useUiSetRoomIdCallback(),
+ ) as any;
+
+ return (
+
+ );
+};
diff --git a/src/client/components/main/room/manage/RoomSetVisibility.tsx b/src/client/components/main/room/manage/RoomSetVisibility.tsx
new file mode 100644
index 0000000..8e5bb02
--- /dev/null
+++ b/src/client/components/main/room/manage/RoomSetVisibility.tsx
@@ -0,0 +1,47 @@
+import {
+ LOCAL,
+ RoomType,
+ getRoomOtherVisibility,
+ useRoomOwner,
+ useRoomSetVisibilityCallback,
+ useRoomVisibility,
+} from '../../../../stores/rooms';
+import {PRIVATE, PUBLIC} from '../../../../../common';
+import React from 'react';
+import {SelectButton} from '../../../common/ToggleButton';
+import {useUiUsername} from '../../../../stores/UiStore';
+
+export const RoomSetVisibility = ({
+ roomType,
+ roomId,
+}: {
+ readonly roomType: RoomType;
+ readonly roomId: string;
+}) => {
+ const roomVisibility = useRoomVisibility(roomId);
+ const disabled =
+ useUiUsername() !== useRoomOwner(roomId) || roomType == LOCAL;
+ const buttonTitle =
+ `This is a ${roomVisibility} room. ` +
+ (roomVisibility == PRIVATE
+ ? 'Only this ' + (roomType == LOCAL ? 'browser' : 'user') + ' can see it.'
+ : 'Any logged-in user can join it.') +
+ (disabled ? '' : ' Click to change visibility.');
+ const message =
+ `Change the visibility of room from ${roomVisibility}` +
+ ` to ${getRoomOtherVisibility(roomVisibility)}?`;
+
+ return (
+
+ );
+};
diff --git a/src/client/components/main/sidebar/RoomCreate.tsx b/src/client/components/main/sidebar/RoomCreate.tsx
new file mode 100644
index 0000000..8967c12
--- /dev/null
+++ b/src/client/components/main/sidebar/RoomCreate.tsx
@@ -0,0 +1,58 @@
+import {
+ CLOUD,
+ LOCAL,
+ getNewRoomId,
+ useRoomJoinCallback,
+} from '../../../stores/rooms';
+import {CLOUD_DESCRIPTION, LOCAL_DESCRIPTION} from '../../../common';
+import React, {useCallback} from 'react';
+import {useUiSetRoomIdCallback, useUiUsername} from '../../../stores/UiStore';
+import {useModal} from '../../common/Modal';
+
+export const RoomCreate = () => {
+ const [Modal, showModal, hideModal] = useModal();
+
+ const setRoomId = useUiSetRoomIdCallback();
+ const then = useCallback(
+ (roomId: string) => {
+ hideModal();
+ setRoomId(roomId);
+ },
+ [hideModal, setRoomId],
+ );
+
+ const createLocalRoom = useRoomJoinCallback(LOCAL, getNewRoomId, then);
+ const createCloudRoom = useRoomJoinCallback(CLOUD, getNewRoomId, then);
+
+ const username = useUiUsername();
+
+ const createLocalOrShowModal = useCallback(
+ () => (username ? showModal() : createLocalRoom()),
+ [username, showModal, createLocalRoom],
+ );
+
+ return (
+ <>
+
+
+ What type of room would you like to create?
+
+
+
+
+
+ {LOCAL_DESCRIPTION}
+
+ {CLOUD_DESCRIPTION} You can convert rooms from one type to the other
+ whenever you are logged in.
+
+
+ >
+ );
+};
diff --git a/src/client/components/main/sidebar/RoomJoin.tsx b/src/client/components/main/sidebar/RoomJoin.tsx
new file mode 100644
index 0000000..dd021fa
--- /dev/null
+++ b/src/client/components/main/sidebar/RoomJoin.tsx
@@ -0,0 +1,28 @@
+import {CLOUD, useRoomJoinCallback} from '../../../stores/rooms';
+import React, {useCallback} from 'react';
+import {
+ useUiOnline,
+ useUiSetRoomIdCallback,
+ useUiUsername,
+} from '../../../stores/UiStore';
+
+export const RoomJoin = ({roomId}: {readonly roomId: string}) => {
+ const online = useUiOnline();
+ const username = useUiUsername();
+
+ const handleJoin = useRoomJoinCallback(
+ CLOUD,
+ useCallback(() => roomId, [roomId]),
+ useUiSetRoomIdCallback(),
+ );
+
+ return (
+
+ );
+};
diff --git a/src/client/components/main/sidebar/RoomLink.tsx b/src/client/components/main/sidebar/RoomLink.tsx
new file mode 100644
index 0000000..3b62ec7
--- /dev/null
+++ b/src/client/components/main/sidebar/RoomLink.tsx
@@ -0,0 +1,35 @@
+import {
+ CREATING,
+ RoomType,
+ useRoomName,
+ useRoomState,
+} from '../../../stores/rooms';
+import {FORBIDDEN} from '../../../common';
+import React from 'react';
+import {useUiSetRoomId} from '../../../stores/UiStore';
+
+export const RoomLink = ({
+ roomType,
+ roomId,
+ currentRoomId,
+}: {
+ readonly roomType: RoomType;
+ readonly roomId: string;
+ readonly currentRoomId: string;
+}) => {
+ const roomState = useRoomState(roomType, roomId);
+ const roomName = useRoomName(roomId) ?? '\u00A0';
+
+ const classes: string[] = [roomType, roomState];
+ if (roomId == currentRoomId) {
+ classes.push('current');
+ }
+
+ const handleClick = useUiSetRoomId(roomId);
+
+ return roomState == CREATING || roomState == FORBIDDEN ? null : (
+
+ {roomName}
+
+ );
+};
diff --git a/src/client/components/main/sidebar/Sidebar.tsx b/src/client/components/main/sidebar/Sidebar.tsx
new file mode 100644
index 0000000..a496d41
--- /dev/null
+++ b/src/client/components/main/sidebar/Sidebar.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import {RoomCreate} from './RoomCreate';
+import {RoomLink} from './RoomLink';
+import {Status} from './Status';
+import {useRoomsAllSortedIdsTypes} from '../../../stores/rooms';
+import {useUiRoomId} from '../../../stores/UiStore';
+
+export const Sidebar = () => {
+ const currentRoomId = useUiRoomId();
+ return (
+
+ );
+};
diff --git a/src/client/components/main/sidebar/Status.tsx b/src/client/components/main/sidebar/Status.tsx
new file mode 100644
index 0000000..a76eb65
--- /dev/null
+++ b/src/client/components/main/sidebar/Status.tsx
@@ -0,0 +1,12 @@
+import {useUiOnline, useUiUsername} from '../../../stores/UiStore';
+import React from 'react';
+
+export const Status = () => (
+
+
+ {useUiUsername() ? 'authenticated' : 'anonymous'}
+ {' & '}
+ {useUiOnline() ? 'online' : 'offline'}
+
+
+);
diff --git a/src/client/favicon.svg b/src/client/favicon.svg
new file mode 100644
index 0000000..2661cb7
--- /dev/null
+++ b/src/client/favicon.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/src/client/index.css b/src/client/index.css
new file mode 100644
index 0000000..8b55bd4
--- /dev/null
+++ b/src/client/index.css
@@ -0,0 +1,318 @@
+@font-face {
+ font-family: Inter;
+ src: url(/inter.woff2) format('woff2');
+ font-display: swap;
+}
+
+* {
+ margin: 0;
+ font-family: Inter;
+ font-size: inherit;
+ color: #445;
+ user-select: none;
+}
+
+html {
+ font-size: 1rem;
+}
+
+input,
+button {
+ background: #fff;
+ box-shadow: 0 0rem 0.15rem 0 #0000001a;
+ border: 1px solid #eee;
+ border-radius: 0.25rem;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ &:hover,
+ &.default,
+ &.current {
+ background-color: #e9e9e9;
+ border-color: #ccc;
+ }
+ &:disabled {
+ opacity: 0.4;
+ cursor: default;
+ &:hover {
+ background-color: #fff;
+ border-color: #eee;
+ }
+ }
+}
+
+p {
+ font-size: 0.8rem;
+ margin: 1rem;
+ text-align: center;
+ color: #667;
+}
+
+hr {
+ margin: 1rem 0;
+ border: solid #eee;
+ border-width: 1px 0 0 0;
+ width: 100%;
+}
+
+#app,
+#warning {
+ align-items: stretch;
+ background: #fff;
+ flex-direction: column;
+ height: 100vh;
+ justify-content: center;
+ margin: 0;
+ width: 100vw;
+}
+#app {
+ display: none;
+}
+#warning {
+ display: flex;
+ text-align: center;
+}
+
+@media only screen and (min-width: 48rem) {
+ #app {
+ display: flex;
+ }
+ #warning {
+ display: none;
+ }
+}
+
+#header {
+ height: 3rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ box-shadow: 0 0rem 0.3rem 0 #0000001a;
+ border: 1px solid #eee;
+ padding: 1rem;
+ z-index: 1;
+}
+
+#logo {
+ & img {
+ width: 3rem;
+ height: 3rem;
+ border-radius: 1.5rem;
+ display: inline-block;
+ border: 1px solid #eee;
+ box-shadow: 0 0rem 0.15rem 0 rgba(0, 0, 0, 0.1);
+ vertical-align: top;
+ margin: 0 0.5rem 0 0;
+ background: #fff;
+ }
+ & h1 {
+ line-height: 3rem;
+ display: inline-block;
+ font-size: 2rem;
+ }
+ & a {
+ padding: 0.25rem;
+ width: 1rem;
+ height: 1rem;
+ border: 0;
+ display: inline-block;
+ vertical-align: top;
+ &:hover {
+ border-radius: 0.25rem;
+ background-color: #e9e9e9;
+ }
+ }
+}
+
+#auth > button {
+ margin-left: 1rem;
+}
+
+#main {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+}
+
+#sidebar {
+ border-right: 1px solid #eee;
+ box-shadow: 0 0rem 0.3rem 0 rgba(0, 0, 0, 0.1);
+ align-self: stretch;
+ flex: 0 0 12rem;
+ display: flex;
+ flex-direction: column;
+ padding: 1rem;
+ background: #f9f9f9;
+ & button {
+ margin-bottom: 1rem;
+ }
+}
+
+#roomList {
+ flex: 1;
+ overflow: auto;
+ padding: 0;
+ width: 12rem;
+ & li {
+ margin-bottom: 1rem;
+ cursor: pointer;
+ padding: 0.5rem;
+ border-radius: 0.25rem;
+ line-height: 1rem;
+ font-weight: 400;
+ &:hover {
+ background-color: #e9e9e9;
+ }
+ &.current {
+ background-color: #d9d9d9;
+ cursor: unset;
+ }
+ }
+}
+
+#room {
+ align-self: stretch;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: stretch;
+ padding: 2rem;
+ & button {
+ max-width: 10rem;
+ margin: 0.5rem auto;
+ }
+}
+
+#roomHeader {
+ flex: 0;
+ display: flex;
+ & .roomName {
+ flex: 1;
+ display: flex;
+ & input {
+ font-weight: 600;
+ border-width: 0;
+ box-shadow: none;
+ font-size: 2rem;
+ padding: 0;
+ width: 100%;
+ }
+ }
+}
+
+#roomManage {
+ & button {
+ margin-left: 1rem;
+ width: 7rem;
+ }
+}
+
+#roomBody {
+ flex: 1;
+ margin-top: 1rem;
+ border: 1px solid #eee;
+}
+
+#overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ background: #333a;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ z-index: 10;
+ align-items: center;
+ justify-content: center;
+}
+
+#modal {
+ background: #fff;
+ box-shadow: 0 0rem 0.1rem 0 #0000001a;
+ border: 1px solid #eee;
+ border-radius: 0.25rem;
+ padding: 2rem;
+ text-align: center;
+ width: 22rem;
+ & b {
+ font-weight: 600;
+ }
+ & #buttons {
+ display: flex;
+ place-content: space-around;
+ }
+ & button.cancel {
+ display: block;
+ margin: -1rem -1rem -1rem auto;
+ padding: 0;
+ width: 2rem;
+ height: 2rem;
+ border: 0;
+ box-shadow: none;
+ &::before {
+ margin: 0 auto;
+ }
+ }
+}
+
+button::before,
+.local::before,
+.cloud::before {
+ margin: 0 0.5rem 0 0;
+ height: 1rem;
+ min-width: 1rem;
+ display: inline-block;
+ vertical-align: middle;
+ line-height: 1rem;
+}
+.local::before {
+ content: url('data:image/svg+xml,');
+}
+.cloud::before {
+ content: url('data:image/svg+xml,');
+}
+.cloud.open::before {
+ content: url('data:image/svg+xml,');
+}
+.test.alice::before {
+ content: '👩';
+}
+.test.bob::before {
+ content: '👨';
+}
+.test.carol::before {
+ content: '🧑';
+}
+.github::before {
+ content: url('data:image/svg+xml,');
+}
+.login::before {
+ content: url('data:image/svg+xml,');
+}
+.logout::before {
+ content: url('data:image/svg+xml,');
+}
+.create::before {
+ content: url('data:image/svg+xml,');
+}
+.join::before {
+ content: url('data:image/svg+xml,');
+}
+.leave.local::before {
+ content: url('data:image/svg+xml,');
+}
+.leave.cloud::before {
+ content: url('data:image/svg+xml,');
+}
+.public::before {
+ content: url('data:image/svg+xml,');
+}
+.private::before {
+ content: url('data:image/svg+xml,');
+}
+.what::before {
+ content: url('data:image/svg+xml,');
+}
+#modal button.cancel::before {
+ content: url('data:image/svg+xml,');
+}
diff --git a/src/client/index.html b/src/client/index.html
new file mode 100644
index 0000000..3df8ba1
--- /dev/null
+++ b/src/client/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ TinyRooms
+
+
+
+
+
+
+
+ Please use this app on a larger screen.
+
+
+
diff --git a/src/client/index.tsx b/src/client/index.tsx
new file mode 100644
index 0000000..b2225cf
--- /dev/null
+++ b/src/client/index.tsx
@@ -0,0 +1,14 @@
+import {APP_NAME} from '../config';
+import {App} from './App';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+
+window.addEventListener('load', async () => {
+ document.title = APP_NAME;
+
+ ReactDOM.createRoot(document.getElementById('app')!).render();
+
+ await navigator.serviceWorker.register('/worker.js', {
+ scope: location.origin,
+ });
+});
diff --git a/src/client/inter.woff2 b/src/client/inter.woff2
new file mode 100644
index 0000000..980853f
Binary files /dev/null and b/src/client/inter.woff2 differ
diff --git a/src/client/stores/RoomStore.tsx b/src/client/stores/RoomStore.tsx
new file mode 100644
index 0000000..b90daa2
--- /dev/null
+++ b/src/client/stores/RoomStore.tsx
@@ -0,0 +1,48 @@
+import {
+ CLOUD,
+ CLOUD_ROOM_STORE_PARTY,
+ RoomType,
+ getRoomStoreId,
+ useRoomSetStateCallback,
+} from './rooms';
+import {useCreateStore, useProvideStore} from 'tinybase/debug/ui-react';
+import {EditableValueView} from 'tinybase/debug/ui-react-dom';
+import {NAME_VALUE} from '../../common';
+import React from 'react';
+import {createStore} from 'tinybase';
+import {usePersisters} from '../common';
+
+export const RoomStore = ({
+ roomType,
+ roomId,
+ initialJson,
+}: {
+ readonly roomType: RoomType;
+ readonly roomId: string;
+ readonly initialJson: string;
+}) => {
+ const roomStoreId = getRoomStoreId(roomId);
+ const roomStore = useCreateStore(createStore);
+ const setRoomState = useRoomSetStateCallback(roomType, roomId);
+ if (initialJson) {
+ roomStore.setJson(initialJson);
+ }
+ useProvideStore(roomStoreId, roomStore);
+ usePersisters(
+ roomStore,
+ roomStoreId,
+ ...((roomType == CLOUD
+ ? [CLOUD_ROOM_STORE_PARTY, roomId, setRoomState]
+ : []) as any),
+ );
+ return null;
+};
+
+export const EditableRoomNameView = ({roomId}: {readonly roomId: string}) => (
+
+);
diff --git a/src/client/stores/RoomsStore.tsx b/src/client/stores/RoomsStore.tsx
new file mode 100644
index 0000000..5367d99
--- /dev/null
+++ b/src/client/stores/RoomsStore.tsx
@@ -0,0 +1,53 @@
+import {
+ CLOUD,
+ CLOUD_ROOMS_STORE_PARTY,
+ INITIAL_JSON_CELL,
+ RoomType,
+ getRoomsStoreId,
+} from './rooms';
+import {
+ useCreateStore,
+ useProvideStore,
+ useRowIds,
+} from 'tinybase/debug/ui-react';
+import {ROOMS_TABLE} from '../../common';
+import React from 'react';
+import {RoomStore} from './RoomStore';
+import {createStore} from 'tinybase';
+import {usePersisters} from '../common';
+import {useUiUsername} from './UiStore';
+
+/**
+ * The indexes of local and cloud rooms
+ */
+export const RoomsStore = ({roomType}: {roomType: RoomType}) => {
+ const username = useUiUsername();
+ const roomsStoreId = getRoomsStoreId(roomType);
+ const roomsStore = useCreateStore(createStore);
+ useProvideStore(roomsStoreId, roomsStore);
+ usePersisters(
+ roomsStore,
+ roomsStoreId,
+ ...(roomType == CLOUD ? [CLOUD_ROOMS_STORE_PARTY, username] : []),
+ );
+ return useRowIds(ROOMS_TABLE, roomsStore).map((roomId) => {
+ const initialJson = roomsStore.getCell(
+ ROOMS_TABLE,
+ roomId,
+ INITIAL_JSON_CELL,
+ ) as string;
+ if (initialJson) {
+ requestAnimationFrame(() =>
+ roomsStore.delCell(ROOMS_TABLE, roomId, INITIAL_JSON_CELL),
+ );
+ }
+ return (
+
+ );
+ });
+};
diff --git a/src/client/stores/UiStore.tsx b/src/client/stores/UiStore.tsx
new file mode 100644
index 0000000..b10abf9
--- /dev/null
+++ b/src/client/stores/UiStore.tsx
@@ -0,0 +1,97 @@
+import {useCallback, useEffect} from 'react';
+import {
+ useCreateStore,
+ useDelValueCallback,
+ useProvideStore,
+ useSetValueCallback,
+ useValue,
+ useValueListener,
+} from 'tinybase/debug/ui-react';
+import {createStore} from 'tinybase';
+import {getConnection} from '../common';
+
+const UI_STORE_ID = 'ui';
+
+const USERNAME_VALUE = 'username';
+const ONLINE_VALUE = 'online';
+const ROOM_ID_VALUE = 'roomId';
+
+/**
+ * The app's UI state
+ */
+export const UiStore = () => {
+ // Create Store
+ const uiStore = useCreateStore(createStore);
+
+ useProvideStore(UI_STORE_ID, uiStore);
+
+ // Update room if hash changes, and vice-versa
+ const handleHash = useCallback(
+ () => uiStore.setValue(ROOM_ID_VALUE, location.hash.slice(1)),
+ [uiStore],
+ );
+ useEffect(() => {
+ handleHash();
+ addEventListener('hashchange', handleHash);
+ return () => removeEventListener('hashchange', handleHash);
+ }, [handleHash]);
+ useValueListener(
+ ROOM_ID_VALUE,
+ (_stateStore, _valueId, roomId) =>
+ history.replaceState(null, '', '#' + roomId),
+ [],
+ false,
+ uiStore,
+ );
+
+ // Update Store if on/offline changes
+ useEffect(() => {
+ const [connection, destroyConnection] = getConnection(
+ undefined,
+ 'ping',
+ (state) => uiStore.setValue(ONLINE_VALUE, state == 'open'),
+ );
+ uiStore.setValue(ONLINE_VALUE, connection.readyState == connection.OPEN);
+ return destroyConnection;
+ }, [uiStore]);
+
+ // Update Store if authenticated username changes
+ useEffect(() => {
+ const fetchUsername = () =>
+ fetch('/parties/username').then((response) =>
+ response
+ .json()
+ .then((username: any) =>
+ username
+ ? uiStore.setValue(USERNAME_VALUE, username)
+ : uiStore.delValue(USERNAME_VALUE),
+ ),
+ );
+ fetchUsername();
+ const interval = setInterval(fetchUsername, 10000);
+ return () => clearInterval(interval);
+ }, [uiStore]);
+
+ return null;
+};
+
+export const useUiUsername = () =>
+ useValue(USERNAME_VALUE, UI_STORE_ID) as string;
+
+export const useUiDelUsernameCallback = () =>
+ useDelValueCallback(USERNAME_VALUE, UI_STORE_ID);
+
+export const useUiOnline = () => useValue(ONLINE_VALUE, UI_STORE_ID) as boolean;
+
+export const useUiRoomId = () => useValue(ROOM_ID_VALUE, UI_STORE_ID) as string;
+
+export const useUiSetRoomId = (roomId: string) =>
+ useSetValueCallback(ROOM_ID_VALUE, () => roomId, [roomId], UI_STORE_ID);
+
+export const useUiSetRoomIdCallback = () =>
+ useSetValueCallback(
+ ROOM_ID_VALUE,
+ (roomId: string) => roomId ?? '',
+ [],
+ UI_STORE_ID,
+ );
diff --git a/src/client/stores/UserStore.tsx b/src/client/stores/UserStore.tsx
new file mode 100644
index 0000000..812a7f0
--- /dev/null
+++ b/src/client/stores/UserStore.tsx
@@ -0,0 +1,87 @@
+import React, {MouseEventHandler, useCallback} from 'react';
+import {redirect, usePersisters} from '../common';
+import {
+ useCreateStore,
+ useProvideStore,
+ useValue,
+} from 'tinybase/debug/ui-react';
+import {useUiDelUsernameCallback, useUiUsername} from './UiStore';
+import {EditableValueView} from 'tinybase/debug/ui-react-dom';
+import {createStore} from 'tinybase';
+
+const USER_STORE_ID = 'user';
+
+const PROVIDER_VALUE = 'provider';
+const PROVIDER_USERNAME_VALUE = 'providerUsername';
+const NAME_VALUE = 'name';
+const AVATAR_VALUE = 'avatar';
+
+/**
+ * The user's profile
+ */
+export const UserStore = () => {
+ const userStore = useCreateStore(createStore);
+ useProvideStore(USER_STORE_ID, userStore);
+ usePersisters(userStore, USER_STORE_ID, USER_STORE_ID, useUiUsername());
+ return null;
+};
+
+export const useUserProvider = () =>
+ useValue(PROVIDER_VALUE, USER_STORE_ID) as string;
+
+export const useUserProviderUsername = () =>
+ useValue(PROVIDER_USERNAME_VALUE, USER_STORE_ID) as string;
+
+export const useUserName = () => useValue(NAME_VALUE, USER_STORE_ID) as string;
+
+export const useUserAvatar = () =>
+ useValue(AVATAR_VALUE, USER_STORE_ID) as string;
+
+export const useTestUserLoginCallback = (name: string) =>
+ useCallback>(
+ (event) => {
+ event.currentTarget.disabled = true;
+ const params = new URLSearchParams({
+ name: name,
+ uri: location.toString(),
+ });
+ redirect('/parties/auth/test?' + params.toString());
+ },
+ [name],
+ );
+
+export const useGitHubUserLoginCallback = () =>
+ useCallback>((event) => {
+ event.currentTarget.disabled = true;
+ const state = Math.random().toString();
+ sessionStorage.setItem('state', state);
+ const params = new URLSearchParams(location.search);
+ redirect(
+ '/parties/auth/github?' +
+ new URLSearchParams({
+ step: '0',
+ state,
+ uri:
+ location.origin +
+ '/auth.html' +
+ (params.size > 0 ? '?' + params.toString() : ''),
+ }).toString(),
+ );
+ }, []);
+
+export const useUserLogoutCallback = () => {
+ const delUsername = useUiDelUsernameCallback();
+ return useCallback(
+ () => fetch('/parties/logout').then(delUsername).catch(delUsername),
+ [delUsername],
+ );
+};
+
+export const EditableUsernameView = () => (
+
+);
diff --git a/src/client/stores/rooms.ts b/src/client/stores/rooms.ts
new file mode 100644
index 0000000..5e9a86c
--- /dev/null
+++ b/src/client/stores/rooms.ts
@@ -0,0 +1,263 @@
+import {
+ CREATED_CELL,
+ NAME_VALUE,
+ OWNER_VALUE,
+ PRIVATE,
+ PUBLIC,
+ ROOMS_TABLE,
+ STATE_CELL,
+ TYPE_CELL,
+ VISIBILITY_VALUE,
+} from '../../common';
+import {ConnectionState, FORBIDDEN, OPEN} from '../common';
+import {
+ useCell,
+ useDelRowCallback,
+ useDelTableCallback,
+ useHasRow,
+ useRowIds,
+ useSetCellCallback,
+ useSetValueCallback,
+ useStore,
+ useValue,
+} from 'tinybase/debug/ui-react';
+import {Row} from 'tinybase';
+import {nanoid} from 'nanoid';
+import {useCallback} from 'react';
+import {useUiSetRoomIdCallback} from './UiStore';
+
+export type RoomState = ConnectionState | typeof CREATING;
+export type RoomType = typeof LOCAL | typeof CLOUD;
+export type RoomVisibility = typeof PUBLIC | typeof PRIVATE;
+
+export const CREATING = 'creating';
+
+export const LOCAL = 'local';
+export const CLOUD = 'cloud';
+
+export const ROOM_STORE_ID_PREFIX = 'room/';
+
+export const CLOUD_ROOMS_STORE_PARTY = 'rooms';
+export const CLOUD_ROOM_STORE_PARTY = 'room';
+
+export const INITIAL_JSON_CELL = 'initialJson';
+
+const LOCAL_ROOMS_STORE_ID = 'local';
+const CLOUD_ROOMS_STORE_ID = 'cloud';
+
+export const useRoomsStore = (roomType: RoomType) =>
+ useStore(getRoomsStoreId(roomType));
+
+export const useRoomsAllSortedIdsTypes = (): [id: string, type: RoomType][] => {
+ const localRoomsStore = useRoomsStore(LOCAL);
+ const cloudRoomsStore = useRoomsStore(CLOUD);
+ return (
+ [
+ ...useRowIds(ROOMS_TABLE, localRoomsStore).map((id) => [
+ id,
+ localRoomsStore?.getRow(ROOMS_TABLE, id),
+ ]),
+ ...useRowIds(ROOMS_TABLE, cloudRoomsStore).map((id) => [
+ id,
+ cloudRoomsStore?.getRow(ROOMS_TABLE, id),
+ ]),
+ ] as [id: string, id: Row][]
+ )
+ .sort(([, row1], [, row2]) =>
+ row1[CREATED_CELL] > row2[CREATED_CELL] ? 1 : -1,
+ )
+ .map(([id, row]) => [id, row[TYPE_CELL] as RoomType]);
+};
+
+export const useRoomsHasRoom = (roomType: RoomType, roomId: string) =>
+ useHasRow(ROOMS_TABLE, roomId, getRoomsStoreId(roomType));
+
+export const useRoomsRemoveAllCallback = (roomType: RoomType) =>
+ useDelTableCallback(ROOMS_TABLE, getRoomsStoreId(roomType));
+
+export const useRoomJoinCallback = (
+ roomType: RoomType,
+ getRoomId: () => string,
+ then?: (roomId: string) => void,
+) => {
+ const roomsStore = useStore(getRoomsStoreId(roomType));
+ const otherRoomsStore = useStore(getRoomsStoreId(getRoomOtherType(roomType)));
+ return useCallback(() => {
+ const roomId = getRoomId();
+ roomsStore?.setRow(ROOMS_TABLE, roomId, {
+ [TYPE_CELL]: roomType,
+ [STATE_CELL]: roomType == LOCAL ? OPEN : CREATING,
+ [CREATED_CELL]: Date.now(),
+ [INITIAL_JSON_CELL]: JSON.stringify([
+ {},
+ {
+ [NAME_VALUE]:
+ 'Room ' +
+ (1 +
+ roomsStore.getRowCount(ROOMS_TABLE) +
+ (otherRoomsStore?.getRowCount(ROOMS_TABLE) ?? 0)),
+ [VISIBILITY_VALUE]: PRIVATE,
+ },
+ ]),
+ });
+ then?.(roomId);
+ return roomId;
+ }, [getRoomId, roomsStore, otherRoomsStore, roomType, then]);
+};
+
+export const useRoomsLeaveCallback = (
+ roomType: RoomType,
+ roomId: string,
+ then?: () => void,
+) => {
+ const deleteRoom = useDelRowCallback(
+ ROOMS_TABLE,
+ roomId,
+ getRoomsStoreId(roomType),
+ );
+ return useCallback(() => {
+ deleteRoom();
+ localStorage.removeItem(getRoomStoreId(roomId));
+ then?.();
+ }, [deleteRoom, roomId, then]);
+};
+
+export const useRoomsSetTopRoomIdCallback = () => {
+ const setRoomId = useUiSetRoomIdCallback();
+ const localStore = useRoomsStore(LOCAL);
+ const cloudStore = useRoomsStore(CLOUD);
+ return useCallback(() => {
+ const localRoomId = localStore?.getSortedRowIds(
+ ROOMS_TABLE,
+ CREATED_CELL,
+ )[0];
+ const cloudRoomId = cloudStore?.getSortedRowIds(
+ ROOMS_TABLE,
+ CREATED_CELL,
+ )[0];
+ if (localStore && localRoomId && cloudStore && cloudRoomId) {
+ setRoomId(
+ localStore.getCell(ROOMS_TABLE, localRoomId, CREATED_CELL)! <
+ cloudStore.getCell(ROOMS_TABLE, cloudRoomId, CREATED_CELL)!
+ ? localRoomId
+ : cloudRoomId,
+ );
+ } else if (cloudRoomId) {
+ setRoomId(cloudRoomId);
+ } else if (localRoomId) {
+ setRoomId(localRoomId);
+ }
+ }, [setRoomId, localStore, cloudStore]);
+};
+
+export const getRoomsStoreId = (roomType: RoomType) =>
+ roomType == LOCAL ? LOCAL_ROOMS_STORE_ID : CLOUD_ROOMS_STORE_ID;
+
+// --
+
+export const useRoomStore = (roomId: string) =>
+ useStore(getRoomStoreId(roomId));
+
+export const useRoomName = (roomId: string): string =>
+ useValue(NAME_VALUE, getRoomStoreId(roomId)) as string;
+
+export const useRoomOwner = (roomId: string): string =>
+ useValue(OWNER_VALUE, getRoomStoreId(roomId)) as string;
+
+export const useRoomVisibility = (roomId: string): RoomVisibility =>
+ useValue(VISIBILITY_VALUE, getRoomStoreId(roomId)) as RoomVisibility;
+
+export const useRoomSetTypeCallback = (
+ oldRoomType: RoomType,
+ oldRoomId: string,
+ then?: (roomId: string) => void,
+) => {
+ const oldRoomsStore = useRoomsStore(oldRoomType);
+ const oldRoomStore = useRoomStore(oldRoomId);
+ const newRoomType = getRoomOtherType(oldRoomType);
+ const newRoomsStore = useRoomsStore(newRoomType);
+
+ const leaveOldRoom = useRoomsLeaveCallback(oldRoomType, oldRoomId);
+
+ return useCallback(
+ (roomType: RoomType) => {
+ if (roomType != oldRoomType) {
+ const newRoomId = getNewRoomId();
+ const room = oldRoomsStore?.getRow(ROOMS_TABLE, oldRoomId) ?? {};
+ oldRoomStore?.setValue(VISIBILITY_VALUE, PRIVATE);
+ leaveOldRoom();
+ newRoomsStore?.setRow(ROOMS_TABLE, newRoomId, {
+ ...room,
+ [TYPE_CELL]: newRoomType,
+ [INITIAL_JSON_CELL]: oldRoomStore?.getJson() ?? '',
+ });
+ then?.(newRoomId);
+ return newRoomId;
+ }
+ },
+ [
+ oldRoomType,
+ oldRoomId,
+ oldRoomsStore,
+ oldRoomStore,
+ newRoomType,
+ newRoomsStore,
+ leaveOldRoom,
+ then,
+ ],
+ );
+};
+
+export const useRoomSetVisibilityCallback = (roomId: string) =>
+ useSetValueCallback(
+ VISIBILITY_VALUE,
+ (visibility: RoomVisibility) => visibility,
+ [],
+ getRoomStoreId(roomId),
+ );
+
+export const useRoomType = (roomId: string): RoomType | null => {
+ const isLocal = useHasRow(ROOMS_TABLE, roomId, LOCAL_ROOMS_STORE_ID);
+ const isCloud = useHasRow(ROOMS_TABLE, roomId, CLOUD_ROOMS_STORE_ID);
+ return isLocal ? LOCAL : isCloud ? CLOUD : null;
+};
+
+export const useRoomState = (roomType: RoomType, roomId: string) =>
+ useCell(
+ ROOMS_TABLE,
+ roomId,
+ STATE_CELL,
+ getRoomsStoreId(roomType),
+ ) as RoomState;
+
+export const useRoomSetStateCallback = (roomType: RoomType, roomId: string) => {
+ const leaveRoom = useRoomsLeaveCallback(
+ roomType,
+ roomId,
+ useRoomsSetTopRoomIdCallback(),
+ );
+ return useSetCellCallback(
+ ROOMS_TABLE,
+ roomId,
+ STATE_CELL,
+ (state: RoomState) => state,
+ [],
+ getRoomsStoreId(roomType),
+ (_, state) => {
+ if (state == FORBIDDEN) {
+ leaveRoom();
+ }
+ },
+ );
+};
+
+export const getRoomStoreId = (roomId: string) => ROOM_STORE_ID_PREFIX + roomId;
+
+export const getRoomOtherType = (roomType: RoomType): RoomType =>
+ roomType == CLOUD ? LOCAL : CLOUD;
+
+export const getRoomOtherVisibility = (
+ visibility: RoomVisibility,
+): RoomVisibility => (visibility == PUBLIC ? PRIVATE : PUBLIC);
+
+export const getNewRoomId = () => nanoid(14).replace(/_/g, '-');
diff --git a/src/client/worker.ts b/src/client/worker.ts
new file mode 100644
index 0000000..d00b074
--- /dev/null
+++ b/src/client/worker.ts
@@ -0,0 +1,89 @@
+declare const self: ServiceWorkerGlobalScope;
+export {};
+
+const CACHE = 'tinyRoomsCache';
+
+const preCacheResources = [
+ '/',
+ '/favicon.svg',
+ '/index.js',
+ '/index.css',
+ '/inter.woff2',
+ '/_shapes/index.css',
+];
+
+self.addEventListener('install', (event) =>
+ event.waitUntil(
+ (async () => await (await caches.open(CACHE)).addAll(preCacheResources))(),
+ ),
+);
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener('fetch', (event) =>
+ event.respondWith(
+ (async () => {
+ const {request} = event;
+ const pathname = new URL(request.url).pathname;
+
+ // For login details, try network (updating cache) and fallback to cache
+ if (pathname == '/parties/username') {
+ return await tryResponse(request, fetchNetworkAndCache, fetchCache);
+ }
+
+ // For logout, if offline, nullify the login details in the cache
+ if (pathname == '/parties/logout') {
+ return await tryResponse(request, fetchNetwork, async (request) => {
+ await (
+ await caches.open(CACHE)
+ ).put(
+ request.url.replace('/logout', '/username'),
+ newResponse(200, 'null'),
+ );
+ return newResponse(200);
+ });
+ }
+
+ // Otherwise try cache and fallback to network (without updating cache)
+ return await tryResponse(request, fetchCache, fetchNetwork);
+ })(),
+ ),
+);
+
+const tryResponse = async (
+ request: Request,
+ fetch1: (request: Request) => Promise,
+ fetch2: (request: Request) => Promise,
+) => {
+ try {
+ return await fetch1(request);
+ } catch {
+ try {
+ return await fetch2(request);
+ } catch {}
+ }
+ return newResponse(404);
+};
+
+const fetchCache = async (request: Request): Promise => {
+ const response = await (await caches.open(CACHE)).match(request.url);
+ if (response) {
+ return response;
+ }
+ throw new Error();
+};
+
+const fetchNetwork = async (request: Request): Promise => {
+ return await fetch(request);
+};
+
+const fetchNetworkAndCache = async (request: Request): Promise => {
+ const response = await fetchNetwork(request);
+ (await caches.open(CACHE)).put(request, response.clone());
+ return response;
+};
+
+const newResponse = (status: number, body: string | null = null) =>
+ new Response(body, {status});
diff --git a/src/common.ts b/src/common.ts
new file mode 100644
index 0000000..7944267
--- /dev/null
+++ b/src/common.ts
@@ -0,0 +1,14 @@
+export const ROOMS_TABLE = 'rooms';
+export const TYPE_CELL = 'type';
+export const STATE_CELL = 'state';
+export const CREATED_CELL = 'created';
+
+export const NAME_VALUE = 'name';
+export const OWNER_VALUE = 'owner';
+export const VISIBILITY_VALUE = 'visibility';
+
+export const PUBLIC = 'public';
+export const PRIVATE = 'private';
+
+export const FORBIDDEN_MESSAGE = 'forbidden';
+export const FORBIDDEN_CODE = 1008;
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..50fc8aa
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,11 @@
+export const APP_NAME = 'TinyRooms';
+
+// The authentication options available. If using GitHub, ensure your app client
+// Id and secret is provided in partykit.json or your environment variables.
+export const TEST_LOGIN = true;
+export const GITHUB_LOGIN = false;
+
+// The PartyKit client/server communication and storage configuration.
+export const STORE_PATH = '/tinybase';
+export const MESSAGE_PREFIX = 'tinybase_';
+export const STORAGE_PREFIX = 'tinybase_';
diff --git a/src/server/AuthServer.ts b/src/server/AuthServer.ts
new file mode 100644
index 0000000..e1c1d08
--- /dev/null
+++ b/src/server/AuthServer.ts
@@ -0,0 +1,62 @@
+import {GITHUB_LOGIN, TEST_LOGIN} from '../config';
+import {Party, Request, Server} from 'partykit/server';
+import UserServer, {User} from './UserServer';
+import {getTokenHeaders, newResponse, redirect} from './common';
+import {getGitHubProvider} from './oauth/github';
+
+/**
+ * This PartyKit server is responsible for authenticating a user, either by
+ * proxying OAuth requests, or assigning test users. It serves the /auth/github/
+ * and /auth/test/ URLs.
+ *
+ * You could add extra OAuth providers or any other authentication technique
+ * here - as long as you call UserServer.createUser at the end of the flow.
+ */
+export default class AuthServer implements Server {
+ constructor(readonly party: Party) {}
+
+ async onRequest(request: Request): Promise {
+ const params = new URL(request.url).searchParams;
+ const uri = params.get('uri') ?? '/';
+
+ if (this.party.id == 'github' && GITHUB_LOGIN) {
+ const [redirectToAuthorize, getUser] = getGitHubProvider(this.party.env);
+ switch (params.get('step') ?? '0') {
+ case '0': {
+ return redirectToAuthorize(params.get('state') ?? '', uri);
+ }
+ case '1': {
+ const code = params.get('code');
+ if (code) {
+ const user = await getUser(code);
+ const username = encodeURIComponent(
+ user.provider + '-' + user.providerUsername,
+ );
+ user.username = username;
+ await UserServer.createUser(this.party.context, username, user);
+ return redirect(uri, await getTokenHeaders(this.party, username));
+ }
+ }
+ }
+ }
+
+ if (this.party.id == 'test' && TEST_LOGIN) {
+ const name = params.get('name') ?? 'Alice';
+ const provider = 'test';
+ const providerUsername = name.toLowerCase();
+ const username = provider + '-' + providerUsername;
+ const user: User = {
+ username,
+ provider,
+ providerUsername,
+ name,
+ avatar: '',
+ accessToken: '',
+ };
+ await UserServer.createUser(this.party.context, username, user);
+ return redirect(uri, await getTokenHeaders(this.party, username));
+ }
+
+ return newResponse(404);
+ }
+}
diff --git a/src/server/MainServer.ts b/src/server/MainServer.ts
new file mode 100644
index 0000000..2422ed9
--- /dev/null
+++ b/src/server/MainServer.ts
@@ -0,0 +1,51 @@
+import {
+ Connection,
+ ConnectionContext,
+ FetchLobby,
+ Party,
+ Request,
+ Server,
+} from 'partykit/server';
+import {getTokenContent, newResponse, setCookie} from './common';
+
+/**
+ * This PartyKit server is responsible for handling top-level API calls, such as
+ * getting the logged in username (from a JWT cookie), logging users out, and a
+ * long-running 'ping' socket to help clients identify if they are online.
+ */
+export default class MainServer implements Server {
+ constructor(readonly party: Party) {}
+
+ readonly options = {
+ hibernate: false,
+ };
+
+ static async onFetch(request: Request, lobby: FetchLobby): Promise {
+ const username = await getTokenContent(lobby, request);
+ const url = new URL(request.url);
+
+ if (url.pathname == '/parties/username') {
+ return newResponse(200, JSON.stringify(username));
+ }
+
+ if (url.pathname == '/parties/logout') {
+ return newResponse(200, '', setCookie('token', ''));
+ }
+
+ return newResponse(404);
+ }
+
+ static async onBeforeRequest(): Promise {
+ return newResponse(404);
+ }
+
+ static async onBeforeConnect(
+ connection: Connection,
+ {request}: ConnectionContext,
+ ): Promise {
+ if (connection.url && new URL(connection.url).pathname == '/party/ping') {
+ return request;
+ }
+ return newResponse(404);
+ }
+}
diff --git a/src/server/RoomServer.ts b/src/server/RoomServer.ts
new file mode 100644
index 0000000..08ba99a
--- /dev/null
+++ b/src/server/RoomServer.ts
@@ -0,0 +1,144 @@
+import {
+ Request as CfRequest,
+ Connection,
+ ConnectionContext,
+ Lobby,
+ Party,
+} from 'partykit/server';
+import {
+ FORBIDDEN_CODE,
+ FORBIDDEN_MESSAGE,
+ OWNER_VALUE,
+ PUBLIC,
+ VISIBILITY_VALUE,
+} from '../common';
+import {Id, Value} from 'tinybase';
+import {MESSAGE_PREFIX, STORAGE_PREFIX, STORE_PATH} from '../config';
+import {
+ TinyBasePartyKitServer,
+ TinyBasePartyKitServerConfig,
+ broadcastTransactionChanges,
+} from 'tinybase/persisters/persister-partykit-server';
+import {getTokenContent, newResponse} from './common';
+
+/**
+ * This PartyKit server is responsible for storing (and collaborating on)
+ * individual rooms. It serves the /room// URLs.
+ *
+ * Rooms stored here have a few essential values: the owner's username, the
+ * room's visibility, and its name. Otherwise, the content of this can be
+ * anything that's required as the data structure of a 'room' - such as the
+ * position of shapes on a canvas.
+ *
+ * To save to this server, a client must be a room's owner (if it is private) or
+ * logged in (if it is public). The owner cannot be changed, and the visibility
+ * can only be changed by the owner.
+ */
+export default class RoomServer extends TinyBasePartyKitServer {
+ constructor(readonly party: Party) {
+ super(party);
+ }
+
+ readonly options = {
+ hibernate: true,
+ };
+
+ readonly config: TinyBasePartyKitServerConfig = {
+ storePath: STORE_PATH,
+ storagePrefix: STORAGE_PREFIX,
+ messagePrefix: MESSAGE_PREFIX,
+ };
+
+ static async onBeforeRequest(
+ request: CfRequest,
+ lobby: Lobby,
+ ): Promise {
+ return (await getTokenContent(lobby, request)) != null
+ ? request
+ : newResponse(403);
+ }
+
+ static async onBeforeConnect(
+ request: CfRequest,
+ lobby: Lobby,
+ ): Promise {
+ return RoomServer.onBeforeRequest(request, lobby);
+ }
+
+ async onRequest(request: CfRequest): Promise {
+ const [isPublic, owner, username] = await getPolicy(this, request);
+ if ((!isPublic && owner !== username) || !username) {
+ return newResponse(403);
+ }
+ return super.onRequest(request);
+ }
+
+ async onConnect(connection: Connection, {request}: ConnectionContext) {
+ const [, , username] = await getPolicy(this, request);
+ connection.setState({username});
+ await validateConnections(this);
+ }
+
+ async onMessage(
+ message: string,
+ connection: Connection<{username: string}>,
+ ): Promise {
+ await validateConnections(this);
+ if (connection.readyState == WebSocket.OPEN) {
+ await super.onMessage(message, connection);
+ await validateConnections(this);
+ }
+ }
+
+ async canSetValue(
+ valueId: string,
+ _value: Value,
+ initialSave: boolean,
+ requestOrConnection: CfRequest | Connection,
+ ): Promise {
+ if (valueId == OWNER_VALUE) {
+ return false;
+ }
+ if (valueId == VISIBILITY_VALUE && !initialSave) {
+ const [, owner] = await getPolicy(this);
+ return (
+ owner ==
+ (requestOrConnection as Connection<{username: string}>).state?.username
+ );
+ }
+ return true;
+ }
+}
+
+const getPolicy = async (that: RoomServer, request?: CfRequest) => {
+ const username = request ? await getTokenContent(that.party, request) : '';
+ let owner = await that.party.storage.get(getValueStorageKey(OWNER_VALUE));
+ if (owner == null && username) {
+ await that.party.storage.put(getValueStorageKey(OWNER_VALUE), username);
+ owner = username;
+ setTimeout(
+ () => broadcastTransactionChanges(that, [{}, {owner} as {owner: string}]),
+ 100,
+ );
+ }
+ return [
+ (await that.party.storage.get(getValueStorageKey(VISIBILITY_VALUE))) ==
+ PUBLIC,
+ owner,
+ username,
+ ];
+};
+
+const getValueStorageKey = (valueId: Id) => STORAGE_PREFIX + 'v' + valueId;
+
+const validateConnections = async (that: RoomServer): Promise => {
+ const [isPublic, owner] = await getPolicy(that);
+ [...that.party.getConnections<{username: string}>()].forEach((connection) => {
+ const {username} = connection.state ?? {};
+ if (!isPublic && owner !== username) {
+ connection.setState(null);
+ connection.send(FORBIDDEN_MESSAGE);
+ connection.close(FORBIDDEN_CODE);
+ }
+ });
+};
diff --git a/src/server/RoomsServer.ts b/src/server/RoomsServer.ts
new file mode 100644
index 0000000..4e5d42e
--- /dev/null
+++ b/src/server/RoomsServer.ts
@@ -0,0 +1,80 @@
+import {CREATED_CELL, ROOMS_TABLE, STATE_CELL, TYPE_CELL} from '../common';
+import {
+ Request as CfRequest,
+ Connection,
+ ConnectionContext,
+ Lobby,
+ Party,
+} from 'partykit/server';
+import {MESSAGE_PREFIX, STORAGE_PREFIX, STORE_PATH} from '../config';
+import {
+ TinyBasePartyKitServer,
+ TinyBasePartyKitServerConfig,
+} from 'tinybase/persisters/persister-partykit-server';
+import {getTokenContent, newResponse} from './common';
+
+/**
+ * This PartyKit server is responsible for maintaining a list of all the rooms
+ * each user owns. It serves the /rooms// URLs.
+ *
+ * It has a single table containing the rooms, by Id. Each has 'type' (which
+ * is always 'cloud' here on the server), 'state', and 'created' cells.
+ */
+export default class RoomsServer extends TinyBasePartyKitServer {
+ constructor(readonly party: Party) {
+ super(party);
+ }
+
+ readonly options = {
+ hibernate: true,
+ };
+
+ readonly config: TinyBasePartyKitServerConfig = {
+ storePath: STORE_PATH,
+ storagePrefix: STORAGE_PREFIX,
+ messagePrefix: MESSAGE_PREFIX,
+ };
+
+ static async onBeforeRequest(
+ request: CfRequest,
+ lobby: Lobby,
+ ): Promise {
+ return lobby.id === (await getTokenContent(lobby, request))
+ ? request
+ : newResponse(403);
+ }
+
+ static async onBeforeConnect(
+ request: CfRequest,
+ lobby: Lobby,
+ ): Promise {
+ return RoomsServer.onBeforeRequest(request, lobby);
+ }
+
+ async onRequest(request: CfRequest): Promise {
+ return super.onRequest(request);
+ }
+
+ async onConnect(connection: Connection, {request}: ConnectionContext) {
+ const username = await getTokenContent(this.party, request);
+ connection.setState({username});
+ }
+
+ async canSetTable(tableId: string): Promise {
+ return tableId == ROOMS_TABLE;
+ }
+
+ async canSetCell(
+ _tableId: string,
+ _rowId: string,
+ cellId: string,
+ ): Promise {
+ return (
+ cellId == TYPE_CELL || cellId == STATE_CELL || cellId == CREATED_CELL
+ );
+ }
+
+ async canSetValue(): Promise {
+ return false;
+ }
+}
diff --git a/src/server/UserServer.ts b/src/server/UserServer.ts
new file mode 100644
index 0000000..12809a0
--- /dev/null
+++ b/src/server/UserServer.ts
@@ -0,0 +1,90 @@
+import {
+ Request as CfRequest,
+ Connection,
+ ConnectionContext,
+ Context,
+ Lobby,
+ Party,
+} from 'partykit/server';
+import {MESSAGE_PREFIX, STORAGE_PREFIX, STORE_PATH} from '../config';
+import {PUT, getTokenContent, newResponse} from './common';
+import {
+ TinyBasePartyKitServer,
+ TinyBasePartyKitServerConfig,
+} from 'tinybase/persisters/persister-partykit-server';
+import {Value} from 'tinybase';
+
+export type User = {
+ username: string;
+ provider: 'github' | 'test';
+ providerUsername: string;
+ name: string;
+ avatar: string;
+ accessToken: string;
+};
+
+/**
+ * This PartyKit server is responsible for storing each user's account. It
+ * serves the /user// URLs.
+ *
+ * It has a set of values containing the user's profile information. Most of
+ * this is populated by the AuthServer (via createUser) when the user first logs
+ * in, but the logged-in user can subsequently change the name field.
+ */
+export default class UserServer extends TinyBasePartyKitServer {
+ constructor(readonly party: Party) {
+ super(party);
+ }
+
+ readonly options = {
+ hibernate: true,
+ };
+
+ readonly config: TinyBasePartyKitServerConfig = {
+ storePath: STORE_PATH,
+ storagePrefix: STORAGE_PREFIX,
+ messagePrefix: MESSAGE_PREFIX,
+ };
+
+ static async createUser(context: Context, username: string, user: User) {
+ return await (
+ await context.parties.user.get(username).fetch(STORE_PATH, {
+ method: PUT,
+ body: JSON.stringify([{}, user]),
+ })
+ ).text();
+ }
+
+ static async onBeforeRequest(
+ request: CfRequest,
+ lobby: Lobby,
+ ): Promise {
+ return lobby.id === (await getTokenContent(lobby, request))
+ ? request
+ : newResponse(403);
+ }
+
+ static async onBeforeConnect(
+ request: CfRequest,
+ lobby: Lobby,
+ ): Promise {
+ return UserServer.onBeforeRequest(request, lobby);
+ }
+
+ async onConnect(connection: Connection, {request}: ConnectionContext) {
+ const username = await getTokenContent(this.party, request);
+ connection.setState({username});
+ }
+
+ async canSetTable(): Promise {
+ return false;
+ }
+
+ async canSetValue(
+ valueId: string,
+ _value: Value,
+ initialSave: boolean,
+ ): Promise {
+ return initialSave || valueId == 'name';
+ }
+}
diff --git a/src/server/common.ts b/src/server/common.ts
new file mode 100644
index 0000000..de19566
--- /dev/null
+++ b/src/server/common.ts
@@ -0,0 +1,57 @@
+import {FetchLobby, Party, Request} from 'partykit/server';
+import jwt from '@tsndr/cloudflare-worker-jwt';
+
+export const GET = 'GET';
+export const PUT = 'PUT';
+export const POST = 'POST';
+
+const TOKEN = 'token';
+
+export const newResponse = (
+ status: number,
+ body: string | null = null,
+ headers: HeadersInit = {},
+) => new Response(body, {status, headers});
+
+export const redirect = (location: string, headers: HeadersInit = {}) =>
+ new Response('', {status: 302, headers: {...headers, location}});
+
+export const setCookie = (key: string, value: string): HeadersInit => ({
+ 'set-cookie': `${key}=${value};samesite=strict;httponly;path=/`,
+});
+
+export const getCookies = (request: Request): {[key: string]: string} => {
+ const cookies: {[key: string]: string} = {};
+ (request.headers.get('cookie') ?? '').split(',').forEach((header) => {
+ header.split(';').forEach((part) => {
+ const [key, value] = part.split('=');
+ if (key) {
+ cookies[key] = value;
+ }
+ });
+ });
+ return cookies;
+};
+
+export const getTokenHeaders = async (
+ party: Party,
+ username: string,
+): Promise => {
+ const now = Math.floor(Date.now() / 1000);
+ const token = await jwt.sign(
+ {username, iat: now, exp: now + 7200},
+ party.env.jwtSecret as string,
+ );
+ return setCookie(TOKEN, token);
+};
+
+export const getTokenContent = async (
+ lobbyOrParty: FetchLobby | Party,
+ request: Request,
+): Promise => {
+ const token = getCookies(request)[TOKEN];
+ return token &&
+ (await jwt.verify(token, lobbyOrParty.env.jwtSecret as string))
+ ? jwt.decode(token).payload?.username
+ : null;
+};
diff --git a/src/server/oauth/github.ts b/src/server/oauth/github.ts
new file mode 100644
index 0000000..e9aa084
--- /dev/null
+++ b/src/server/oauth/github.ts
@@ -0,0 +1,66 @@
+import {User} from '../UserServer';
+import {redirect} from '../common';
+
+type Env = {[key: string]: any};
+
+const PROVIDER = 'github';
+const HEADERS = {
+ 'user-agent': 'PartyKit',
+ accept: 'application/vnd.github+json',
+};
+
+export const getGitHubProvider = (
+ env: Env,
+): [
+ redirectToAuthorize: (state: string, uri: string) => Response,
+ getUser: (code: string) => Promise,
+] => {
+ const redirectToAuthorize = (state: string, uri: string) =>
+ redirect(
+ 'https://github.com/login/oauth/authorize?' +
+ new URLSearchParams({
+ scope: 'gist',
+ type: 'user_agent',
+ client_id: env.githubClientId,
+ state,
+ redirect_uri: uri,
+ }).toString(),
+ );
+
+ const getUser = async (code: string): Promise => {
+ const {access_token: accessToken} = (await (
+ await fetch(
+ 'https://github.com/login/oauth/access_token/?' +
+ new URLSearchParams({
+ client_id: env.githubClientId,
+ client_secret: env.githubClientSecret,
+ code,
+ }).toString(),
+ {method: 'POST', headers: HEADERS},
+ )
+ ).json()) as any;
+ const {
+ login: providerUsername,
+ name,
+ avatar_url: avatar,
+ } = (await (
+ await fetch('https://api.github.com/user', {
+ headers: {
+ ...HEADERS,
+ authorization: `Bearer ${accessToken}`,
+ },
+ })
+ ).json()) as any;
+
+ return {
+ username: '',
+ provider: PROVIDER,
+ providerUsername,
+ name,
+ avatar,
+ accessToken,
+ };
+ };
+
+ return [redirectToAuthorize, getUser];
+};
diff --git a/src/tsconfig.json b/src/tsconfig.json
new file mode 100644
index 0000000..689bffa
--- /dev/null
+++ b/src/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "alwaysStrict": true,
+ "noImplicitAny": true,
+ "noImplicitThis": true,
+ "strict": true,
+ "removeComments": true,
+ "module": "es2020",
+ "target": "es2020",
+ "jsx": "react",
+ "moduleResolution": "node",
+ "allowSyntheticDefaultImports": true,
+ "types": ["@cloudflare/workers-types"],
+ "lib": ["es2020", "dom", "webworker"],
+ "skipLibCheck": true
+ }
+}