Skip to content

Commit

Permalink
feat: React hooks for Spaces (#233)
Browse files Browse the repository at this point in the history
* feat: add `SpacesProvider` and `useSpaces` (#198)

* feat: add `useLock` and `useLocks` hooks (#211)

* feat: add `useLocations` hook (#206)

* feat: `useMembers` implementation (#203)

* feat: add `useCursors` hook (#210)

* feat: add tests (#217)

* feat: slide deck demo with hooks (#219)

* fix: `useLocations`,`useMembers` hook overload (#232)

* docs: react hook for spaces (#228)

---------

Co-authored-by: Mark Hulbert <39801222+m-hulbert@users.noreply.github.com>
  • Loading branch information
ttypic and m-hulbert authored Oct 25, 2023
1 parent fa95f9f commit 05b6710
Show file tree
Hide file tree
Showing 45 changed files with 3,296 additions and 341 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ module.exports = {
// security/detect-object-injection just gives a lot of false positives
// see https://github.com/nodesecurity/eslint-plugin-security/issues/21
'security/detect-object-injection': 'off',
// the code problem checked by this ESLint rule is automatically checked by the TypeScript compiler
'no-redeclare': 'off',
},
overrides: [
{
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ const client = new Ably.Realtime.Promise({ key: "<API-key>", clientId: "<client-
const spaces = new Spaces(client);
```

## Spaces for React Developers

A set of React Hooks are available which make it seamless to use Spaces in any React application. See the [React Hooks documentation](/docs/react.md) for further details.

## Creating a new Space

A space is the virtual area of your application where you want to enable synchronous collaboration. A space can be anything from a web page, a sheet within a spreadsheet, an individual slide in a slideshow, or the slideshow itself. A space has a participant state containing online and recently left members, their profile details, their locations and any locks they have acquired for the UI components.
Expand Down
3 changes: 3 additions & 0 deletions __mocks__/ably/promises/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ function createMockChannel() {
return () => mockHistory;
})(),
subscribe: () => {},
unsubscribe: () => {},
on: () => {},
off: () => {},
publish: () => {},
subscriptions: createMockEmitter(),
};
Expand Down
105 changes: 69 additions & 36 deletions demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@
"deploy:production": "npm run build && netlify deploy --prod"
},
"dependencies": {
"@ably-labs/react-hooks": "^3.0.0-canary.1",
"@ably/spaces": "0.1.3",
"ably": "^1.2.44",
"@ably/spaces": "file:..",
"ably": "^1.2.45",
"classnames": "^2.3.2",
"dayjs": "^1.11.9",
"nanoid": "^4.0.2",
Expand Down
25 changes: 14 additions & 11 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useMembers, useSpace, useLocations } from '@ably/spaces/react';

import { Header, SlideMenu, SpacesContext, CurrentSlide, AblySvg, slides, Modal } from './components';
import { Header, SlideMenu, CurrentSlide, AblySvg, slides, Modal } from './components';
import { getRandomName, getRandomColor } from './utils';
import { useMembers } from './hooks';
import { PreviewProvider } from './components/PreviewContext.tsx';

import { type Member } from './utils/types';

const App = () => {
const space = useContext(SpacesContext);
const { space, enter } = useSpace();
const { self, others } = useMembers();
const { update } = useLocations();
const [isModalVisible, setModalIsVisible] = useState(false);

useEffect(() => {
if (!space || self?.profileData.name) return;

const enter = async () => {
const init = async () => {
const name = getRandomName();
await space.enter({ name, color: getRandomColor() });
await space.locations.set({ slide: `${0}`, element: null });
await enter({ name, color: getRandomColor() });
await update({ slide: `${0}`, element: null });
setModalIsVisible(true);
};

enter();
init();
}, [space, self?.profileData.name]);

return (
<div className="min-w-[375px]">
<Header
self={self}
others={others}
self={self as Member}
others={others as Member[]}
/>
<div className="text-ably-charcoal-grey bg-slate-500">
<main>
Expand All @@ -50,7 +53,7 @@ const App = () => {
</a>
</div>
<Modal
self={self}
self={self as Member}
isVisible={isModalVisible}
setIsVisible={setModalIsVisible}
/>
Expand Down
3 changes: 2 additions & 1 deletion demo/src/components/CurrentSlide.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useRef } from 'react';
import { Cursors } from '.';
import { useMembers, useTrackCursor } from '../hooks';
import { useTrackCursor } from '../hooks';
import { SlidePreviewProps } from './SlidePreview';
import { useMembers } from '@ably/spaces/react';

interface Props {
slides: Omit<SlidePreviewProps, 'index'>[];
Expand Down
76 changes: 19 additions & 57 deletions demo/src/components/Cursors.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,27 @@
import { useContext, useEffect, useState } from 'react';
import type { CursorUpdate as _CursorUpdate } from '@ably/spaces';

import { useCursors } from '@ably/spaces/react';
import cn from 'classnames';
import { CursorSvg, SpacesContext } from '.';
import { useMembers, CURSOR_ENTER, CURSOR_LEAVE, CURSOR_MOVE } from '../hooks';

type state = typeof CURSOR_ENTER | typeof CURSOR_LEAVE | typeof CURSOR_MOVE;
type CursorUpdate = Omit<_CursorUpdate, 'data'> & { data: { state: state } };
import { CursorSvg } from '.';
import { CURSOR_LEAVE } from '../hooks';

export const Cursors = () => {
const space = useContext(SpacesContext);
const { self, others } = useMembers();
const [cursors, setCursors] = useState<{
[connectionId: string]: { position: CursorUpdate['position']; state: CursorUpdate['data']['state'] };
}>({});

useEffect(() => {
if (!space || !others) return;

space.cursors.subscribe('update', (cursorUpdate) => {
const { connectionId, position, data } = cursorUpdate as CursorUpdate;

if (cursorUpdate.connectionId === self?.connectionId) return;

setCursors((currentCursors) => ({
...currentCursors,
[connectionId]: { position, state: data.state },
}));
const { space, cursors } = useCursors({ returnCursors: true });

const activeCursors = Object.keys(cursors)
.filter((connectionId) => {
const { member, cursorUpdate } = cursors[connectionId]!!;
return (
member.connectionId !== space.connectionId && member.isConnected && cursorUpdate.data.state !== CURSOR_LEAVE
);
})
.map((connectionId) => {
const { member, cursorUpdate } = cursors[connectionId]!!;
return {
connectionId: member.connectionId,
profileData: member.profileData,
position: cursorUpdate.position,
};
});

return () => {
space.cursors.unsubscribe('update');
};
}, [space, others, self?.connectionId]);

useEffect(() => {
const handler = async (member: { connectionId: string }) => {
setCursors((currentCursors) => ({
...currentCursors,
[member.connectionId]: { position: { x: 0, y: 0 }, state: CURSOR_LEAVE },
}));
};

space?.members.subscribe('leave', handler);

return () => {
space?.members.unsubscribe('leave', handler);
};
}, [space]);

const activeCursors = others
.filter(
(member) =>
member.isConnected && cursors[member.connectionId] && cursors[member.connectionId].state !== CURSOR_LEAVE,
)
.map((member) => ({
connectionId: member.connectionId,
profileData: member.profileData,
position: cursors[member.connectionId].position,
}));

return (
<div className="h-full w-full z-10 pointer-events-none top-0 left-0 absolute">
{activeCursors.map((cursor) => {
Expand Down
3 changes: 2 additions & 1 deletion demo/src/components/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import cn from 'classnames';
import { useClickOutside, useElementSelect, useMembers } from '../hooks';
import { useClickOutside, useElementSelect } from '../hooks';
import { findActiveMembers, getMemberFirstName, getOutlineClasses } from '../utils';
import { useRef } from 'react';
import { useMembers } from '@ably/spaces/react';
import { usePreview } from './PreviewContext.tsx';

interface Props extends React.HTMLAttributes<HTMLImageElement> {
Expand Down
9 changes: 4 additions & 5 deletions demo/src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { FormEvent, useContext, useRef } from 'react';
import { FormEvent, useRef } from 'react';
import cn from 'classnames';

import { SpacesContext } from '.';
import { useSpace } from '@ably/spaces/react';
import { Member } from '../utils/types';

interface Props {
Expand All @@ -11,15 +10,15 @@ interface Props {
}

export const Modal = ({ isVisible = false, setIsVisible, self }: Props) => {
const space = useContext(SpacesContext);
const { space, updateProfileData } = useSpace();
const inputRef = useRef<HTMLInputElement>(null);

const handleSubmit = (e: FormEvent) => {
e.preventDefault();

if (!space || !setIsVisible) return;

space.updateProfileData((profileData) => ({ ...profileData, name: inputRef.current?.value }));
updateProfileData((profileData) => ({ ...profileData, name: inputRef.current?.value }));
setIsVisible(false);
};

Expand Down
Loading

0 comments on commit 05b6710

Please sign in to comment.