├── .editorconfig ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── cdn.yml │ ├── dev-ci.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .npmignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .tool-versions ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ ├── ably │ └── index.ts └── nanoid │ └── index.ts ├── demo ├── .env.example ├── .eslintrc ├── .gitignore ├── README.md ├── api │ └── ably-token-request │ │ ├── index.ts │ │ ├── package-lock.json │ │ └── package.json ├── index.html ├── netlify.toml ├── noop.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── alignment.svg │ ├── bubble-diagram.svg │ ├── collaborative-document.svg │ ├── contrast.svg │ ├── proximity.svg │ └── repetition.svg ├── src │ ├── App.tsx │ ├── components │ │ ├── Avatar.tsx │ │ ├── AvatarInfo.tsx │ │ ├── AvatarStack.tsx │ │ ├── CurrentSlide.tsx │ │ ├── Cursors.tsx │ │ ├── EditableText.tsx │ │ ├── Header.tsx │ │ ├── Image.tsx │ │ ├── Modal.tsx │ │ ├── Paragraph.tsx │ │ ├── PreviewContext.tsx │ │ ├── SlideMenu.tsx │ │ ├── SlidePreview.tsx │ │ ├── SlidesStateContext.tsx │ │ ├── StickyLabel.tsx │ │ ├── Title.tsx │ │ ├── index.ts │ │ ├── slides.tsx │ │ └── svg │ │ │ ├── Ably.tsx │ │ │ ├── Cross.tsx │ │ │ ├── CurrentSelector.tsx │ │ │ ├── Cursor.tsx │ │ │ ├── ExternalLink.tsx │ │ │ ├── Info.tsx │ │ │ ├── Lightning.tsx │ │ │ ├── Lock.tsx │ │ │ ├── LockedFilled.tsx │ │ │ ├── ReplyStack.tsx │ │ │ └── index.ts │ ├── hooks │ │ ├── index.ts │ │ ├── useElementSelect.ts │ │ ├── useSlideElementContent.ts │ │ ├── useTextComponentLock.ts │ │ └── useTrackCursor.ts │ ├── index.css │ ├── main.tsx │ ├── utils │ │ ├── active-member.ts │ │ ├── colors.ts │ │ ├── fake-names.ts │ │ ├── index.ts │ │ ├── locking.ts │ │ ├── types.d.ts │ │ └── url.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── docs ├── channel-usage.md ├── connection-and-channel-management.md ├── images │ ├── avatar-stack.png │ ├── collab.gif │ ├── field-locking.png │ ├── live-updates.png │ └── user-location.png ├── react.md ├── typedoc │ └── intro.md └── usage.md ├── examples ├── avatar-stack.ts ├── live-cursors.ts ├── location.ts ├── locking.ts └── locks.ts ├── netlify.toml ├── package-lock.json ├── package.json ├── react └── package.json ├── res ├── package.cjs.json └── package.mjs.json ├── rollup.config.mjs ├── src ├── CursorBatching.ts ├── CursorConstants.ts ├── CursorDispensing.ts ├── CursorHistory.ts ├── Cursors.test.ts ├── Cursors.ts ├── Errors.ts ├── Leavers.ts ├── Locations.test.ts ├── Locations.ts ├── Locks.test.ts ├── Locks.ts ├── Members.test.ts ├── Members.ts ├── Space.test.ts ├── Space.ts ├── SpaceUpdate.test.ts ├── SpaceUpdate.ts ├── Spaces.test.ts ├── Spaces.ts ├── index.ts ├── react │ ├── contexts │ │ ├── SpaceContext.tsx │ │ └── SpacesContext.tsx │ ├── index.ts │ ├── types.ts │ ├── useChannelState.ts │ ├── useConnectionState.ts │ ├── useCursors.test.tsx │ ├── useCursors.ts │ ├── useEventListener.ts │ ├── useLocations.test.tsx │ ├── useLocations.ts │ ├── useLock.ts │ ├── useLocks.test.tsx │ ├── useLocks.ts │ ├── useMembers.test.tsx │ ├── useMembers.ts │ ├── useSpace.test.tsx │ ├── useSpace.ts │ └── useSpaces.ts ├── types.ts ├── utilities │ ├── EventEmitter.test.ts │ ├── EventEmitter.ts │ ├── Logger.ts │ ├── is.ts │ ├── math.ts │ ├── test │ │ └── fakes.ts │ └── types.ts ├── version.test.ts └── version.ts ├── test ├── cdn-bundle │ ├── lib │ │ └── webServer.ts │ ├── playwright.config.js │ ├── resources │ │ └── test.html │ ├── server.ts │ ├── test │ │ └── cdnBundle.test.ts │ └── tsconfig.json ├── integration │ ├── integration.test.ts │ └── utilities │ │ └── setup.ts └── lib │ ├── ablySandbox.ts │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.iife.json ├── tsconfig.mjs.json ├── typedoc.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | 10 | [*.{ts,ts.d}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # IntelliJ Specific 15 | ij_javascript_spaces_within_imports = true 16 | ij_typescript_spaces_within_imports = true 17 | ij_typescript_spaces_around_arrow_function_operator = true 18 | ij_javascript_spaces_around_arrow_function_operator = true 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | browser: true, 7 | }, 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | sourceType: 'module', 11 | }, 12 | plugins: ['@typescript-eslint', 'security', 'jsdoc', 'import'], 13 | extends: ['eslint:recommended', 'plugin:security/recommended'], 14 | rules: { 15 | 'eol-last': 'error', 16 | // security/detect-object-injection just gives a lot of false positives 17 | // see https://github.com/nodesecurity/eslint-plugin-security/issues/21 18 | 'security/detect-object-injection': 'off', 19 | // the code problem checked by this ESLint rule is automatically checked by the TypeScript compiler 20 | 'no-redeclare': 'off', 21 | }, 22 | overrides: [ 23 | { 24 | files: ['**/*.{ts,tsx}'], 25 | rules: { 26 | '@typescript-eslint/no-unused-vars': ['error'], 27 | // TypeScript already enforces these rules better than any eslint setup can 28 | 'no-undef': 'off', 29 | 'no-dupe-class-members': 'off', 30 | // see: 31 | // https://github.com/ably/spaces/issues/76 32 | // https://github.com/microsoft/TypeScript/issues/16577#issuecomment-703190339 33 | 'import/extensions': [ 34 | 'error', 35 | 'always', 36 | { 37 | ignorePackages: true, 38 | }, 39 | ], 40 | }, 41 | }, 42 | { 43 | files: 'ably.d.ts', 44 | extends: ['plugin:jsdoc/recommended'], 45 | }, 46 | ], 47 | ignorePatterns: ['dist', 'build', 'examples', 'test/ably-common'], 48 | settings: { 49 | jsdoc: { 50 | tagNamePreference: { 51 | default: 'defaultValue', 52 | }, 53 | }, 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" # weekdays (Monday to Friday) 7 | labels: [ ] # prevent the default `dependencies` label from being added to pull requests 8 | ignore: 9 | - dependency-name: "nanoid" 10 | update-types: ["version-update:semver-major"] # prevent from upgrading from nanoid@3, see for more info: https://github.com/ably/spaces/pull/307 11 | -------------------------------------------------------------------------------- /.github/workflows/cdn.yml: -------------------------------------------------------------------------------- 1 | name: Publish to CDN 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: "The release tag to use" 7 | required: true 8 | bucket: 9 | description: "S3 bucket to upload to" 10 | default: "prod-cdn.ably.com" 11 | type: choice 12 | options: 13 | - "prod-cdn.ably.com" 14 | - "nonprod-cdn.ably.com" 15 | role-to-assume: 16 | description: "The AWS role to assume" 17 | default: "prod-ably-sdk-cdn" 18 | type: choice 19 | options: 20 | - "prod-ably-sdk-cdn" 21 | - "nonprod-ably-sdk-cdn" 22 | 23 | jobs: 24 | publish: 25 | runs-on: ubuntu-latest 26 | # These permissions are necessary to run the configure-aws-credentials action 27 | permissions: 28 | id-token: write 29 | contents: read 30 | steps: 31 | - uses: actions/checkout@v2 32 | with: 33 | ref: ${{ github.event.inputs.version }} 34 | - uses: aws-actions/configure-aws-credentials@v2 35 | with: 36 | role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/${{ github.event.inputs.role-to-assume }} 37 | aws-region: us-east-1 38 | - name: Use Node.js 18 39 | uses: actions/setup-node@v3 40 | with: 41 | node-version: 18 42 | - name: Install dependencies and build 43 | env: 44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | run: | 46 | npm ci 47 | npm run build 48 | # Note: If you modify what we upload to the CDN, you must make sure you keep the `test:cdn-bundle` NPM script in sync with your changes. 49 | - run: | 50 | aws s3 cp ./dist/iife/index.bundle.js s3://${{ github.event.inputs.bucket }}/spaces/${{ github.event.inputs.version }}/iife/index.bundle.js 51 | -------------------------------------------------------------------------------- /.github/workflows/dev-ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | audit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: 18 17 | - run: npm ci 18 | - run: npm audit --production 19 | format-check: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-node@v1 24 | with: 25 | node-version: 18 26 | - run: npm ci 27 | - run: npm run format:check 28 | lint: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions/setup-node@v1 33 | with: 34 | node-version: 18 35 | - run: npm ci 36 | - run: npm run lint 37 | test: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v2 41 | with: 42 | submodules: true 43 | - uses: actions/setup-node@v1 44 | with: 45 | node-version: 18 46 | - run: npm ci 47 | - run: npm run test 48 | build: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v2 52 | - uses: actions/setup-node@v1 53 | with: 54 | node-version: 18 55 | - run: npm ci 56 | - run: npm run build 57 | docs: 58 | runs-on: ubuntu-latest 59 | permissions: 60 | id-token: write 61 | deployments: write 62 | steps: 63 | - uses: actions/checkout@v2 64 | - uses: actions/setup-node@v1 65 | with: 66 | node-version: 18 67 | - name: Install Package Dependencies 68 | run: npm ci 69 | - name: Build SDK 70 | run: npm run build 71 | - name: Build Documentation 72 | run: npm run docs 73 | - name: Configure AWS Credentials 74 | uses: aws-actions/configure-aws-credentials@v1 75 | env: 76 | ably_aws_account_id_sdk: ${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }} 77 | # do not run if these variables are not available; they will not be available for anybody outside the Ably org 78 | if: ${{ env.ably_aws_account_id_sdk != '' }} 79 | with: 80 | aws-region: eu-west-2 81 | role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-spaces 82 | role-session-name: "${{ github.run_id }}-${{ github.run_number }}" 83 | - name: Upload Documentation 84 | uses: ably/sdk-upload-action@v1 85 | env: 86 | ably_aws_account_id_sdk: ${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }} 87 | # do not run if these variables are not available; they will not be available for anybody outside the Ably org 88 | if: ${{ env.ably_aws_account_id_sdk != '' }} 89 | with: 90 | sourcePath: docs/typedoc/generated 91 | githubToken: ${{ secrets.GITHUB_TOKEN }} 92 | artifactName: typedoc 93 | test-cdn-bundle: 94 | runs-on: ubuntu-latest 95 | steps: 96 | - uses: actions/checkout@v2 97 | with: 98 | submodules: true 99 | - uses: actions/setup-node@v1 100 | with: 101 | node-version: 18 102 | - run: npm ci 103 | - run: npx playwright install chromium 104 | - run: npm run build 105 | - run: npm run test:cdn-bundle 106 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release packages 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: 'write' 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: '18.13.0' 17 | - name: Install dependencies and publish 18 | env: 19 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | run: | 21 | npm ci 22 | npm run build 23 | npm publish --access=public 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | dist/ 4 | node_modules/ 5 | .vscode 6 | coverage 7 | .env 8 | docs/typedoc/generated/ 9 | test-results/ 10 | 11 | # Local Netlify folder 12 | .netlify 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/ably-common"] 2 | path = test/ably-common 3 | url = https://github.com/ably/ably-common.git 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /**/*.test.ts 2 | /**/*.test.js 3 | /**/*.test.js.map 4 | /**/*.test.d.ts 5 | /**/*/node_modules 6 | scripts 7 | test 8 | demo 9 | netlify.toml 10 | examples 11 | __mocks__ 12 | vitest.config.ts 13 | .github 14 | .gitignore 15 | .editorconfig 16 | .eslintrc.js 17 | .prettierrc.json 18 | .tool-versions 19 | CHANGELOG.MD 20 | CONTRIBUTING.md -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | test/ably-common/ 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "singleAttributePerLine": true, 7 | "bracketSameLine": false, 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.13.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.4.0 4 | 5 | Breaking changes in this release: 6 | 7 | * Upgrade to using Ably JavaScript SDK v2 [\#325](https://github.com/ably/spaces/pull/325) 8 | 9 | With this release the Spaces SDK now requires Ably JavaScript SDK v2 to be installed and used with the Spaces client. Please refer to [Ably JavaScript SDK v2](https://github.com/ably/ably-js/releases/tag/2.0.0) GitHub release notes for the list of breaking changes and the corresponding migration guide. 10 | 11 | **Full Changelog**: https://github.com/ably/spaces/compare/0.3.1...0.4.0 12 | 13 | ## v0.3.1 14 | 15 | No breaking changes were introduced in this release. 16 | 17 | * Fix not being able to import CJS Spaces bundle due to `ERR_REQUIRE_ESM` error [\#307](https://github.com/ably/spaces/pull/307) 18 | 19 | ## v0.3.0 20 | 21 | Breaking changes in this release: 22 | 23 | * [COL-577] Use ::$space as space channel suffix [\#244](https://github.com/ably/spaces/pull/244) 24 | 25 | This change updates the name of the channel [used by spaces internally](./docs/channel-usage.md). 26 | 27 | Previously, if you create a space with `spaces.get('example')`, this would create an Ably channel named `example-space`. This will now become `example::$space`. 28 | 29 | When deploying this change, it's important to note that there will be no continuty between the channel used so far and the channel used after the update. If you have customer applications that are running spaces, sessions using the old channel should end before this update is implemented. 30 | 31 | Other notable changes: 32 | 33 | * [COL-533] Upgrade Ably to 1.2.46 by @lawrence-forooghian in https://github.com/ably/spaces/pull/235 34 | * [COL-56] Add integration tests by @lawrence-forooghian in https://github.com/ably/spaces/pull/229 35 | 36 | **Full Changelog**: https://github.com/ably/spaces/compare/0.2.0...0.3.0 37 | 38 | ## v0.2.0 39 | 40 | In this release, we introduce React hooks for Spaces [\#233](https://github.com/ably/spaces/pull/233). See the [React Hooks documentation](/docs/react.md) for further details. 41 | 42 | Breaking changes in this release: 43 | 44 | * \[MMB-317\], \[MMB-318\] — Remove the `LockAttributes` type [\#214](https://github.com/ably/spaces/pull/214) ([lawrence-forooghian](https://github.com/lawrence-forooghian)) 45 | * Remove ability to pass array of event names to `EventEmitter.prototype.once` [\#196](https://github.com/ably/spaces/pull/196) ([lawrence-forooghian](https://github.com/lawrence-forooghian)) 46 | 47 | Other notable changes: 48 | 49 | * \[COL-335\] Fix bug where `space.enter()` sometimes hangs. [\#227](https://github.com/ably/spaces/pull/227) ([lawrence-forooghian](https://github.com/lawrence-forooghian)) 50 | * Add agent param [\#220](https://github.com/ably/spaces/pull/220) ([dpiatek](https://github.com/dpiatek)) 51 | * Add script to test CDN bundle [\#216](https://github.com/ably/spaces/pull/216) ([lawrence-forooghian](https://github.com/lawrence-forooghian)) 52 | * Publish to new CDN bucket only [\#205](https://github.com/ably/spaces/pull/205) ([surminus](https://github.com/surminus)) 53 | * \[MMB-156\] Add documentation comments and generate HTML documentation [\#204](https://github.com/ably/spaces/pull/204) ([lawrence-forooghian](https://github.com/lawrence-forooghian)) 54 | * Demo updates [\#195](https://github.com/ably/spaces/pull/195) ([dpiatek](https://github.com/dpiatek)) 55 | 56 | **Full Changelog**: https://github.com/ably/spaces/compare/0.1.3...0.2.0 57 | 58 | ## v0.1.3 59 | 60 | Breaking changes in this release: 61 | * `space.locks.getLocksForConnectionId` is now private by @lawrence-forooghian https://github.com/ably/spaces/pull/188 62 | 63 | Other notable changes: 64 | * Refactor space updates by @dpiatek in https://github.com/ably/spaces/pull/180 65 | * Fixes `space.updateProfileData` not passing the existing `ProfileData` if a function was passed in as an argument 66 | * Fixes `space.leave` resetting `ProfileData` when no arguments are passed in 67 | 68 | **Full Changelog**: https://github.com/ably/spaces/compare/0.1.2...0.1.3 69 | 70 | ## v0.1.2 71 | 72 | No breaking changes were introduced in this release. 73 | 74 | * cursors: Fix cursor batching calculation by @lmars in https://github.com/ably/spaces/pull/169 75 | * [MMB-260] Update default batch time to 25ms by @dpiatek in https://github.com/ably/spaces/pull/167 76 | 77 | ## v0.1.1 78 | 79 | No breaking changes were introduced in this release. 80 | 81 | * Update README.md by @Srushtika in https://github.com/ably/spaces/pull/159 82 | * Update README.md by @Srushtika in https://github.com/ably/spaces/pull/160 83 | * refactor: avoid early initialisation of common errors by @owenpearson in https://github.com/ably/spaces/pull/163 84 | * Update demo to use latest version of Spaces by @dpiatek in https://github.com/ably/spaces/pull/161 85 | * fix: unlock update hasn't triggered after lock release by @ttypic in https://github.com/ably/spaces/pull/164 86 | * [MMB-247] Channel tagging by @dpiatek in https://github.com/ably/spaces/pull/166 87 | 88 | ## v0.1.0 89 | 90 | In this release, we're advancing Spaces from alpha to beta. Along with introducing this library to a wider audience, we've decided to move it to the `ably` organisation as Spaces is no longer an experiment, it's something we see as an excellent supplement to our core SDKs to help developers build collaborative environments in their apps. We are committed to grow and officially maintain it. 91 | 92 | If you are one of our early adopters, this means you need to update your `package.json` from `@ably-labs/spaces` to `@ably/spaces`. 93 | 94 | Visit [official documentation on Ably's website](https://ably.com/docs/spaces) to learn more about Spaces and understand what the beta status means for you. 95 | 96 | The following APIs are currently available: 97 | - **Space** - a virtual area of your application in which realtime collaboration between users can take place. 98 | - **Avatar stack** - the most common way of showing the online status of users in an application. 99 | - **Member locations** - track where users are to see which part of your application they're interacting with. 100 | - **Live cursors** - track the cursor positions of users in realtime. 101 | - **Component locking** - optimistically lock stateful UI components before editing them. 102 | 103 | Your feedback will help prioritize improvements and fixes in subsequent releases. Spaces features have been validated for a set of use-cases, but breaking changes may still occur between minor releases until we reach 1.0.0. The beta is implemented based on real world situations and loads, but we'd advise to take caution when adding it to production environments. 104 | 105 | Please reach out to [beta@ably.com](mailto:beta@ably.com) for any questions or share feedback through [this form]( https://go.ably.com/spaces-feedback). 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing To Spaces API 2 | 3 | ## Contributing 4 | 5 | 1. Fork it 6 | 2. Create your feature branch (`git checkout -b my-new-feature`) 7 | 3. Commit your changes (`git commit -am 'Add some feature'`) 8 | 4. Ensure you have added suitable tests and the test suite is passing(`npm test`) 9 | 5. Push the branch (`git push origin my-new-feature`) 10 | 6. Create a new Pull Request 11 | 12 | ## Release Process 13 | 14 | 1. Make sure the tests are passing in CI for main 15 | 1. Add a new commit using Semantic Versioning rules. 16 | 1. [Semantic Versioning guidelines](https://semver.org/) entail a format of M.m.p, for example 1.2.3, where: 17 | - The first number represents a major release, which lets users know a breaking change has occurred that will require action from them. 18 | - A major update in the AblyJS SDK will also require a major update in the Spaces API. 19 | - The second number represents a minor release, which lets users know new functionality or features have been added. 20 | - The third number represents a patch release, which represents bug-fixes and may be used when no action should be required from users. 21 | 1. The commit should update `package.json`, the `Spaces.ts` class containing a `version` property and `package-lock.json`. 22 | Running `npm install` after changing `package.json` will update `package-lock.json`. 23 | 1. Update the README.md for any references to the new version, such as the CDN link. 24 | 1. Merge the commit into main. 25 | 1. Tag a release using [Github releases](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#creating-a-release). The version needs to match the one from the commit. Use the "Generate release notes" button to 26 | add changelog notes and update as required. 27 | 1. Ensure that the GitHub release action (.github/workflows/release.yml) has run successfully and package was published. 28 | 1. Run the GitHub CDN publish action (.github/workflows/cdn.yml) with the same tag. 29 | 30 | ## Test suite 31 | 32 | ### Setup 33 | 34 | Before running the tests, you first need to initialize the repository’s submodules: 35 | 36 | ``` 37 | git submodule update --init 38 | ``` 39 | 40 | ### Running the tests 41 | 42 | To run the Jest tests, simply run the following command: 43 | 44 | ```bash 45 | npm test 46 | ``` 47 | 48 | ### CDN bundle test 49 | 50 | To test the bundle that we upload to the CDN: 51 | 52 | 1. Install browser for Playwright to use: `npx run playwright install chromium` 53 | 2. Build the bundle: `npm run build` 54 | 3. Run the test: `npm run test:cdn-bundle` 55 | -------------------------------------------------------------------------------- /__mocks__/ably/index.ts: -------------------------------------------------------------------------------- 1 | import { PresenceMessage, Rest, ErrorInfo } from 'ably'; 2 | 3 | const MOCK_CLIENT_ID = 'MOCK_CLIENT_ID'; 4 | 5 | const mockPromisify = (expectedReturnValue): Promise => new Promise((resolve) => resolve(expectedReturnValue)); 6 | const methodReturningVoidPromise = () => mockPromisify((() => {})()); 7 | 8 | function createMockPresence() { 9 | return { 10 | get: () => mockPromisify([]), 11 | update: () => mockPromisify(undefined), 12 | enter: methodReturningVoidPromise, 13 | leave: methodReturningVoidPromise, 14 | subscriptions: { 15 | once: (_: unknown, fn: Function) => { 16 | fn(); 17 | }, 18 | }, 19 | subscribe: () => {}, 20 | unsubscribe: () => {}, 21 | }; 22 | } 23 | 24 | function createMockHistory() { 25 | const mockHistory = { 26 | items: [], 27 | first: () => mockPromisify(mockHistory), 28 | next: () => mockPromisify(mockHistory), 29 | current: () => mockPromisify(mockHistory), 30 | hasNext: () => false, 31 | isLast: () => true, 32 | }; 33 | return mockHistory; 34 | } 35 | 36 | function createMockEmitter() { 37 | return { 38 | any: [], 39 | events: {}, 40 | anyOnce: [], 41 | eventsOnce: {}, 42 | }; 43 | } 44 | 45 | function createMockChannel() { 46 | return { 47 | presence: createMockPresence(), 48 | history: (() => { 49 | const mockHistory = createMockHistory(); 50 | return () => mockHistory; 51 | })(), 52 | subscribe: () => {}, 53 | unsubscribe: () => {}, 54 | on: () => {}, 55 | off: () => {}, 56 | publish: () => {}, 57 | subscriptions: createMockEmitter(), 58 | }; 59 | } 60 | 61 | class MockRealtime { 62 | public channels: { 63 | get: () => ReturnType; 64 | }; 65 | public auth: { 66 | clientId: string; 67 | }; 68 | public connection: { 69 | id?: string; 70 | state: string; 71 | }; 72 | 73 | public time() {} 74 | 75 | constructor() { 76 | this.channels = { 77 | get: (() => { 78 | const mockChannel = createMockChannel(); 79 | return () => mockChannel; 80 | })(), 81 | }; 82 | this.auth = { 83 | clientId: MOCK_CLIENT_ID, 84 | }; 85 | this.connection = { 86 | id: '1', 87 | state: 'connected', 88 | }; 89 | 90 | this['options'] = {}; 91 | } 92 | } 93 | 94 | class MockErrorInfo extends ErrorInfo {} 95 | 96 | // maintain the PresenceMessage class so tests can initialise it directly using 97 | // PresenceMessage.fromValues. 98 | MockRealtime.PresenceMessage = Rest.PresenceMessage; 99 | 100 | export { MockRealtime as Realtime, MockErrorInfo as ErrorInfo }; 101 | -------------------------------------------------------------------------------- /__mocks__/nanoid/index.ts: -------------------------------------------------------------------------------- 1 | const nanoidId = 'NanoidID'; 2 | 3 | function nanoid(): string { 4 | return nanoidId; 5 | } 6 | 7 | export { nanoid, nanoidId }; 8 | -------------------------------------------------------------------------------- /demo/.env.example: -------------------------------------------------------------------------------- 1 | ABLY_API_KEY= -------------------------------------------------------------------------------- /demo/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "browser": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "plugins": ["import"], 12 | "overrides": [ 13 | { 14 | "files": ["**/*.{ts,tsx}"], 15 | "rules": { 16 | "import/extensions": ["off"] 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Spaces Demo 2 | 3 | An app showcasing the usage of different multiplayer features enabled by the spaces API. 4 | 5 | ## Setup 6 | 7 | First, create a `.env` file in this folder, based on the `.env.example`. You will need environment variables listed in there. 8 | 9 | You will need [node.js](https://nodejs.org/en/). The demo has separate dependencies to the library, you will need to run `npm install` in this folder as well as the root one. 10 | 11 | To run the development server, do `npm run start`. This will start a Netlify dev server, and will make sure the auth endpoint in `api` works correctly locally. 12 | 13 | ## Deployment 14 | 15 | To deploy, you will need access to the Ably Netlify account. Run `npx netlify login` to [login locally](https://docs.netlify.com/cli/get-started/#obtain-a-token-with-the-command-line). 16 | 17 | We use Netlify [manual deploys](https://docs.netlify.com/site-deploys/create-deploys/), so do connect the repo to the github repo when prompted by the CLI. Run `npm run deploy` to deploy a test site and `npm run deploy:production` to deploy the production site. 18 | -------------------------------------------------------------------------------- /demo/api/ably-token-request/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import * as Ably from 'ably'; 3 | import { HandlerEvent } from '@netlify/functions'; 4 | 5 | dotenv.config(); 6 | 7 | export async function handler(event: HandlerEvent) { 8 | if (!process.env.ABLY_API_KEY) { 9 | console.error(` 10 | Missing ABLY_API_KEY environment variable. 11 | If you're running locally, please ensure you have a ./.env file with a value for ABLY_API_KEY=your-key. 12 | If you're running in Netlify, make sure you've configured env variable ABLY_API_KEY. 13 | 14 | Please see README.md for more details on configuring your Ably API Key.`); 15 | 16 | return { 17 | statusCode: 500, 18 | headers: { 'content-type': 'application/json' }, 19 | body: JSON.stringify('ABLY_API_KEY is not set'), 20 | }; 21 | } 22 | 23 | const clientId = event.queryStringParameters?.['clientId'] || process.env.DEFAULT_CLIENT_ID || 'NO_CLIENT_ID'; 24 | const client = new Ably.Rest(process.env.ABLY_API_KEY); 25 | const tokenRequestData = await client.auth.createTokenRequest({ clientId: clientId }); 26 | 27 | return { 28 | statusCode: 200, 29 | headers: { 'content-type': 'application/json' }, 30 | body: JSON.stringify(tokenRequestData), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /demo/api/ably-token-request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ably-token-request", 3 | "version": "1.0.0", 4 | "description": "ably token request", 5 | "main": "index.ts", 6 | "scripts": { 7 | "serve": "netlify functions:serve" 8 | }, 9 | "keywords": [ 10 | "netlify", 11 | "serverless", 12 | "typescript" 13 | ], 14 | "author": "Netlify", 15 | "license": "MIT", 16 | "dependencies": { 17 | "@netlify/functions": "^1.4.0", 18 | "@types/node": "^18.3.0", 19 | "ably": "^2.3.0", 20 | "dotenv": "^16.0.3", 21 | "typescript": "^4.9.5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Spaces Demo 6 | 10 | 11 | 16 | 20 | 21 | 22 | 23 |
24 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /demo/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "dist" 3 | command = "npm run build" 4 | 5 | [functions] 6 | directory = "api" 7 | 8 | [[redirects]] 9 | from = "/api/*" 10 | to = "/.netlify/functions/:splat" 11 | status = 200 12 | 13 | [template.environment] 14 | ABLY_API_KEY = "change me to your Ably API key" 15 | -------------------------------------------------------------------------------- /demo/noop.js: -------------------------------------------------------------------------------- 1 | // See https://github.com/vitejs/vite/issues/9200 2 | module.exports = {}; 3 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "prepare": "cd api/ably-token-request && npm install", 8 | "dev": "vite", 9 | "start": "npx netlify dev -c \"npm run dev\" --targetPort 8080", 10 | "build": "npx tsc && vite build", 11 | "deploy": "npm run build && netlify deploy", 12 | "deploy:production": "npm run build && netlify deploy --prod" 13 | }, 14 | "dependencies": { 15 | "@ably/spaces": "file:..", 16 | "ably": "^2.3.0", 17 | "classnames": "^2.3.2", 18 | "dayjs": "^1.11.9", 19 | "nanoid": "^3.3.7", 20 | "random-words": "^2.0.0", 21 | "react": "^18.2.0", 22 | "react-contenteditable": "^3.3.7", 23 | "react-dom": "^18.2.0", 24 | "sanitize-html": "^2.11.0" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^18.2.15", 28 | "@types/react-dom": "^18.2.7", 29 | "@types/react-helmet": "^6.1.6", 30 | "@types/sanitize-html": "^2.9.0", 31 | "@typescript-eslint/eslint-plugin": "^6.0.0", 32 | "@typescript-eslint/parser": "^6.0.0", 33 | "@vitejs/plugin-react": "^4.0.3", 34 | "autoprefixer": "^10.4.14", 35 | "esbuild": "^0.19.0", 36 | "eslint": "^8.45.0", 37 | "eslint-plugin-react-hooks": "^4.6.0", 38 | "eslint-plugin-react-refresh": "^0.4.3", 39 | "netlify-cli": "^15.9.1", 40 | "postcss": "^8.4.31", 41 | "tailwindcss": "^3.3.3", 42 | "typescript": "^5.0.2", 43 | "vite": "^4.4.5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /demo/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /demo/public/alignment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /demo/public/contrast.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /demo/public/proximity.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /demo/public/repetition.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useMembers, useSpace, useLocations } from '@ably/spaces/react'; 3 | 4 | import { Header, SlideMenu, CurrentSlide, AblySvg, slides, Modal } from './components'; 5 | import { getRandomName, getRandomColor } from './utils'; 6 | import { PreviewProvider } from './components/PreviewContext.tsx'; 7 | 8 | import { type Member } from './utils/types'; 9 | 10 | const App = () => { 11 | const { space, enter } = useSpace(); 12 | const { self, others } = useMembers(); 13 | const { update } = useLocations(); 14 | const [isModalVisible, setModalIsVisible] = useState(false); 15 | 16 | useEffect(() => { 17 | if (!space || self?.profileData.name) return; 18 | 19 | const init = async () => { 20 | const name = getRandomName(); 21 | await enter({ name, color: getRandomColor() }); 22 | await update({ slide: `${0}`, element: null }); 23 | setModalIsVisible(true); 24 | }; 25 | 26 | init(); 27 | }, [space, self?.profileData.name]); 28 | 29 | return ( 30 |
31 |
35 |
36 |
37 |
41 | 42 | 43 | 44 | 45 |
46 |
47 | 51 | Powered by 52 | 53 | 54 |
55 | 60 |
61 | ); 62 | }; 63 | 64 | export default App; 65 | -------------------------------------------------------------------------------- /demo/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { type SpaceMember } from '@ably/spaces'; 3 | 4 | import { AvatarInfo } from './AvatarInfo'; 5 | import { LightningSvg } from './svg'; 6 | import { type ProfileData } from '../utils/types'; 7 | 8 | export type AvatarProps = Omit & { 9 | isInContent?: boolean; 10 | isSelf?: boolean; 11 | profileData: ProfileData; 12 | }; 13 | 14 | export const Avatar = ({ 15 | isSelf = false, 16 | isConnected = false, 17 | isInContent = false, 18 | profileData, 19 | ...spaceProps 20 | }: AvatarProps) => { 21 | const initials = profileData.name 22 | .split(' ') 23 | .map((n: string) => n[0].toUpperCase()) 24 | .join(''); 25 | 26 | return ( 27 |
38 | {isSelf && } 39 | 40 |
51 |

55 | {initials} 56 |

57 |
58 | 59 | {!isInContent && ( 60 | 66 | )} 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /demo/src/components/AvatarInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import cn from 'classnames'; 4 | import dayjs from 'dayjs'; 5 | import relativeTime from 'dayjs/plugin/relativeTime'; 6 | 7 | import { type SpaceMember } from '@ably/spaces'; 8 | import { type ProfileData } from '../utils/types'; 9 | 10 | type Props = Omit & { 11 | isSelf?: boolean; 12 | isList?: boolean; 13 | profileData: ProfileData; 14 | }; 15 | 16 | dayjs.extend(relativeTime); 17 | 18 | export const AvatarInfo = ({ isSelf, isConnected, profileData, isList = false, lastEvent }: Props) => { 19 | const [currentTime, setCurrentTime] = useState(dayjs()); 20 | 21 | const lastSeen = (timestamp: number) => { 22 | const diffInSeconds = currentTime.diff(timestamp, 'seconds') + 1; 23 | 24 | if (diffInSeconds === 0) { 25 | return `Last seen a moment ago`; 26 | } else { 27 | return `Last seen ${diffInSeconds} second${diffInSeconds === 1 ? '' : 's'} ago`; 28 | } 29 | }; 30 | 31 | useEffect(() => { 32 | let intervalId: ReturnType; 33 | 34 | if (isSelf) return; 35 | 36 | if (!isConnected) { 37 | intervalId = setInterval(() => { 38 | setCurrentTime(dayjs()); 39 | }, 1000); 40 | } 41 | 42 | return () => { 43 | clearInterval(intervalId); 44 | }; 45 | }, [isConnected]); 46 | 47 | return isSelf ? ( 48 |
52 |

56 | {profileData.name} (You) 57 |

58 |
59 | ) : ( 60 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /demo/src/components/AvatarStack.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from './Avatar'; 2 | 3 | import cn from 'classnames'; 4 | import { AvatarInfo } from './AvatarInfo'; 5 | 6 | import { type AvatarProps } from './Avatar'; 7 | 8 | interface Props { 9 | isInContent?: boolean; 10 | avatars: AvatarProps[]; 11 | } 12 | 13 | export const AvatarStack = ({ isInContent = false, avatars }: Props) => { 14 | const largeAvatars = avatars.slice(0, 4); 15 | const hiddenAvatars = avatars.slice(4); 16 | 17 | return ( 18 |
    23 | {largeAvatars.map((avatar) => ( 24 |
  • 28 | 32 |
  • 33 | ))} 34 | 35 | {hiddenAvatars.length > 0 && ( 36 |
  • 37 |
    47 |
    51 |

    52 | +{hiddenAvatars.length} 53 |

    54 |
    55 | 56 | {!isInContent && ( 57 |
    58 |
    62 | {hiddenAvatars.map((avatar, index) => ( 63 | 68 | ))} 69 |
    70 |
    71 | )} 72 |
    73 |
  • 74 | )} 75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /demo/src/components/CurrentSlide.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { Cursors } from '.'; 3 | import { useTrackCursor } from '../hooks'; 4 | import { SlidePreviewProps } from './SlidePreview'; 5 | import { useMembers } from '@ably/spaces/react'; 6 | 7 | interface Props { 8 | slides: Omit[]; 9 | } 10 | 11 | export const CurrentSlide = ({ slides }: Props) => { 12 | const containerRef = useRef(null); 13 | const { self } = useMembers(); 14 | const slide = parseInt(self?.location?.slide || '', 10) || 0; 15 | 16 | useTrackCursor(containerRef, self?.connectionId); 17 | 18 | return ( 19 |
23 |
24 | {slides[slide].children} 25 |
26 | 27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /demo/src/components/Cursors.tsx: -------------------------------------------------------------------------------- 1 | import { useCursors } from '@ably/spaces/react'; 2 | import cn from 'classnames'; 3 | import { CursorSvg } from '.'; 4 | import { CURSOR_LEAVE } from '../hooks'; 5 | 6 | export const Cursors = () => { 7 | const { space, cursors } = useCursors({ returnCursors: true }); 8 | 9 | const activeCursors = Object.keys(cursors) 10 | .filter((connectionId) => { 11 | const { member, cursorUpdate } = cursors[connectionId]!!; 12 | return ( 13 | member.connectionId !== space.connectionId && member.isConnected && cursorUpdate.data.state !== CURSOR_LEAVE 14 | ); 15 | }) 16 | .map((connectionId) => { 17 | const { member, cursorUpdate } = cursors[connectionId]!!; 18 | return { 19 | connectionId: member.connectionId, 20 | profileData: member.profileData, 21 | position: cursorUpdate.position, 22 | }; 23 | }); 24 | 25 | return ( 26 |
27 | {activeCursors.map((cursor) => { 28 | const { connectionId, profileData } = cursor; 29 | 30 | return ( 31 |
39 | 44 |

51 | {profileData.name.split(' ')[0]} 52 |

53 |
54 | ); 55 | })} 56 |
57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /demo/src/components/EditableText.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef } from 'react'; 2 | import ContentEditable, { ContentEditableEvent } from 'react-contenteditable'; 3 | import sanitize from 'sanitize-html'; 4 | 5 | interface EditableTextProps extends Omit, 'onChange' | 'children'> { 6 | as?: string; 7 | disabled: boolean; 8 | value: string; 9 | onChange(nextValue: string): void; 10 | maxlength?: number; 11 | className?: string; 12 | } 13 | 14 | export const EditableText: React.FC = ({ 15 | as, 16 | disabled, 17 | maxlength = 300, 18 | value, 19 | onChange, 20 | ...restProps 21 | }) => { 22 | const elementRef = useRef(null); 23 | const handleTextChange = useCallback( 24 | (evt: ContentEditableEvent) => { 25 | const nextValue = sanitize(evt.target.value, { 26 | allowedTags: [], 27 | }); 28 | 29 | if (nextValue.length > maxlength) { 30 | onChange(value); 31 | } else { 32 | onChange(nextValue); 33 | } 34 | }, 35 | [onChange, value, maxlength], 36 | ); 37 | 38 | useEffect(() => { 39 | const element = elementRef.current; 40 | if (!disabled && element) { 41 | moveCursorToEnd(element); 42 | } 43 | }, [disabled]); 44 | 45 | return ( 46 | 54 | ); 55 | }; 56 | 57 | const moveCursorToEnd = (el: HTMLElement) => { 58 | el.focus(); 59 | const range = document.createRange(); 60 | range.selectNodeContents(el); 61 | range.collapse(false); 62 | const selection = window.getSelection(); 63 | selection?.removeAllRanges(); 64 | selection?.addRange(range); 65 | }; 66 | -------------------------------------------------------------------------------- /demo/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from './Avatar'; 2 | import { AvatarStack } from './AvatarStack'; 3 | import { ExternalLinkSvg, InfoSvg } from './svg'; 4 | import { type Member } from '../utils/types'; 5 | import { getParamValueFromUrl, generateTeamName } from '../utils'; 6 | 7 | interface Props { 8 | self?: Member; 9 | others?: Member[]; 10 | } 11 | 12 | export const Header = ({ self, others }: Props) => { 13 | const teamName = getParamValueFromUrl('team', generateTeamName); 14 | const formattedTeamName = teamName?.replace(/-/g, ' '); 15 | 16 | return ( 17 |
21 |
22 |
23 |

Team {formattedTeamName}

24 |

Pitch deck

25 |
26 | 27 |
28 | 29 |

How to try this demo

30 |
31 | Open this page in multiple windows or share the URL with your team to try out the live avatar stack. 32 |
33 |
34 | 35 |
39 | <> 40 | {self && ( 41 | 45 | )} 46 | {others && others.length > 0 && } 47 | 48 |
49 | 50 |
51 | 57 |

Space API

58 | 59 |
60 | 61 | 65 | Sign Up 66 | 67 |
68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /demo/src/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { useClickOutside, useElementSelect } from '../hooks'; 3 | import { findActiveMembers, getMemberFirstName, getOutlineClasses } from '../utils'; 4 | import { useRef } from 'react'; 5 | import { useMembers } from '@ably/spaces/react'; 6 | import { usePreview } from './PreviewContext.tsx'; 7 | 8 | interface Props extends React.HTMLAttributes { 9 | src: string; 10 | children?: React.ReactNode; 11 | className?: string; 12 | id: string; 13 | slide: string; 14 | locatable?: boolean; 15 | } 16 | 17 | export const Image = ({ src, children, className, id, slide, locatable = true }: Props) => { 18 | const containerRef = useRef(null); 19 | const preview = usePreview(); 20 | const { members, self } = useMembers(); 21 | const { handleSelect } = useElementSelect(id, false); 22 | const activeMembers = findActiveMembers(id, slide, members); 23 | const occupiedByMe = activeMembers.some((member) => member.connectionId === self?.connectionId); 24 | const [firstMember] = activeMembers; 25 | const { outlineClasses, stickyLabelClasses } = getOutlineClasses(firstMember); 26 | const memberName = getMemberFirstName(firstMember); 27 | const name = occupiedByMe ? 'You' : memberName; 28 | const label = activeMembers.length > 1 ? `${name} +${activeMembers.length - 1}` : name; 29 | 30 | useClickOutside(containerRef, self, occupiedByMe && !preview); 31 | 32 | return ( 33 |
40 | 47 | {children ? children : null} 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /demo/src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useRef } from 'react'; 2 | import cn from 'classnames'; 3 | import { useSpace } from '@ably/spaces/react'; 4 | import { Member } from '../utils/types'; 5 | 6 | interface Props { 7 | self?: Member; 8 | isVisible?: boolean; 9 | setIsVisible?: (isVisible: boolean) => void; 10 | } 11 | 12 | export const Modal = ({ isVisible = false, setIsVisible, self }: Props) => { 13 | const { space, updateProfileData } = useSpace(); 14 | const inputRef = useRef(null); 15 | 16 | const handleSubmit = (e: FormEvent) => { 17 | e.preventDefault(); 18 | 19 | if (!space || !setIsVisible) return; 20 | 21 | updateProfileData((profileData) => ({ ...profileData, name: inputRef.current?.value })); 22 | setIsVisible(false); 23 | }; 24 | 25 | return ( 26 |
35 |
39 |

Enter your name

40 | 45 | 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /demo/src/components/Paragraph.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import cn from 'classnames'; 3 | import { ChannelProvider } from 'ably/react'; 4 | 5 | import { generateSpaceName, getMemberFirstName, getOutlineClasses, getParamValueFromUrl } from '../utils'; 6 | import { StickyLabel } from './StickyLabel'; 7 | import { LockFilledSvg } from './svg/LockedFilled.tsx'; 8 | import { EditableText } from './EditableText.tsx'; 9 | import { useTextComponentLock } from '../hooks/useTextComponentLock.ts'; 10 | import { buildLockId } from '../utils/locking.ts'; 11 | 12 | interface Props extends React.HTMLAttributes { 13 | id: string; 14 | slide: string; 15 | variant?: 'regular' | 'aside'; 16 | children: string; 17 | maxlength?: number; 18 | } 19 | 20 | export const Paragraph = (props: Props) => { 21 | const spaceName = getParamValueFromUrl('space', generateSpaceName); 22 | const lockId = buildLockId(props.slide, props.id); 23 | const channelName = `${spaceName}${lockId}`; 24 | 25 | return ( 26 | 30 | 34 | 35 | ); 36 | }; 37 | 38 | const ParagraphChild = ({ 39 | variant = 'regular', 40 | id, 41 | slide, 42 | className, 43 | children, 44 | maxlength = 300, 45 | channelName, 46 | ...props 47 | }: Props & { channelName: string }) => { 48 | const containerRef = useRef(null); 49 | const { content, activeMember, locked, lockedByYou, editIsNotAllowed, handleSelect, handleContentUpdate } = 50 | useTextComponentLock({ 51 | channelName, 52 | id, 53 | slide, 54 | defaultText: children, 55 | containerRef, 56 | }); 57 | const memberName = getMemberFirstName(activeMember); 58 | const { outlineClasses, stickyLabelClasses } = getOutlineClasses(activeMember); 59 | 60 | return ( 61 |
67 | 71 | {lockedByYou ? 'You' : memberName} 72 | {editIsNotAllowed && } 73 | 74 | 94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /demo/src/components/PreviewContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | interface PreviewContextProviderProps { 4 | preview: boolean; 5 | children: React.ReactNode; 6 | } 7 | const PreviewContext = React.createContext(false); 8 | 9 | export const PreviewProvider: React.FC = ({ preview, children }) => ( 10 | {children} 11 | ); 12 | 13 | export const usePreview = () => useContext(PreviewContext); 14 | -------------------------------------------------------------------------------- /demo/src/components/SlideMenu.tsx: -------------------------------------------------------------------------------- 1 | import { SlidePreview } from './SlidePreview'; 2 | 3 | interface Props { 4 | slides: { 5 | children: React.ReactNode; 6 | }[]; 7 | } 8 | 9 | export const SlideMenu = ({ slides }: Props) => { 10 | return ( 11 | 12 |
    16 | {slides.map(({ children }, index) => ( 17 | 21 | {children} 22 | 23 | ))} 24 |
25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /demo/src/components/SlidePreview.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { AvatarStack, CurrentSelectorSvg } from '.'; 3 | import { useLocations, useMembers } from '@ably/spaces/react'; 4 | import { Member } from '../utils/types'; 5 | 6 | export interface SlidePreviewProps { 7 | children: React.ReactNode; 8 | index: number; 9 | } 10 | 11 | export const SlidePreview = ({ children, index }: SlidePreviewProps) => { 12 | const { space, self, members } = useMembers(); 13 | const { update } = useLocations(); 14 | const membersOnASlide = (members || []).filter(({ location }) => location?.slide === `${index}`); 15 | const isActive = self?.location?.slide === `${index}`; 16 | 17 | const handleSlideClick = async () => { 18 | if (!space || !self) return; 19 | update({ slide: `${index}`, element: null }); 20 | }; 21 | 22 | return ( 23 |
  • 0, 28 | })} 29 | onClick={handleSlideClick} 30 | > 31 |
    35 | {isActive && } 36 |
    37 |

    41 | {index + 1} 42 |

    43 |
    47 | {children} 48 |
    49 | 53 |
  • 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /demo/src/components/SlidesStateContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from 'react'; 2 | 3 | interface SlidesStateContextProps { 4 | slidesState: Record; 5 | setContent(id: string, nextContent: string): void; 6 | } 7 | export const SlidesStateContext = React.createContext({ 8 | slidesState: {}, 9 | setContent: () => {}, 10 | }); 11 | 12 | export const SlidesStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 13 | const [slidesState, setSlidesState] = useState>({}); 14 | const value = useMemo( 15 | () => ({ 16 | slidesState, 17 | setContent: (id: string, nextContent: string) => { 18 | setSlidesState((prevState) => ({ ...prevState, [id]: nextContent })); 19 | }, 20 | }), 21 | [slidesState, setSlidesState], 22 | ); 23 | return {children}; 24 | }; 25 | -------------------------------------------------------------------------------- /demo/src/components/StickyLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface StickyLabelProps { 4 | visible: boolean; 5 | className?: string; 6 | children: React.ReactNode; 7 | } 8 | export const StickyLabel: React.FC = ({ visible, className, children }) => { 9 | if (!visible) return null; 10 | 11 | return ( 12 |
    15 | {children} 16 |
    17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /demo/src/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import cn from 'classnames'; 3 | import { ChannelProvider } from 'ably/react'; 4 | 5 | import { generateSpaceName, getMemberFirstName, getOutlineClasses, getParamValueFromUrl } from '../utils'; 6 | import { LockFilledSvg } from './svg/LockedFilled.tsx'; 7 | import { StickyLabel } from './StickyLabel.tsx'; 8 | import { EditableText } from './EditableText.tsx'; 9 | import { useTextComponentLock } from '../hooks/useTextComponentLock.ts'; 10 | import { buildLockId } from '../utils/locking.ts'; 11 | 12 | interface Props extends React.HTMLAttributes { 13 | id: string; 14 | slide: string; 15 | variant?: 'h1' | 'h2' | 'h3'; 16 | children: string; 17 | maxlength?: number; 18 | } 19 | 20 | export const Title = (props: Props) => { 21 | const spaceName = getParamValueFromUrl('space', generateSpaceName); 22 | const lockId = buildLockId(props.slide, props.id); 23 | const channelName = `${spaceName}${lockId}`; 24 | 25 | return ( 26 | 30 | 34 | 35 | ); 36 | }; 37 | 38 | const TitleChild = ({ 39 | variant = 'h1', 40 | className, 41 | id, 42 | slide, 43 | children, 44 | maxlength = 70, 45 | channelName, 46 | ...props 47 | }: Props & { channelName: string }) => { 48 | const containerRef = useRef(null); 49 | const { content, activeMember, locked, lockedByYou, editIsNotAllowed, handleSelect, handleContentUpdate } = 50 | useTextComponentLock({ 51 | channelName, 52 | id, 53 | slide, 54 | defaultText: children, 55 | containerRef, 56 | }); 57 | const memberName = getMemberFirstName(activeMember); 58 | const { outlineClasses, stickyLabelClasses } = getOutlineClasses(activeMember); 59 | 60 | return ( 61 |
    67 | 71 | {lockedByYou ? 'You' : memberName} 72 | {editIsNotAllowed && } 73 | 74 | 96 |
    97 | ); 98 | }; 99 | -------------------------------------------------------------------------------- /demo/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Avatar'; 2 | export * from './AvatarInfo'; 3 | export * from './AvatarStack'; 4 | export * from './CurrentSlide'; 5 | export * from './Cursors'; 6 | export * from './Header'; 7 | export * from './Image'; 8 | export * from './Modal'; 9 | export * from './Paragraph'; 10 | export * from './SlideMenu'; 11 | export * from './SlidePreview'; 12 | export * from './slides'; 13 | export * from './svg'; 14 | export * from './Title'; 15 | -------------------------------------------------------------------------------- /demo/src/components/svg/Ably.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const AblySvg = (props: SVGProps) => { 4 | return ( 5 | 13 | 17 | 21 | 25 | 26 | 34 | 35 | 39 | 43 | 47 | 51 | 55 | 56 | 64 | 65 | 69 | 73 | 77 | 81 | 85 | 86 | 87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /demo/src/components/svg/Cross.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const CrossSvg = (props: SVGProps) => { 4 | return ( 5 | 13 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /demo/src/components/svg/CurrentSelector.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const CurrentSelectorSvg = (props: SVGProps) => { 4 | return ( 5 | 13 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /demo/src/components/svg/Cursor.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | interface Props extends SVGProps { 4 | startColor: string; 5 | endColor: string; 6 | id: string; 7 | } 8 | 9 | export const CursorSvg = ({ startColor, endColor, id, ...props }: Props) => { 10 | return ( 11 | 19 | 23 | 24 | 32 | 33 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /demo/src/components/svg/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const ExternalLinkSvg = (props: SVGProps) => { 4 | return ( 5 | 13 | 14 | 21 | 27 | 33 | 34 | 35 | 36 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /demo/src/components/svg/Info.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const InfoSvg = (props: SVGProps) => { 4 | return ( 5 | 13 | 14 | 18 | 22 | 28 | 29 | 30 | 31 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /demo/src/components/svg/Lightning.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const LightningSvg = (props: SVGProps) => { 4 | return ( 5 | 14 | 15 | 23 | 24 | 25 | 26 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /demo/src/components/svg/Lock.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const LockSvg = (props: SVGProps) => { 4 | return ( 5 | 13 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /demo/src/components/svg/LockedFilled.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const LockFilledSvg = (props: SVGProps) => { 4 | return ( 5 | 13 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /demo/src/components/svg/ReplyStack.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from 'react'; 2 | 3 | export const ReplyStackSvg = (props: SVGProps) => { 4 | return ( 5 | 13 | 20 | 27 | 34 | 40 | 48 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /demo/src/components/svg/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Ably'; 2 | export * from './Cross'; 3 | export * from './CurrentSelector'; 4 | export * from './Cursor'; 5 | export * from './ExternalLink'; 6 | export * from './Info'; 7 | export * from './Lightning'; 8 | export * from './Lock'; 9 | export * from './ReplyStack'; 10 | -------------------------------------------------------------------------------- /demo/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useElementSelect'; 2 | export * from './useTrackCursor'; 3 | -------------------------------------------------------------------------------- /demo/src/hooks/useElementSelect.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useEffect } from 'react'; 2 | 3 | import { buildLockId, releaseMyLocks } from '../utils/locking'; 4 | import { Member } from '../utils/types'; 5 | import { useMembers, useSpace } from '@ably/spaces/react'; 6 | 7 | export const useElementSelect = (element?: string, lockable: boolean = true) => { 8 | const { space, self } = useMembers(); 9 | 10 | const handleSelect = async () => { 11 | if (!space || !self) return; 12 | 13 | if (lockable) { 14 | const lockId = buildLockId(self.location?.slide, element); 15 | const lock = space.locks.get(lockId); 16 | 17 | if (!lock) { 18 | // The lock is not set but we enter the location optimistically 19 | await space.locations.set({ slide: self.location?.slide, element }); 20 | // TODO delete this workaround when spaces API is ready 21 | await delay(60); 22 | await space.locks.acquire(lockId); 23 | } 24 | } else { 25 | space.locations.set({ slide: self.location?.slide, element }); 26 | } 27 | }; 28 | 29 | return { handleSelect }; 30 | }; 31 | 32 | export const useClickOutside = (ref: MutableRefObject, self?: Member, enabled?: boolean) => { 33 | const { space } = useSpace(); 34 | 35 | useEffect(() => { 36 | if (!enabled) return; 37 | const handleClick = async (e: DocumentEventMap['click']) => { 38 | const clickedOutside = !ref.current?.contains(e.target as Node); 39 | if (clickedOutside && space && self) { 40 | await space.locations.set({ slide: self.location?.slide, element: undefined }); 41 | // TODO delete this workaround when spaces API is ready 42 | await delay(60); 43 | await releaseMyLocks(space); 44 | } 45 | }; 46 | 47 | document.addEventListener('click', handleClick, true); 48 | 49 | return () => { 50 | document.removeEventListener('click', handleClick, true); 51 | }; 52 | }, [space, self, enabled]); 53 | }; 54 | 55 | export const useClearOnFailedLock = (lockConflict: boolean, self?: Member) => { 56 | const { space } = useSpace(); 57 | 58 | useEffect(() => { 59 | if (lockConflict) { 60 | space?.locations.set({ slide: self?.location?.slide, element: undefined }); 61 | } 62 | }, [lockConflict]); 63 | }; 64 | 65 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 66 | -------------------------------------------------------------------------------- /demo/src/hooks/useSlideElementContent.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext } from 'react'; 2 | import { SlidesStateContext } from '../components/SlidesStateContext.tsx'; 3 | 4 | export const useSlideElementContent = (id: string, defaultContent: string) => { 5 | const { slidesState, setContent } = useContext(SlidesStateContext); 6 | const updateContent = useCallback( 7 | (nextContent: string) => { 8 | setContent(id, nextContent); 9 | }, 10 | [id], 11 | ); 12 | return [slidesState[id] ?? defaultContent, updateContent] as const; 13 | }; 14 | -------------------------------------------------------------------------------- /demo/src/hooks/useTextComponentLock.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useCallback } from 'react'; 2 | import { useChannel } from 'ably/react'; 3 | import { useMembers, useLock } from '@ably/spaces/react'; 4 | import sanitize from 'sanitize-html'; 5 | import { findActiveMember } from '../utils'; 6 | import { buildLockId } from '../utils/locking.ts'; 7 | import { usePreview } from '../components/PreviewContext.tsx'; 8 | import { useClearOnFailedLock, useClickOutside, useElementSelect } from './useElementSelect.ts'; 9 | import { useSlideElementContent } from './useSlideElementContent.ts'; 10 | 11 | interface UseTextComponentLockArgs { 12 | channelName: string; 13 | id: string; 14 | slide: string; 15 | defaultText: string; 16 | containerRef: MutableRefObject; 17 | } 18 | 19 | export const useTextComponentLock = ({ 20 | channelName, 21 | id, 22 | slide, 23 | defaultText, 24 | containerRef, 25 | }: UseTextComponentLockArgs) => { 26 | const { members, self } = useMembers(); 27 | const activeMember = findActiveMember(id, slide, members); 28 | const lockId = buildLockId(slide, id); 29 | const { status, member } = useLock(lockId); 30 | const locked = status === 'locked'; 31 | const lockedByYou = locked && self?.connectionId === member?.connectionId; 32 | const [content, updateContent] = useSlideElementContent(lockId, defaultText); 33 | const preview = usePreview(); 34 | 35 | const { handleSelect } = useElementSelect(id); 36 | const handleContentUpdate = useCallback((content: string) => { 37 | updateContent(content); 38 | channel.publish('update', content); 39 | }, []); 40 | 41 | const { channel } = useChannel(channelName, (message) => { 42 | if (message.connectionId === self?.connectionId) return; 43 | const sanitizedValue = sanitize(message.data, { allowedTags: [] }); 44 | updateContent(sanitizedValue); 45 | }); 46 | 47 | const optimisticallyLocked = !!activeMember; 48 | const optimisticallyLockedByYou = optimisticallyLocked && activeMember?.connectionId === self?.connectionId; 49 | const editIsNotAllowed = !optimisticallyLockedByYou && optimisticallyLocked; 50 | const lockConflict = optimisticallyLockedByYou && locked && !lockedByYou && !preview; 51 | 52 | useClickOutside(containerRef, self, optimisticallyLockedByYou && !preview); 53 | useClearOnFailedLock(lockConflict, self); 54 | 55 | return { 56 | content, 57 | activeMember, 58 | locked: optimisticallyLocked, 59 | lockedByYou: optimisticallyLockedByYou, 60 | editIsNotAllowed, 61 | handleSelect, 62 | handleContentUpdate, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /demo/src/hooks/useTrackCursor.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react'; 2 | import { useCursors } from '@ably/spaces/react'; 3 | 4 | export const CURSOR_MOVE = 'move'; 5 | export const CURSOR_ENTER = 'enter'; 6 | export const CURSOR_LEAVE = 'leave'; 7 | 8 | export const useTrackCursor = (containerRef: RefObject, selfConnectionId?: string) => { 9 | const { set } = useCursors(); 10 | 11 | useEffect(() => { 12 | if (!containerRef.current || !set) return; 13 | 14 | const { current: cursorContainer } = containerRef; 15 | 16 | const cursorHandlers = { 17 | enter: (event: MouseEvent) => { 18 | if (!selfConnectionId) return; 19 | const { top, left } = cursorContainer.getBoundingClientRect(); 20 | set({ 21 | position: { x: event.clientX - left, y: event.clientY - top }, 22 | data: { state: CURSOR_ENTER }, 23 | }); 24 | }, 25 | move: (event: MouseEvent) => { 26 | if (!selfConnectionId) return; 27 | const { top, left } = cursorContainer.getBoundingClientRect(); 28 | set({ 29 | position: { x: event.clientX - left, y: event.clientY - top }, 30 | data: { state: CURSOR_MOVE }, 31 | }); 32 | }, 33 | leave: (event: MouseEvent) => { 34 | if (!selfConnectionId) return; 35 | const { top, left } = cursorContainer.getBoundingClientRect(); 36 | set({ 37 | position: { x: event.clientX - left, y: event.clientY - top }, 38 | data: { state: CURSOR_LEAVE }, 39 | }); 40 | }, 41 | }; 42 | 43 | cursorContainer.addEventListener('mouseenter', cursorHandlers.enter); 44 | cursorContainer.addEventListener('mousemove', cursorHandlers.move); 45 | cursorContainer.addEventListener('mouseleave', cursorHandlers.leave); 46 | 47 | return () => { 48 | cursorContainer.removeEventListener('mouseenter', cursorHandlers.enter); 49 | cursorContainer.removeEventListener('mousemove', cursorHandlers.move); 50 | cursorContainer.removeEventListener('mouseleave', cursorHandlers.leave); 51 | }; 52 | }, [set, containerRef, selfConnectionId]); 53 | }; 54 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: 'Inter', sans-serif; 7 | font-size: 0.875rem; 8 | line-height: 1.4; 9 | font-weight: 400; 10 | } 11 | -------------------------------------------------------------------------------- /demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | import { SlidesStateContextProvider } from './components/SlidesStateContext.tsx'; 7 | import Spaces from '@ably/spaces'; 8 | import { SpacesProvider, SpaceProvider } from '@ably/spaces/react'; 9 | import { Realtime } from 'ably'; 10 | import { nanoid } from 'nanoid'; 11 | import { generateSpaceName, getParamValueFromUrl } from './utils'; 12 | import { AblyProvider } from 'ably/react'; 13 | 14 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); 15 | 16 | const clientId = nanoid(); 17 | 18 | const client = new Realtime({ 19 | authUrl: `/api/ably-token-request?clientId=${clientId}`, 20 | clientId, 21 | }); 22 | 23 | const spaces = new Spaces(client); 24 | const spaceName = getParamValueFromUrl('space', generateSpaceName); 25 | 26 | root.render( 27 | 28 | 29 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | , 41 | ); 42 | -------------------------------------------------------------------------------- /demo/src/utils/active-member.ts: -------------------------------------------------------------------------------- 1 | import { Member } from './types'; 2 | 3 | export const findActiveMember = (id: string, slide: string, members?: Member[]) => { 4 | if (!members) return; 5 | return members.find((member) => member.location?.element === id && member.location?.slide === slide); 6 | }; 7 | 8 | export const findActiveMembers = (id: string, slide: string, members?: Member[]) => { 9 | return (members ?? []).filter((member) => member.location?.element === id && member.location?.slide === slide); 10 | }; 11 | 12 | export const getMemberFirstName = (member?: Member) => { 13 | if (!member) return ''; 14 | return member.profileData.name.split(' ')[0]; 15 | }; 16 | 17 | export const getOutlineClasses = (member?: Member) => { 18 | if (!member) 19 | return { 20 | outlineClasses: '', 21 | stickyLabelClasses: '', 22 | }; 23 | const { color } = member.profileData; 24 | const { name } = color; 25 | const { intensity } = color.gradientStart; 26 | return { 27 | outlineClasses: `outline-${name}-${intensity}`, 28 | stickyLabelClasses: `bg-${name}-${intensity}`, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /demo/src/utils/colors.ts: -------------------------------------------------------------------------------- 1 | import twColorSet from 'tailwindcss/colors'; 2 | import { DefaultColors } from 'tailwindcss/types/generated/colors'; 3 | 4 | const filteredTwSet = Object.keys(twColorSet) 5 | .filter( 6 | (colorName) => 7 | ![ 8 | 'gray', 9 | 'zinc', 10 | 'stone', 11 | 'slate', 12 | 'amber', 13 | 'neutral', 14 | 'warmGray', 15 | 'trueGray', 16 | 'coolGray', 17 | 'blueGray', 18 | 'lightBlue', 19 | ].includes(colorName) && typeof twColorSet[colorName as keyof DefaultColors] === 'object', 20 | ) 21 | .map((colorName) => { 22 | return { 23 | colorName, 24 | intesity: Object.entries(twColorSet[colorName as keyof DefaultColors]).filter( 25 | ([key]) => !['50', '100', '900', '950'].includes(key), 26 | ), 27 | }; 28 | }); 29 | 30 | type MemberColor = { 31 | name: string; 32 | gradientStart: { hex: string; tw: string; intensity: string }; 33 | gradientEnd: { hex: string; tw: string; intensity: string }; 34 | }; 35 | 36 | const getRandomColor: () => MemberColor = () => { 37 | const colorOptions = filteredTwSet.length; 38 | const intesityOptions = filteredTwSet[0].intesity.length - 1; 39 | 40 | const colorIndex = Math.floor(Math.random() * colorOptions); 41 | const intensityIndex = Math.floor(Math.random() * intesityOptions); 42 | 43 | const color = filteredTwSet[colorIndex]; 44 | const [startIntesity, startHex] = color.intesity[intensityIndex]; 45 | const [endIntesity, endHex] = color.intesity[intensityIndex + 1]; 46 | 47 | return { 48 | name: color.colorName, 49 | gradientStart: { 50 | intensity: startIntesity, 51 | hex: startHex as string, 52 | tw: `from-${color.colorName}-${startIntesity}`, 53 | }, 54 | gradientEnd: { 55 | intensity: endIntesity, 56 | hex: endHex as string, 57 | tw: `to-${color.colorName}-${endIntesity}`, 58 | }, 59 | }; 60 | }; 61 | 62 | export type { MemberColor }; 63 | export { getRandomColor }; 64 | -------------------------------------------------------------------------------- /demo/src/utils/fake-names.ts: -------------------------------------------------------------------------------- 1 | const fakeNames = [ 2 | 'Anum Reeve', 3 | 'Tiernan Stubbs', 4 | 'Hakim Hernandez', 5 | 'Madihah Maynard', 6 | 'Mac Beard', 7 | 'Gracie-Mae Dunne', 8 | 'Oliver Leigh', 9 | 'Jose Tapia', 10 | 'Lyle Beasley', 11 | 'Arslan Samuels', 12 | 'Dolores Viale', 13 | 'Romola De Carlo', 14 | 'Nilde Mancino', 15 | 'Donata Curro', 16 | 'Beatrice Pata', 17 | 'Ella Notaro', 18 | 'Acilia Simoni', 19 | 'Berenice Rutigliano', 20 | 'Ilia Gangi', 21 | 'Atanasia Bonacci', 22 | 'Jyoti Sane', 23 | 'Shaili Potrick', 24 | 'Manjusha Dehadray', 25 | 'Meera Deoghar', 26 | 'Sameera Bhole', 27 | 'Bimla Jichkar', 28 | 'Kavita Phadake', 29 | 'Unnati Zantye', 30 | 'Chandra Govatrikar', 31 | 'Gauravi Kotwal', 32 | 'Keenan Robinson', 33 | 'Destin Ellis', 34 | 'Tremon Epps', 35 | 'Quinell Palmer', 36 | 'Demarien Murphy', 37 | 'Andres Coleman', 38 | 'Reginal Charles', 39 | 'Terryl Young', 40 | 'Herold Mitchell', 41 | 'Kione Butler', 42 | 'Magdalena Czech', 43 | 'Wioleta Pach', 44 | 'Zofia Stasiak', 45 | 'Cecylia Wydra', 46 | 'Balbina Ochocka', 47 | 'Kinga Piłat', 48 | 'Kalina Krzemien', 49 | 'Olga Moszkowicz', 50 | 'Maryla Jeżewska', 51 | 'Odeta Turek', 52 | 'Noud Groothalle', 53 | 'Teun Ekkerink', 54 | 'Pier Assendorp', 55 | 'Matthijs Mennink', 56 | 'Korneel Septer', 57 | 'Andries IJsak', 58 | 'Freek Vellener', 59 | 'Koen Lokman', 60 | 'Lodewijk Struijck', 61 | 'Stefaan Houtzagers', 62 | 'Buddy Saunders', 63 | 'Dennis Hunter', 64 | 'Alfie Moss', 65 | 'River Lewis', 66 | 'Tomas Stone', 67 | 'Samuel Kelly', 68 | 'Rory Phillips', 69 | 'Thomas Hussain', 70 | 'Ralphy Palmer', 71 | 'Eric Evans', 72 | 'Silver Gardner', 73 | 'Jody Wright', 74 | 'Dane Mills', 75 | 'Sam Mason', 76 | 'Rudy Powell', 77 | 'Carol Griffith', 78 | 'Brynn Witt', 79 | 'Jaden Terrell', 80 | 'Riley Rice', 81 | 'Ash Mcintosh', 82 | 'Rowan Edwards', 83 | 'Ryan Shaw', 84 | 'Riley Harris', 85 | 'Danni Francis', 86 | 'Will Lloyd', 87 | 'Danni Roy', 88 | 'Alex Dean', 89 | 'Bev Gamble', 90 | 'Danny Blair', 91 | 'Leslie Diaz', 92 | 'Sugondo Xiaohui', 93 | 'Ateng Nuwa', 94 | 'Ymkje Anakotta', 95 | 'Merry Situmorang', 96 | 'Jochebed Selangit', 97 | 'Ruby Silo', 98 | 'Raja Nizar', 99 | 'Fida Nicola', 100 | 'Fairuz Jasir', 101 | 'Tarub Jamal', 102 | 'Zainab Sabri', 103 | 'Rima Khalid', 104 | 'Najla Karam', 105 | 'Oleksandra Andriyenko', 106 | 'Sofiya Hlushko', 107 | 'Myroslava Kornijchuk', 108 | 'Alina Tymchenko', 109 | 'Lina Kravchuk', 110 | 'Zhanna Novikova', 111 | 'Natalya Kravets', 112 | 'Nadiya Movchan', 113 | 'Nina Sayenko', 114 | 'Hanna Tretyak', 115 | ]; 116 | 117 | const nameToInitials = (name: string) => 118 | name 119 | .split(' ') 120 | .map((str: string) => str[0]) 121 | .slice(0, 2) 122 | .join('') 123 | .toUpperCase(); 124 | 125 | const firstName = (name: string) => name.split(' ')[0]; 126 | 127 | const getRandomName = () => fakeNames[Math.floor(Math.random() * fakeNames.length)]; 128 | 129 | export { fakeNames, nameToInitials, getRandomName, firstName }; 130 | -------------------------------------------------------------------------------- /demo/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './colors'; 2 | export * from './fake-names'; 3 | export * from './active-member'; 4 | export * from './url'; 5 | -------------------------------------------------------------------------------- /demo/src/utils/locking.ts: -------------------------------------------------------------------------------- 1 | import { Space } from '@ably/spaces'; 2 | 3 | export const releaseMyLocks = async (space: Space) => { 4 | const locks = await space.locks.getSelf(); 5 | 6 | if (locks.length > 0) { 7 | locks.forEach((lock) => space.locks.release(lock.id)); 8 | } 9 | }; 10 | 11 | export const buildLockId = (slide: string | undefined, element: string | undefined) => 12 | `/slide/${slide}/element/${element}`; 13 | -------------------------------------------------------------------------------- /demo/src/utils/types.d.ts: -------------------------------------------------------------------------------- 1 | import { type SpaceMember } from '@ably/spaces'; 2 | 3 | interface ProfileData { 4 | name: string; 5 | color: { 6 | name: string; 7 | gradientStart: { 8 | tw: string; 9 | intensity: string; 10 | hex: string; 11 | }; 12 | gradientEnd: { 13 | tw: string; 14 | intensity: string; 15 | hex: string; 16 | }; 17 | }; 18 | } 19 | 20 | type Member = Omit & { 21 | profileData: ProfileData; 22 | location: { slide: string; element: string }; 23 | }; 24 | 25 | export { ProfileData, Member }; 26 | -------------------------------------------------------------------------------- /demo/src/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { generate } from 'random-words'; 2 | 3 | const getParamValueFromUrl = (param: string, generateDefault: () => string): string => { 4 | const url = new URL(window.location.href); 5 | const value = url.searchParams.get(param); 6 | 7 | if (!value) { 8 | const generatedValue = generateDefault(); 9 | url.searchParams.set(param, generatedValue); 10 | window.history.replaceState({}, '', `?${url.searchParams.toString()}`); 11 | 12 | return generatedValue; 13 | } 14 | 15 | return value; 16 | }; 17 | 18 | const generateSpaceName = () => { 19 | return generate({ exactly: 3, join: '-' }); 20 | }; 21 | 22 | const generateTeamName = () => { 23 | return generate({ exactly: 1, join: '' }); 24 | }; 25 | 26 | export { getParamValueFromUrl, generateSpaceName, generateTeamName }; 27 | -------------------------------------------------------------------------------- /demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: { 6 | screens: { 7 | xs: '375px', 8 | sm: '640px', 9 | md: '768px', 10 | lg: '1024px', 11 | xl: '1280px', 12 | }, 13 | colors: { 14 | 'ably-black': '#03020D', 15 | 'ably-charcoal-grey': '#292831', 16 | 'ably-light-grey': '#F5F5F6', 17 | 'ably-avatar-stack-demo-slide-text': '#32394E', 18 | 'ably-avatar-stack-demo-show-replies': '#3E3E3E', 19 | 'ably-avatar-stack-demo-number-text': '#4E4E4E', 20 | 'ably-avatar-stack-demo-new-slide': '#848484', 21 | 'ably-avatar-stack-demo-slide-title-highlight': '#116AEB', 22 | 'ably-avatar-stack-demo-slide-preview-border': '#D6D1E3', 23 | }, 24 | backdropBlur: { 25 | 'ably-xs': '2.5px', 26 | }, 27 | boxShadow: { 28 | 'ably-paper': '0px 0px 19px rgba(0, 0, 0, 0.08)', 29 | }, 30 | }, 31 | }, 32 | safelist: [ 33 | { 34 | pattern: 35 | /(from|to|outline|bg)-(red|orange|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(200|300|400|500|600|700|800)/, 36 | variants: ['before'], 37 | }, 38 | ], 39 | plugins: [], 40 | }; 41 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | resolve: { 7 | alias: { 8 | // Silence Vite warnings about node packages loaded by postcss 9 | // See https://github.com/vitejs/vite/issues/9200 10 | path: './noop.js', 11 | fs: './noop.js', 12 | url: './noop.js', 13 | 'source-map-js': './noop.js', 14 | }, 15 | }, 16 | plugins: [react()], 17 | server: { 18 | port: 8080, 19 | strictPort: true, 20 | host: true, 21 | proxy: { 22 | '/.netlify': 'http://localhost:9999/.netlify', 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /docs/channel-usage.md: -------------------------------------------------------------------------------- 1 | # Channel usage 2 | 3 | ## Introduction & Context 4 | 5 | The below channels are used by the Spaces library internally. 6 | 7 | ### Space channel 8 | 9 | Each `Space` (as defined by the [`Space` class](https://sdk.ably.com/builds/ably/spaces/main/typedoc/classes/Space.html)) creates its own [Ably Channel](https://ably.com/docs/channels). 10 | 11 | The channel name is defined by the `name` of the Space and takes the form: `${name}::$space`. The full name of a `channel` belonging to a `Space` called 'slides' would therefore be `slides::$space`. 12 | 13 | ### Cursors 14 | 15 | If any member of a `Space` subscribes to or sets cursor updates a channel is created for `cursors` updates. 16 | 17 | The channel name is defined by the name of the Space with the `::$cursors` suffix, taking the form: `${space.name}::$cursors`. The full name of a channel belonging to a `Space` called 'slides' would therefore be `slides::$cursors`. 18 | 19 | #### Events published 20 | 21 | 1. `cursorUpdate` - a batch of cursor updates passed to [`set`](https://sdk.ably.com/builds/ably/spaces/main/typedoc/classes/Cursors.html#set). 22 | -------------------------------------------------------------------------------- /docs/connection-and-channel-management.md: -------------------------------------------------------------------------------- 1 | # Connection and channel management 2 | 3 | Spaces SDK uses the Ably Core SDK client [connections](https://ably.com/docs/connect) and [channels](https://ably.com/docs/channels) to provide higher level features like cursors or locations. Both connections and channels will transition through multiple states throughout their lifecycle. Most state transitions (like a short loss in connectivity) will be handled by the Ably SDK, but there will be use cases where developers will need to observe these states and handle them accordingly. 4 | 5 | This document describes how to access a connection and channels on Spaces, and where to find information about how to handle their state changes. 6 | 7 | ## Connection 8 | 9 | When initializing the Spaces SDK, an Ably client is passed as a required argument: 10 | 11 | ```ts 12 | const client = new Realtime({ key: "", clientId: "" }); 13 | const spaces = new Spaces(client); 14 | ``` 15 | 16 | The Spaces instance exposes the underlying connection which is an event emitter. It can be used to listen for changes in connection state. The client and connection are both available on the Spaces instance: 17 | 18 | ```ts 19 | spaces.client.connection.on('disconnected', () => {}) 20 | spaces.connection.on('disconnected', () => {}) 21 | ``` 22 | 23 | ### Read more on: 24 | 25 | - [Connections](https://ably.com/docs/connect) 26 | - [Connection state and recovery](https://ably.com/docs/connect/states) 27 | 28 | ## Channels 29 | 30 | When a Space is instantiated, it creates an underlying [Ably Channel](https://ably.com/docs/channels) which is used to deliver the functionality of each space. The Ably Channel object is available in the `.channel` attribute. 31 | 32 | Similar to a connection, a channel is an event emitter, allowing us to listen for state changes: 33 | 34 | ```ts 35 | const mySpace = spaces.get('mySpace'); 36 | mySpace.channel.once('suspended', () => {}); 37 | ``` 38 | 39 | When using the Cursors API, an additional channel is used, but it will only be created if we attach a subscriber or set a cursor position: 40 | 41 | ```ts 42 | mySpace.cursors.channel.once('attached', () => {}); 43 | 44 | // but .channel will only be defined if one of these was called before 45 | mySpace.cursors.set({ position: { x, y } });    46 | mySpace.cursors.subscribe('update', () => {}); 47 | ``` 48 | 49 | ### Read more on: 50 | 51 | - [Channels](https://ably.com/docs/channels) 52 | - [Channel states](https://ably.com/docs/channels#states) 53 | - [Handle channel failure](https://ably.com/docs/channels#failure) 54 | -------------------------------------------------------------------------------- /docs/images/avatar-stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably/spaces/23f85ace7748f76c8e62abca7080a1917342d09f/docs/images/avatar-stack.png -------------------------------------------------------------------------------- /docs/images/collab.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably/spaces/23f85ace7748f76c8e62abca7080a1917342d09f/docs/images/collab.gif -------------------------------------------------------------------------------- /docs/images/field-locking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably/spaces/23f85ace7748f76c8e62abca7080a1917342d09f/docs/images/field-locking.png -------------------------------------------------------------------------------- /docs/images/live-updates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably/spaces/23f85ace7748f76c8e62abca7080a1917342d09f/docs/images/live-updates.png -------------------------------------------------------------------------------- /docs/images/user-location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably/spaces/23f85ace7748f76c8e62abca7080a1917342d09f/docs/images/user-location.png -------------------------------------------------------------------------------- /docs/react.md: -------------------------------------------------------------------------------- 1 | # React Hooks 2 | 3 | > [!NOTE] 4 | > For more information about React Hooks for Spaces, please see the official [Spaces documentation](https://ably.com/docs/spaces/react). 5 | 6 | Incorporate Spaces into your React application with idiomatic and user-friendly React Hooks. 7 | 8 | Using this module you can: 9 | 10 | - Interact with [Ably Spaces](https://ably.com/docs/spaces) using a React Hook. 11 | - Subscribe to events in a space 12 | - Retrieve the membership of a space 13 | - Set the location of space members 14 | - Acquire locks on components within a space 15 | - Set the position of members' cursors in a space 16 | 17 | --- 18 | 19 | - [Compatible React Versions](#compatible-react-versions) 20 | - [Usage](#usage) 21 | + [useSpace](#usespace) 22 | + [useMembers](#usemembers) 23 | + [useLocation](#uselocation) 24 | + [useLocks](#uselocks) 25 | + [useLock](#uselock) 26 | + [useCursors](#usecursors) 27 | + [Error Handling](#error-handling) 28 | 29 | --- 30 | 31 | ## Compatible React Versions 32 | 33 | The hooks are compatible with all versions of React above 16.8.0 34 | 35 | ## Usage 36 | 37 | Start by connecting your app to Ably using the `SpacesProvider` component. 38 | 39 | The `SpacesProvider` should wrap every component that needs to access Spaces. 40 | 41 | ```jsx 42 | import { Realtime } from "ably"; 43 | import Spaces from "@ably/spaces"; 44 | import { SpacesProvider, SpaceProvider } from "@ably/spaces/react"; 45 | 46 | const ably = new Realtime({ key: "your-ably-api-key", clientId: 'me' }); 47 | const spaces = new Spaces(ably); 48 | 49 | root.render( 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | ``` 57 | 58 | Once you've done this, you can use the `hooks` in your code. The simplest example is as follows: 59 | 60 | ```javascript 61 | const { self, others } = useMembers(); 62 | ``` 63 | 64 | Our react hooks are designed to run on the client-side, so if you are using server-side rendering, make sure that your components which use Spaces react hooks are only rendered on the client side. 65 | 66 | --- 67 | 68 | ### useSpace 69 | 70 | The `useSpace` hook lets you subscribe to the current Space and receive Space state events and get the current Space instance. 71 | 72 | ```javascript 73 | const { space } = useSpace((update) => { 74 | console.log(update); 75 | }); 76 | ``` 77 | 78 | ### useMembers 79 | 80 | The `useMembers` hook is useful in building avatar stacks. By using the `useMembers` hook you can retrieve members of the space. 81 | This includes members that have recently left the space, but have not yet been removed. 82 | 83 | ```javascript 84 | const { self, others, members } = useMembers(); 85 | ``` 86 | 87 | * `self` - a member’s own member object 88 | * `others` - an array of member objects for all members other than the member themselves 89 | * `members` - an array of all member objects, including the member themselves 90 | 91 | It also lets you subscribe to members entering, leaving, being 92 | removed from the Space (after a timeout) or updating their profile information. 93 | 94 | ```javascript 95 | // Subscribe to all member events in a space 96 | useMembers((memberUpdate) => { 97 | console.log(memberUpdate); 98 | }); 99 | 100 | // Subscribe to member enter events only 101 | useMembers('enter', (memberJoined) => { 102 | console.log(memberJoined); 103 | }); 104 | 105 | // Subscribe to member leave events only 106 | useMembers('leave', (memberLeft) => { 107 | console.log(memberLeft); 108 | }); 109 | 110 | // Subscribe to member remove events only 111 | useMembers('remove', (memberRemoved) => { 112 | console.log(memberRemoved); 113 | }); 114 | 115 | // Subscribe to profile updates on members only 116 | useMembers('updateProfile', (memberProfileUpdated) => { 117 | console.log(memberProfileUpdated); 118 | }); 119 | 120 | // Subscribe to all updates to members 121 | useMembers('update', (memberUpdate) => { 122 | console.log(memberUpdate); 123 | }); 124 | ``` 125 | 126 | ### useLocation 127 | 128 | The `useLocation` hook lets you subscribe to location events. 129 | Location events are emitted whenever a member changes location. 130 | 131 | ```javascript 132 | useLocation((locationUpdate) => { 133 | console.log(locationUpdate); 134 | }); 135 | ``` 136 | 137 | `useLocation` also enables you to update current member location by using `update` method provided by hook. For example: 138 | 139 | ```javascript 140 | const { update } = useLocation((locationUpdate) => { 141 | console.log(locationUpdate); 142 | }); 143 | ``` 144 | 145 | ### useLocks 146 | 147 | `useLocks` enables you to subscribe to lock events by registering a listener. Lock events are emitted whenever a lock transitions into the `locked` or `unlocked` state. 148 | 149 | ```javascript 150 | useLocks((lockUpdate) => { 151 | console.log(lockUpdate); 152 | }); 153 | ``` 154 | 155 | ### useLock 156 | 157 | `useLock` returns the status of a lock and, if the lock has been acquired, the member holding that lock. 158 | 159 | ```javascript 160 | const { status, member } = useLock('my-lock'); 161 | ``` 162 | 163 | ### useCursors 164 | 165 | `useCursors` enables you to track a member's cursor position and provide a view of all members' cursors within a space. For example: 166 | 167 | ```javascript 168 | // Subscribe to events published on "mousemove" by all members 169 | const { set } = useCursors((cursorUpdate) => { 170 | console.log(cursorUpdate); 171 | }); 172 | 173 | useEffect(() => { 174 | // Publish a your cursor position on "mousemove" including optional data 175 | const eventListener = ({ clientX, clientY }) => { 176 | set({ position: { x: clientX, y: clientY }, data: { color: 'red' } }); 177 | } 178 | 179 | window.addEventListener('mousemove', eventListener); 180 | 181 | return () => { 182 | window.removeEventListener('mousemove', eventListener); 183 | }; 184 | }); 185 | ``` 186 | 187 | If you provide `{ returnCursors: true }` as an option you can get active members cursors: 188 | 189 | ```javascript 190 | const { cursors } = useCursors((cursorUpdate) => { 191 | console.log(cursorUpdate); 192 | }, { returnCursors: true }); 193 | ``` 194 | 195 | --- 196 | 197 | ### Error Handling 198 | 199 | `useSpace`, `useMembers`, `useLocks` and `useCursors` return connection and channel errors you may encounter, so that you can handle then within your components. This may include when a client doesn't have permission to attach to a channel, or if it loses its connection to Ably. 200 | 201 | ```jsx 202 | const { connectionError, channelError } = useMembers(); 203 | 204 | if (connectionError) { 205 | // TODO: handle connection errors 206 | } else if (channelError) { 207 | // TODO: handle channel errors 208 | } else { 209 | return 210 | } 211 | ``` 212 | -------------------------------------------------------------------------------- /docs/typedoc/intro.md: -------------------------------------------------------------------------------- 1 | # Ably Spaces SDK 2 | 3 | This is the API reference for Ably’s Spaces SDK, generated with [TypeDoc](https://typedoc.org/). 4 | 5 | To find out more about Spaces and how to get started, visit the [Ably documentation website](https://ably.com/docs/spaces). 6 | -------------------------------------------------------------------------------- /examples/avatar-stack.ts: -------------------------------------------------------------------------------- 1 | import Spaces from '@ably/spaces'; 2 | import { Realtime } from 'ably'; 3 | 4 | import { renderAvatars, renderNotification } from './my-application'; 5 | 6 | // Create Ably client 7 | const client = new Realtime({ authUrl: '', clientId: '' }); 8 | 9 | // Initialize the Spaces SDK with an Ably client 10 | const spaces = new Spaces(client); 11 | 12 | // Create a new space 13 | const space = await spaces.get('slide-deck-224'); 14 | 15 | // Enter a space to become a member 16 | await space.enter({ name: 'Kyle' }); 17 | 18 | // Listen to the member changes within a space 19 | // Triggers for members entering/ leaving a space or updating their profile 20 | space.members.subscribe('update', () => { 21 | const otherMembers = space.members.getOthers(); 22 | renderAvatars(otherMembers); 23 | }); 24 | 25 | // Listen to leave events only to show a notification 26 | space.members.subscribe('leave', (member) => { 27 | renderNotification('memberHasLeft', member); 28 | }); 29 | -------------------------------------------------------------------------------- /examples/live-cursors.ts: -------------------------------------------------------------------------------- 1 | import Spaces from '@ably/spaces'; 2 | import { Realtime } from 'ably'; 3 | 4 | import renderCursor from './my-application'; 5 | 6 | // Create Ably client 7 | const client = new Realtime({ authUrl: '', clientId: '' }); 8 | 9 | // Initialize the Spaces SDK with an Ably client 10 | const spaces = new Spaces(client); 11 | 12 | // Create a new space 13 | const space = await spaces.get('slide-deck-224'); 14 | 15 | // Enter a space to become a member 16 | space.enter({ name: 'Helmut' }); 17 | 18 | // Listen to all changes to all members within a space 19 | space.cursors.subscribe('update', async (cursorUpdate) => { 20 | const members = await space.members.getAll(); 21 | const member = members.find((member) => member.connectionId === cursorUpdate.connectionId); 22 | renderCursor(cursorUpdate, member); 23 | }); 24 | 25 | // Publish cursor events to other members 26 | window.addEventListener('mousemove', ({ clientX, clientY }) => { 27 | space.cursors.set({ position: { x: clientX, y: clientY } }); 28 | }); 29 | -------------------------------------------------------------------------------- /examples/location.ts: -------------------------------------------------------------------------------- 1 | import Spaces from '@ably-labs/spaces'; 2 | import { Realtime } from 'ably'; 3 | 4 | import updateLocationsForMember from './my-application'; 5 | 6 | // Create Ably client 7 | const client = new Realtime({ authUrl: '', clientId: '' }); 8 | 9 | // Initialize the Spaces SDK with an Ably client 10 | const spaces = new Spaces(client); 11 | 12 | // Create a new space 13 | const space = await spaces.get('slide-deck-224'); 14 | 15 | // Enter a space to become a member 16 | await space.enter({ name: 'Amelie' }); 17 | 18 | // Subscribe to all members' location updates 19 | space.locations.subscribe('update', ({ member, currentLocation, previousLocation }) => { 20 | // Update UI to reflect other members locations 21 | updateLocationsForMember(member, currentLocation, previousLocation); 22 | }); 23 | 24 | // Set your location 25 | await space.locations.set({ slide: 0, elementId: 'title' }); 26 | -------------------------------------------------------------------------------- /examples/locking.ts: -------------------------------------------------------------------------------- 1 | import Spaces, { LockStatus } from '@ably/spaces'; 2 | import { Realtime } from 'ably'; 3 | 4 | import { enableLocationEditing, lockId } from './my-application'; 5 | 6 | // Create Ably client 7 | const client = new Realtime({ authUrl: '', clientId: '' }); 8 | 9 | // Initialize the Spaces SDK with an Ably client 10 | const spaces = new Spaces(client); 11 | 12 | // Create a new space 13 | const space = await spaces.get('slide-deck-224'); 14 | 15 | // Enter a space to become a member 16 | await space.enter({ name: 'Yoshi' }); 17 | 18 | const isLocked = space.locks.get(lockId); 19 | 20 | if (!isLocked) { 21 | await space.locks.acquire(lockId); 22 | } 23 | 24 | // Update UI when parts of the UI are locked 25 | space.locks.subscribe('update', async (lock) => { 26 | const self = await space.members.getSelf(); 27 | 28 | if (lock.request.status === LockStatus.LOCKED && self.connectionId === lock.member.connectionId) { 29 | const location = { 30 | slide: lock.request.attributes.get('slide'), 31 | elementId: lock.request.attributes.get('elementId'), 32 | }; 33 | enableLocationEditing({ location }); 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /examples/locks.ts: -------------------------------------------------------------------------------- 1 | // An example of members locating at and locking elements in a slides 2 | // application. 3 | import Ably from 'ably'; 4 | import Spaces from '../dist/cjs/Spaces.js'; 5 | import { Lock, LockAttributes } from '../dist/cjs/index.js'; 6 | 7 | // SlideElement represents an element on a slide which a member can both be 8 | // located at and attempt to lock (e.g. an editable text box). 9 | class SlideElement { 10 | slideId: string; 11 | elementId: string; 12 | 13 | constructor(slideId: string, elementId: string) { 14 | this.slideId = slideId; 15 | this.elementId = elementId; 16 | } 17 | 18 | // the identifier to use to lock this SlideElement. 19 | lockId(): string { 20 | return `/slides/${this.slideId}/element/${this.elementId}`; 21 | } 22 | 23 | // the attributes to use when locking this SlideElement. 24 | lockAttributes(): LockAttributes { 25 | const attributes = new LockAttributes(); 26 | attributes.set('slideId', this.slideId); 27 | attributes.set('elementId', this.elementId); 28 | return attributes; 29 | } 30 | } 31 | 32 | // define a main async function since we can't use await at the top-level. 33 | const main = async () => { 34 | info('initialising Ably client'); 35 | const client = new Ably.Realtime({ 36 | key: process.env.ABLY_API_KEY, 37 | clientId: 'Alice', 38 | }); 39 | 40 | info('entering the "example" space'); 41 | const spaces = new Spaces(client); 42 | const space = await spaces.get('example'); 43 | await space.enter(); 44 | 45 | const location = new SlideElement('123', '456'); 46 | info(`setting location to ${JSON.stringify(location)}`); 47 | await space.locations.set(location); 48 | 49 | info('checking if location is locked'); 50 | const lockId = location.lockId(); 51 | const isLocked = space.locks.get(lockId) !== undefined; 52 | if (isLocked) { 53 | info('location is already locked'); 54 | process.exit(); 55 | } else { 56 | info('location is not locked'); 57 | } 58 | 59 | // initialise a Promise which resolves when the lock changes status. 60 | const lockEvent = new Promise((resolve) => { 61 | const listener = (lock: Lock) => { 62 | if (lock.request.id === lockId) { 63 | info(`received lock update for "${lockId}", status=${lock.request.status}`); 64 | resolve(lock); 65 | info('unsubscribing from lock events'); 66 | space.locks.unsubscribe(listener); 67 | } 68 | }; 69 | info('subscribing to lock events'); 70 | space.locks.subscribe('update', listener); 71 | }); 72 | 73 | info(`attempting to lock "${lockId}"`); 74 | const req = await space.locks.acquire(lockId, { attributes: location.lockAttributes() }); 75 | info(`lock status is "${req.status}"`); 76 | 77 | info('waiting for lock event'); 78 | const lock = await lockEvent; 79 | 80 | info(`lock status is "${lock.request.status}"`); 81 | 82 | info('releasing the lock'); 83 | await space.locks.release(lockId); 84 | 85 | info('done'); 86 | client.close(); 87 | }; 88 | 89 | const info = (msg: string) => { 90 | console.log(new Date(), 'INFO', msg); 91 | } 92 | 93 | main(); 94 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "demo" 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ably/spaces", 3 | "version": "0.4.0", 4 | "description": "", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/mjs/index.js", 7 | "types": "dist/mjs/index.d.ts", 8 | "unpkg": "dist/iife/index.bundle.js", 9 | "scripts": { 10 | "lint": "eslint .", 11 | "lint:fix": "eslint --fix .", 12 | "format": "prettier --write src demo __mocks__ test", 13 | "format:check": "prettier --check src demo __mocks__ test", 14 | "test": "vitest run", 15 | "test:watch": "vitest watch", 16 | "test:cdn-bundle": "npx playwright test -c test/cdn-bundle/playwright.config.js", 17 | "test-support:cdn-server": "ts-node test/cdn-bundle/server.ts", 18 | "coverage": "vitest run --coverage", 19 | "build": "npm run build:mjs && npm run build:cjs && npm run build:iife", 20 | "build:mjs": "npx tsc --project tsconfig.mjs.json && cp res/package.mjs.json dist/mjs/package.json", 21 | "build:cjs": "npx tsc --project tsconfig.cjs.json && cp res/package.cjs.json dist/cjs/package.json", 22 | "build:iife": "rm -rf dist/iife && npx tsc --project tsconfig.iife.json && rollup -c", 23 | "examples:locks": "ts-node ./examples/locks.ts", 24 | "docs": "typedoc" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/ably/spaces.git" 29 | }, 30 | "author": "", 31 | "license": "ISC", 32 | "bugs": { 33 | "url": "" 34 | }, 35 | "homepage": "https://github.com/ably/spaces", 36 | "publishConfig": { 37 | "access": "public" 38 | }, 39 | "keywords": [], 40 | "devDependencies": { 41 | "@playwright/test": "^1.39.0", 42 | "@rollup/plugin-node-resolve": "^15.2.3", 43 | "@rollup/plugin-terser": "^0.4.4", 44 | "@testing-library/react": "^14.0.0", 45 | "@types/express": "^4.17.18", 46 | "@types/react": "^18.2.23", 47 | "@types/react-dom": "^18.2.8", 48 | "@typescript-eslint/eslint-plugin": "^5.51.0", 49 | "@typescript-eslint/parser": "^5.51.0", 50 | "@vitest/coverage-c8": "^0.33.0", 51 | "@vitest/coverage-v8": "^0.34.6", 52 | "eslint": "^8.33.0", 53 | "eslint-plugin-import": "^2.27.5", 54 | "eslint-plugin-jsdoc": "^46.7.0", 55 | "eslint-plugin-security": "^1.7.1", 56 | "express": "^4.18.2", 57 | "jsdom": "^22.1.0", 58 | "prettier": "^3.0.3", 59 | "react": "^18.2.0", 60 | "react-dom": "^18.2.0", 61 | "rollup": "^4.5.0", 62 | "ts-node": "^10.9.1", 63 | "typedoc": "^0.25.2", 64 | "typescript": "^5.2.2", 65 | "vitest": "^0.34.3" 66 | }, 67 | "dependencies": { 68 | "nanoid": "^3.3.7" 69 | }, 70 | "peerDependencies": { 71 | "ably": "^2.3.0", 72 | "react": ">=16.8.0", 73 | "react-dom": ">=16.8.0" 74 | }, 75 | "peerDependenciesMeta": { 76 | "react": { 77 | "optional": true 78 | }, 79 | "react-dom": { 80 | "optional": true 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ably/spaces/react", 3 | "type": "module", 4 | "main": "../dist/cjs/react/index.js", 5 | "module": "../dist/mjs/react/index.js", 6 | "types": "../dist/mjs/react/index.d.ts", 7 | "sideEffects": false 8 | } 9 | -------------------------------------------------------------------------------- /res/package.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /res/package.mjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import terser from '@rollup/plugin-terser'; 3 | 4 | export default { 5 | input: 'dist/iife/index.js', 6 | output: { 7 | format: 'iife', 8 | name: 'Spaces', 9 | file: 'dist/iife/index.bundle.js', 10 | globals: { 11 | ably: 'Ably', 12 | }, 13 | compact: true, 14 | plugins: [terser()], 15 | }, 16 | external: ['ably'], 17 | plugins: [nodeResolve({ browser: true })], 18 | }; 19 | -------------------------------------------------------------------------------- /src/CursorBatching.ts: -------------------------------------------------------------------------------- 1 | import { RealtimeChannel } from 'ably'; 2 | 3 | import { CURSOR_UPDATE } from './CursorConstants.js'; 4 | import type { CursorUpdate } from './types.js'; 5 | import type { CursorsOptions } from './types.js'; 6 | 7 | export type OutgoingBuffer = { cursor: Pick; offset: number }; 8 | 9 | export default class CursorBatching { 10 | outgoingBuffer: OutgoingBuffer[] = []; 11 | 12 | batchTime: number; 13 | 14 | // Set to `true` when a cursor position is in the buffer 15 | hasMovement = false; 16 | 17 | // Set to `true` when the buffer is actively being emptied 18 | isRunning: boolean = false; 19 | 20 | // Set to `true` if there is more than one user listening to cursors 21 | shouldSend: boolean = false; 22 | 23 | // Used for tracking offsets in the buffer 24 | bufferStartTimestamp: number = 0; 25 | 26 | constructor(readonly outboundBatchInterval: CursorsOptions['outboundBatchInterval']) { 27 | this.batchTime = outboundBatchInterval; 28 | } 29 | 30 | pushCursorPosition(channel: RealtimeChannel, cursor: Pick) { 31 | // Ignore the cursor update if there is no one listening 32 | if (!this.shouldSend) return; 33 | 34 | const timestamp = new Date().getTime(); 35 | 36 | let offset: number; 37 | // First update in the buffer is always 0 38 | if (this.outgoingBuffer.length === 0) { 39 | offset = 0; 40 | this.bufferStartTimestamp = timestamp; 41 | } else { 42 | // Add the offset compared to the first update in the buffer 43 | offset = timestamp - this.bufferStartTimestamp; 44 | } 45 | 46 | this.hasMovement = true; 47 | this.pushToBuffer({ cursor, offset }); 48 | this.publishFromBuffer(channel, CURSOR_UPDATE); 49 | } 50 | 51 | setShouldSend(shouldSend: boolean) { 52 | this.shouldSend = shouldSend; 53 | } 54 | 55 | setBatchTime(batchTime: number) { 56 | this.batchTime = batchTime; 57 | } 58 | 59 | private pushToBuffer(value: OutgoingBuffer) { 60 | this.outgoingBuffer.push(value); 61 | } 62 | 63 | private async publishFromBuffer(channel: RealtimeChannel, eventName: string) { 64 | if (!this.isRunning) { 65 | this.isRunning = true; 66 | await this.batchToChannel(channel, eventName); 67 | } 68 | } 69 | 70 | private async batchToChannel(channel: RealtimeChannel, eventName: string) { 71 | if (!this.hasMovement) { 72 | this.isRunning = false; 73 | return; 74 | } 75 | // Must be copied here to avoid a race condition where the buffer is cleared before the publish happens 76 | const bufferCopy = [...this.outgoingBuffer]; 77 | channel.publish(eventName, bufferCopy); 78 | setTimeout(() => this.batchToChannel(channel, eventName), this.batchTime); 79 | this.outgoingBuffer = []; 80 | this.hasMovement = false; 81 | this.isRunning = true; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/CursorConstants.ts: -------------------------------------------------------------------------------- 1 | export const CURSOR_UPDATE = 'cursorUpdate'; 2 | -------------------------------------------------------------------------------- /src/CursorDispensing.ts: -------------------------------------------------------------------------------- 1 | import { type CursorUpdate } from './types.js'; 2 | import { type RealtimeInboundMessage } from './utilities/types.js'; 3 | 4 | export default class CursorDispensing { 5 | private buffer: Record = {}; 6 | 7 | constructor(private emitCursorUpdate: (update: CursorUpdate) => void) {} 8 | 9 | setEmitCursorUpdate(update: CursorUpdate) { 10 | this.emitCursorUpdate(update); 11 | } 12 | 13 | emitFromBatch() { 14 | for (let connectionId in this.buffer) { 15 | const buffer = this.buffer[connectionId]; 16 | const update = buffer.shift(); 17 | 18 | if (!update) continue; 19 | setTimeout(() => this.setEmitCursorUpdate(update.cursor), update.offset); 20 | } 21 | 22 | if (this.bufferHaveData()) { 23 | this.emitFromBatch(); 24 | } 25 | } 26 | 27 | bufferHaveData(): boolean { 28 | return ( 29 | Object.entries(this.buffer) 30 | .map(([, v]) => v) 31 | .flat().length > 0 32 | ); 33 | } 34 | 35 | processBatch(message: RealtimeInboundMessage) { 36 | const updates: { cursor: CursorUpdate; offset: number }[] = message.data || []; 37 | 38 | updates.forEach((update: { cursor: CursorUpdate; offset: number }) => { 39 | const enhancedMsg: { cursor: CursorUpdate; offset: number } = { 40 | cursor: { 41 | clientId: message.clientId, 42 | connectionId: message.connectionId, 43 | position: update.cursor.position, 44 | data: update.cursor.data, 45 | }, 46 | offset: update.offset, 47 | }; 48 | 49 | if (this.buffer[enhancedMsg.cursor.connectionId]) { 50 | this.buffer[enhancedMsg.cursor.connectionId].push(enhancedMsg); 51 | } else { 52 | this.buffer[enhancedMsg.cursor.connectionId] = [enhancedMsg]; 53 | } 54 | }); 55 | 56 | if (this.bufferHaveData()) { 57 | this.emitFromBatch(); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/CursorHistory.ts: -------------------------------------------------------------------------------- 1 | import { InboundMessage, PaginatedResult, RealtimeChannel } from 'ably'; 2 | 3 | import type { CursorUpdate } from './types.js'; 4 | import type { CursorsOptions } from './types.js'; 5 | import type { OutgoingBuffer } from './CursorBatching.js'; 6 | 7 | type ConnectionId = string; 8 | type ConnectionsLastPosition = Record; 9 | 10 | export default class CursorHistory { 11 | constructor() {} 12 | 13 | private positionsMissing(connections: ConnectionsLastPosition) { 14 | return Object.keys(connections).some((connectionId) => connections[connectionId] === null); 15 | } 16 | 17 | private messageToUpdate( 18 | connectionId: string, 19 | clientId: string, 20 | update: Pick, 21 | ): CursorUpdate { 22 | return { 23 | clientId, 24 | connectionId, 25 | position: update.position, 26 | data: update.data, 27 | }; 28 | } 29 | 30 | private allCursorUpdates( 31 | connections: ConnectionsLastPosition, 32 | page: PaginatedResult, 33 | ): ConnectionsLastPosition { 34 | return Object.fromEntries( 35 | Object.entries(connections).map(([connectionId, cursors]) => { 36 | const lastMessage = page.items.find((item) => item.connectionId === connectionId); 37 | if (!lastMessage) return [connectionId, cursors]; 38 | 39 | const { data = [], clientId }: { data?: OutgoingBuffer[] } & Pick = lastMessage; 40 | 41 | const lastPositionSet = data[data.length - 1]?.cursor; 42 | const lastUpdate = lastPositionSet 43 | ? { 44 | clientId, 45 | connectionId, 46 | position: lastPositionSet.position, 47 | data: lastPositionSet.data, 48 | } 49 | : null; 50 | 51 | return [connectionId, lastUpdate]; 52 | }), 53 | ); 54 | } 55 | 56 | async getLastCursorUpdate( 57 | channel: RealtimeChannel, 58 | paginationLimit: CursorsOptions['paginationLimit'], 59 | ): Promise { 60 | const members = await channel.presence.get(); 61 | 62 | if (members.length === 0) return {}; 63 | 64 | let connections: ConnectionsLastPosition = members.reduce( 65 | (acc, member) => ({ 66 | ...acc, 67 | [member.connectionId]: null, 68 | }), 69 | {}, 70 | ); 71 | const history = await channel.history(); 72 | 73 | let pageNo = 1; 74 | let page = await history.current(); 75 | connections = this.allCursorUpdates(connections, page); 76 | pageNo++; 77 | 78 | while (pageNo <= paginationLimit && this.positionsMissing(connections) && history.hasNext()) { 79 | // assert result of .next() is non null as we've checked .hasNext() before 80 | page = (await history.next())!; 81 | connections = this.allCursorUpdates(connections, page); 82 | pageNo++; 83 | } 84 | 85 | return connections; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Errors.ts: -------------------------------------------------------------------------------- 1 | import { ErrorInfo } from 'ably'; 2 | 3 | export const ERR_SPACE_NAME_MISSING = () => new ErrorInfo('must have a non-empty name for the space', 101000, 400); 4 | 5 | export const ERR_NOT_ENTERED_SPACE = () => new ErrorInfo('must enter a space to perform this operation', 101001, 400); 6 | 7 | export const ERR_LOCK_REQUEST_EXISTS = () => new ErrorInfo('lock request already exists', 101002, 400); 8 | 9 | export const ERR_LOCK_IS_LOCKED = () => new ErrorInfo('lock is currently locked', 101003, 400); 10 | 11 | export const ERR_LOCK_INVALIDATED = () => 12 | new ErrorInfo('lock was invalidated by a concurrent lock request which now holds the lock', 101004, 400); 13 | -------------------------------------------------------------------------------- /src/Leavers.ts: -------------------------------------------------------------------------------- 1 | import type { SpaceMember } from './types.js'; 2 | 3 | type SpaceLeaver = { 4 | member: SpaceMember; 5 | timeoutId: ReturnType; 6 | }; 7 | 8 | class Leavers { 9 | private leavers: SpaceLeaver[] = []; 10 | 11 | constructor(private offlineTimeout: number) {} 12 | 13 | getByConnectionId(connectionId: string): SpaceLeaver | undefined { 14 | return this.leavers.find((leaver) => leaver.member.connectionId === connectionId); 15 | } 16 | 17 | getAll(): SpaceLeaver[] { 18 | return this.leavers; 19 | } 20 | 21 | addLeaver(member: SpaceMember, timeoutCallback: () => void) { 22 | // remove any existing leaver to prevent old timers from firing 23 | this.removeLeaver(member.connectionId); 24 | 25 | this.leavers.push({ 26 | member, 27 | timeoutId: setTimeout(timeoutCallback, this.offlineTimeout), 28 | }); 29 | } 30 | 31 | removeLeaver(connectionId: string) { 32 | const leaverIndex = this.leavers.findIndex((leaver) => leaver.member.connectionId === connectionId); 33 | 34 | if (leaverIndex >= 0) { 35 | clearTimeout(this.leavers[leaverIndex].timeoutId); 36 | this.leavers.splice(leaverIndex, 1); 37 | } 38 | } 39 | } 40 | 41 | export default Leavers; 42 | -------------------------------------------------------------------------------- /src/Locations.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect, vi, beforeEach } from 'vitest'; 2 | import { PresenceMessage, Realtime, RealtimeClient, RealtimePresence } from 'ably'; 3 | 4 | import Space from './Space.js'; 5 | 6 | import { 7 | createPresenceEvent, 8 | createPresenceMessage, 9 | createLocationUpdate, 10 | createSpaceMember, 11 | } from './utilities/test/fakes.js'; 12 | 13 | interface SpaceTestContext { 14 | client: RealtimeClient; 15 | space: Space; 16 | presence: RealtimePresence; 17 | presenceMap: Map; 18 | } 19 | 20 | vi.mock('ably'); 21 | vi.mock('nanoid'); 22 | 23 | describe('Locations', () => { 24 | beforeEach((context) => { 25 | const client = new Realtime({}); 26 | const presence = client.channels.get('').presence; 27 | const presenceMap = new Map(); 28 | 29 | presenceMap.set('1', createPresenceMessage('enter', { clientId: 'MOCK_CLIENT_ID' })); 30 | presenceMap.set('2', createPresenceMessage('update', { clientId: '2', connectionId: '2' })); 31 | 32 | vi.spyOn(presence, 'get').mockImplementation(async () => { 33 | return Array.from(presenceMap.values()); 34 | }); 35 | 36 | context.client = client; 37 | context.space = new Space('test', client); 38 | context.presence = presence; 39 | context.presenceMap = presenceMap; 40 | }); 41 | 42 | describe('set', () => { 43 | it('errors if setting location before entering the space', ({ space, presence }) => { 44 | // override presence.get() so the current member is not in presence 45 | vi.spyOn(presence, 'get').mockImplementation(async () => []); 46 | 47 | expect(() => space.locations.set('location1')).rejects.toThrowError(); 48 | }); 49 | 50 | it('sends a presence update on location set', async ({ space, presence }) => { 51 | const spy = vi.spyOn(presence, 'update'); 52 | await space.locations.set('location1'); 53 | expect(spy).toHaveBeenCalledWith(createLocationUpdate({ current: 'location1' })); 54 | }); 55 | 56 | it('fires an event when a location is set', async ({ space, presenceMap }) => { 57 | const spy = vi.fn(); 58 | space.locations.subscribe('update', spy); 59 | await createPresenceEvent(space, presenceMap, 'update', { 60 | data: createLocationUpdate({ current: 'location1' }), 61 | }); 62 | expect(spy).toHaveBeenCalledOnce(); 63 | }); 64 | 65 | it('correctly sets previousLocation', async ({ space, presenceMap }) => { 66 | const spy = vi.fn(); 67 | space.locations.subscribe('update', spy); 68 | 69 | await createPresenceEvent(space, presenceMap, 'update', { 70 | data: createLocationUpdate({ current: 'location1' }), 71 | }); 72 | 73 | await createPresenceEvent(space, presenceMap, 'update', { 74 | data: createLocationUpdate({ current: 'location2', previous: 'location1', id: 'newId' }), 75 | }); 76 | 77 | expect(spy).toHaveBeenLastCalledWith({ 78 | member: createSpaceMember({ location: 'location2' }), 79 | currentLocation: 'location2', 80 | previousLocation: 'location1', 81 | }); 82 | }); 83 | 84 | it('maintains lock state in presence', async ({ space, presence, presenceMap }) => { 85 | presenceMap.set('1', createPresenceMessage('enter')); 86 | 87 | const lockID = 'test'; 88 | const lockReq = await space.locks.acquire(lockID); 89 | 90 | const spy = vi.spyOn(presence, 'update'); 91 | await space.locations.set('location'); 92 | const extras = { locks: [lockReq] }; 93 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ extras })); 94 | }); 95 | }); 96 | 97 | describe('location getters', () => { 98 | it('getSelf returns the location only for self', async ({ space, presenceMap }) => { 99 | await createPresenceEvent(space, presenceMap, 'update', { 100 | data: createLocationUpdate({ current: 'location1' }), 101 | }); 102 | expect(space.locations.getSelf()).resolves.toEqual('location1'); 103 | }); 104 | 105 | it('getOthers returns the locations only for others', async ({ space, presenceMap }) => { 106 | await createPresenceEvent(space, presenceMap, 'update', { 107 | data: createLocationUpdate({ current: 'location1' }), 108 | }); 109 | 110 | await createPresenceEvent(space, presenceMap, 'update', { 111 | connectionId: '2', 112 | data: createLocationUpdate({ current: 'location2' }), 113 | }); 114 | 115 | const othersLocations = await space.locations.getOthers(); 116 | expect(othersLocations).toEqual({ '2': 'location2' }); 117 | }); 118 | 119 | it('getAll returns the locations for self and others', async ({ space, presenceMap }) => { 120 | await createPresenceEvent(space, presenceMap, 'update', { 121 | data: createLocationUpdate({ current: 'location1' }), 122 | }); 123 | 124 | await createPresenceEvent(space, presenceMap, 'update', { 125 | connectionId: '2', 126 | data: createLocationUpdate({ current: 'location2' }), 127 | }); 128 | 129 | const allLocations = await space.locations.getAll(); 130 | expect(allLocations).toEqual({ '1': 'location1', '2': 'location2' }); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/SpaceUpdate.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, vi, expect } from 'vitest'; 2 | 3 | import SpaceUpdate from './SpaceUpdate.js'; 4 | import { createSpaceMember } from './utilities/test/fakes.js'; 5 | 6 | vi.mock('nanoid'); 7 | 8 | describe('SpaceUpdate', () => { 9 | it('creates a profileUpdate', () => { 10 | const self = createSpaceMember({ profileData: { name: 'Berry' } }); 11 | const update = new SpaceUpdate({ self }); 12 | expect(update.updateProfileData({ name: 'Barry' })).toEqual({ 13 | data: { 14 | locationUpdate: { 15 | current: null, 16 | id: null, 17 | previous: null, 18 | }, 19 | profileUpdate: { 20 | current: { 21 | name: 'Barry', 22 | }, 23 | id: 'NanoidID', 24 | }, 25 | }, 26 | extras: undefined, 27 | }); 28 | }); 29 | 30 | it('creates a locationUpdate', () => { 31 | const self = createSpaceMember({ location: { slide: 3 }, profileData: { name: 'Berry' } }); 32 | const update = new SpaceUpdate({ self }); 33 | expect(update.updateLocation({ slide: 1 }, null)).toEqual({ 34 | data: { 35 | locationUpdate: { 36 | current: { slide: 1 }, 37 | id: 'NanoidID', 38 | previous: { slide: 3 }, 39 | }, 40 | profileUpdate: { 41 | current: { 42 | name: 'Berry', 43 | }, 44 | id: null, 45 | }, 46 | }, 47 | extras: undefined, 48 | }); 49 | }); 50 | 51 | it('creates an object with no updates to current data', () => { 52 | const self = createSpaceMember({ location: { slide: 3 }, profileData: { name: 'Berry' } }); 53 | const update = new SpaceUpdate({ self }); 54 | expect(update.noop()).toEqual({ 55 | data: { 56 | locationUpdate: { 57 | current: { slide: 3 }, 58 | id: null, 59 | previous: null, 60 | }, 61 | profileUpdate: { 62 | current: { 63 | name: 'Berry', 64 | }, 65 | id: null, 66 | }, 67 | }, 68 | extras: undefined, 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/SpaceUpdate.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid'; 2 | import { PresenceMessage } from 'ably'; 3 | 4 | import type { SpaceMember, ProfileData } from './types.js'; 5 | import type { PresenceMember } from './utilities/types.js'; 6 | 7 | export interface SpacePresenceData { 8 | data: PresenceMember['data']; 9 | extras: PresenceMember['extras']; 10 | } 11 | 12 | class SpaceUpdate { 13 | private self: SpaceMember | null; 14 | private extras: PresenceMessage['extras']; 15 | 16 | constructor({ self, extras }: { self: SpaceMember | null; extras?: PresenceMessage['extras'] }) { 17 | this.self = self; 18 | this.extras = extras; 19 | } 20 | 21 | private profileUpdate(id: string | null, current: ProfileData) { 22 | return { id, current }; 23 | } 24 | 25 | private profileNoChange() { 26 | return this.profileUpdate(null, this.self ? this.self.profileData : null); 27 | } 28 | 29 | private locationUpdate(id: string | null, current: SpaceMember['location'], previous: SpaceMember['location']) { 30 | return { id, current, previous }; 31 | } 32 | 33 | private locationNoChange() { 34 | const location = this.self ? this.self.location : null; 35 | return this.locationUpdate(null, location, null); 36 | } 37 | 38 | updateProfileData(current: ProfileData): SpacePresenceData { 39 | return { 40 | data: { 41 | profileUpdate: this.profileUpdate(nanoid(), current), 42 | locationUpdate: this.locationNoChange(), 43 | }, 44 | extras: this.extras, 45 | }; 46 | } 47 | 48 | updateLocation(location: SpaceMember['location'], previousLocation?: SpaceMember['location']): SpacePresenceData { 49 | return { 50 | data: { 51 | profileUpdate: this.profileNoChange(), 52 | locationUpdate: this.locationUpdate( 53 | nanoid(), 54 | location, 55 | previousLocation ? previousLocation : this.self?.location, 56 | ), 57 | }, 58 | extras: this.extras, 59 | }; 60 | } 61 | 62 | noop(): SpacePresenceData { 63 | return { 64 | data: { 65 | profileUpdate: this.profileNoChange(), 66 | locationUpdate: this.locationNoChange(), 67 | }, 68 | extras: this.extras, 69 | }; 70 | } 71 | } 72 | 73 | export default SpaceUpdate; 74 | -------------------------------------------------------------------------------- /src/Spaces.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect, expectTypeOf, vi, beforeEach } from 'vitest'; 2 | import { Realtime, RealtimeClient } from 'ably'; 3 | 4 | import Spaces, { type ClientWithOptions } from './Spaces.js'; 5 | 6 | interface SpacesTestContext { 7 | client: ClientWithOptions; 8 | } 9 | 10 | vi.mock('ably'); 11 | 12 | describe('Spaces', () => { 13 | beforeEach((context) => { 14 | context.client = new Realtime({ key: 'asd' }) as ClientWithOptions; 15 | }); 16 | 17 | it('expects the injected client to be of the type RealtimeClient', ({ client }) => { 18 | const spaces = new Spaces(client); 19 | expectTypeOf(spaces.client).toMatchTypeOf(); 20 | }); 21 | 22 | it('creates and retrieves spaces successfully', async ({ client }) => { 23 | const channels = client.channels; 24 | const spy = vi.spyOn(channels, 'get'); 25 | 26 | const spaces = new Spaces(client); 27 | await spaces.get('test'); 28 | 29 | expect(spy).toHaveBeenCalledTimes(1); 30 | expect(spy).toHaveBeenNthCalledWith( 31 | 1, 32 | 'test::$space', 33 | expect.objectContaining({ 34 | params: expect.objectContaining({ 35 | agent: expect.stringContaining('spaces'), 36 | }), 37 | }), 38 | ); 39 | }); 40 | 41 | it('applies the agent header to an existing SDK instance', ({ client }) => { 42 | const spaces = new Spaces(client); 43 | expect(client.options.agents).toEqual({ 44 | spaces: spaces.version, 45 | }); 46 | }); 47 | 48 | it('extend the agents array when it already exists', ({ client }) => { 49 | (client as ClientWithOptions).options.agents = { 'some-client': '1.2.3' }; 50 | const spaces = new Spaces(client); 51 | const ablyClient = spaces.client as ClientWithOptions; 52 | 53 | expect(ablyClient.options.agents).toEqual({ 54 | 'some-client': '1.2.3', 55 | spaces: spaces.version, 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/Spaces.ts: -------------------------------------------------------------------------------- 1 | import { RealtimeClient, Connection } from 'ably'; 2 | import { ERR_SPACE_NAME_MISSING } from './Errors.js'; 3 | 4 | import Space from './Space.js'; 5 | 6 | import type { SpaceOptions } from './types.js'; 7 | import type { Subset } from './utilities/types.js'; 8 | 9 | import { VERSION } from './version.js'; 10 | 11 | export interface ClientWithOptions extends RealtimeClient { 12 | options: { 13 | agents?: Record; 14 | }; 15 | } 16 | 17 | /** 18 | * The `Spaces` class is the entry point to the Spaces SDK. Use its {@link get | `get()`} method to access an individual {@link Space | `Space`}. 19 | * 20 | * To create an instance of `Spaces`, you first need to create an instance of an [Ably realtime client](https://ably.com/docs/getting-started/setup). You then pass this instance to the {@link constructor | Spaces constructor}. 21 | */ 22 | class Spaces { 23 | private spaces: Record = {}; 24 | /** 25 | * Instance of the [Ably realtime client](https://ably.com/docs/getting-started/setup) client that was passed to the {@link constructor}. 26 | */ 27 | client: RealtimeClient; 28 | /** 29 | * Instance of the [Ably realtime client](https://ably.com/docs/getting-started/setup) connection, belonging to the client that was passed to the {@link constructor}. 30 | */ 31 | connection: Connection; 32 | 33 | /** 34 | * Version of the Spaces library. 35 | */ 36 | readonly version = VERSION; 37 | 38 | /** 39 | * Create a new instance of the Spaces SDK by passing an instance of the [Ably realtime client](https://ably.com/docs/getting-started/setup). A [`clientId`](https://ably.com/docs/auth/identified-clients) is required. 40 | * 41 | * An Ably API key is needed to authenticate. [Basic authentication](https://ably.com/docs/auth/basic) may be used for convenience, however Ably strongly recommends you use [token authentication](https://ably.com/docs/auth/token) in a production environment. 42 | * 43 | * @param client An instance of the Ably prmise-based realtime client. 44 | */ 45 | constructor(client: RealtimeClient) { 46 | this.client = client; 47 | this.connection = client.connection; 48 | this.addAgent((this.client as ClientWithOptions)['options']); 49 | this.client.time(); 50 | } 51 | 52 | private addAgent(options: { agents?: Record }) { 53 | const agent = { spaces: this.version }; 54 | options.agents = { ...(options.agents ?? options.agents), ...agent }; 55 | } 56 | 57 | /** 58 | * 59 | * Create or retrieve an existing [space](https://ably.com/docs/spaces/space) from the `Spaces` collection. A space is uniquely identified by its unicode string name. 60 | * 61 | * The following is an example of creating a space: 62 | * 63 | * ```javascript 64 | * const space = await spaces.get('board-presentation'); 65 | * ``` 66 | * 67 | * A set of {@link SpaceOptions | options} may be passed when creating a space to customize a space. 68 | * 69 | * The following is an example of setting custom `SpaceOptions`: 70 | * 71 | * ```javascript 72 | * const space = await spaces.get('board-presentation', { 73 | * offlineTimeout: 180_000, 74 | * cursors: { paginationLimit: 10 } 75 | * }); 76 | * ``` 77 | * 78 | * @param name The name of the space to create or retrieve. 79 | * @param options Additional options to customize the behavior of the space. 80 | */ 81 | async get(name: string, options?: Subset): Promise { 82 | if (typeof name !== 'string' || name.length === 0) { 83 | throw ERR_SPACE_NAME_MISSING(); 84 | } 85 | 86 | if (this.connection.state !== 'connected') { 87 | await this.connection.once('connected'); 88 | } 89 | 90 | if (this.spaces[name]) return this.spaces[name]; 91 | 92 | const space = new Space(name, this.client, options); 93 | this.spaces[name] = space; 94 | 95 | return space; 96 | } 97 | } 98 | 99 | export default Spaces; 100 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Spaces from './Spaces.js'; 2 | 3 | export type { default as Space, SpaceEventMap, SpaceEvents, SpaceState, UpdateProfileDataFunction } from './Space.js'; 4 | 5 | export type { default as Cursors, CursorsEventMap } from './Cursors.js'; 6 | export type { default as Locations, LocationsEventMap, LocationsEvents } from './Locations.js'; 7 | export type { default as Locks, LocksEventMap, LockOptions } from './Locks.js'; 8 | export type { default as Members, MembersEventMap } from './Members.js'; 9 | 10 | // Can be changed to * when we update to TS5 11 | 12 | export default Spaces; 13 | 14 | export type { 15 | CursorsOptions, 16 | CursorPosition, 17 | CursorData, 18 | CursorUpdate, 19 | SpaceOptions, 20 | ProfileData, 21 | SpaceMember, 22 | Lock, 23 | LockStatus, 24 | LockStatuses, 25 | } from './types.js'; 26 | 27 | export type { LockAttributes } from './Locks.js'; 28 | 29 | export type { default as EventEmitter, EventListener, EventListenerThis } from './utilities/EventEmitter.js'; 30 | 31 | export type { Subset } from './utilities/types.js'; 32 | -------------------------------------------------------------------------------- /src/react/contexts/SpaceContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSpaces } from '../useSpaces.js'; 3 | 4 | import type { Space, SpaceOptions } from '../../'; 5 | 6 | export const SpaceContext = React.createContext(undefined); 7 | 8 | interface SpaceProviderProps { 9 | name: string; 10 | options?: Partial; 11 | children?: React.ReactNode | React.ReactNode[] | null; 12 | } 13 | export const SpaceProvider: React.FC = ({ name, options, children }) => { 14 | const [space, setSpace] = React.useState(undefined); 15 | const spaces = useSpaces(); 16 | const optionsRef = React.useRef(options); 17 | 18 | React.useEffect(() => { 19 | optionsRef.current = options; 20 | }, [options]); 21 | 22 | React.useEffect(() => { 23 | let ignore: boolean = false; 24 | 25 | const init = async () => { 26 | if (!spaces) { 27 | throw new Error( 28 | 'Could not find spaces client in context. ' + 29 | 'Make sure your spaces hooks are called inside an ', 30 | ); 31 | } 32 | 33 | const spaceInstance = await spaces.get(name, optionsRef.current); 34 | 35 | if (spaceInstance && !space && !ignore) { 36 | setSpace(spaceInstance); 37 | } 38 | }; 39 | 40 | init(); 41 | 42 | return () => { 43 | ignore = true; 44 | }; 45 | }, [name, spaces]); 46 | 47 | return {children}; 48 | }; 49 | -------------------------------------------------------------------------------- /src/react/contexts/SpacesContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type Spaces from '../../'; 3 | 4 | export const SpacesContext = React.createContext(undefined); 5 | 6 | interface SpacesProviderProps { 7 | client: Spaces; 8 | children?: React.ReactNode | React.ReactNode[] | null; 9 | } 10 | export const SpacesProvider: React.FC = ({ client: spaces, children }) => { 11 | return {children}; 12 | }; 13 | -------------------------------------------------------------------------------- /src/react/index.ts: -------------------------------------------------------------------------------- 1 | export { SpacesProvider } from './contexts/SpacesContext.js'; 2 | export { SpaceProvider } from './contexts/SpaceContext.js'; 3 | export { useSpaces } from './useSpaces.js'; 4 | export { useSpace } from './useSpace.js'; 5 | export { useMembers } from './useMembers.js'; 6 | export { useLocations } from './useLocations.js'; 7 | export { useLocks } from './useLocks.js'; 8 | export { useLock } from './useLock.js'; 9 | export { useCursors } from './useCursors.js'; 10 | -------------------------------------------------------------------------------- /src/react/types.ts: -------------------------------------------------------------------------------- 1 | import type { SpaceMember } from '..'; 2 | 3 | export interface UseSpaceOptions { 4 | /** 5 | * Skip parameter makes the hook skip execution - 6 | * this is useful in order to conditionally register a subscription to 7 | * an EventListener (needed because it's not possible to conditionally call a hook in react) 8 | */ 9 | skip?: boolean; 10 | } 11 | 12 | export type UseSpaceCallback = (params: { members: SpaceMember[] }) => void; 13 | -------------------------------------------------------------------------------- /src/react/useChannelState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useEventListener } from './useEventListener.js'; 3 | 4 | import type { ChannelState, ChannelStateChange, ErrorInfo, EventEmitter } from 'ably'; 5 | 6 | type ChannelStateListener = (stateChange: ChannelStateChange) => void; 7 | 8 | const failedStateEvents: ChannelState[] = ['suspended', 'failed', 'detached']; 9 | const successStateEvents: ChannelState[] = ['attached']; 10 | 11 | /** 12 | * todo use `ably/react` hooks instead 13 | */ 14 | export const useChannelState = ( 15 | emitter?: EventEmitter, 16 | ) => { 17 | const [channelError, setChannelError] = useState(null); 18 | 19 | useEventListener( 20 | emitter, 21 | (stateChange) => { 22 | if (stateChange.reason) { 23 | setChannelError(stateChange.reason); 24 | } 25 | }, 26 | failedStateEvents, 27 | ); 28 | 29 | useEventListener( 30 | emitter, 31 | () => { 32 | setChannelError(null); 33 | }, 34 | successStateEvents, 35 | ); 36 | 37 | return channelError; 38 | }; 39 | -------------------------------------------------------------------------------- /src/react/useConnectionState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useEventListener } from './useEventListener.js'; 3 | 4 | import type { ConnectionState, ConnectionStateChange, ErrorInfo, EventEmitter } from 'ably'; 5 | 6 | type ConnectionStateListener = (stateChange: ConnectionStateChange) => void; 7 | 8 | const failedStateEvents: ConnectionState[] = ['suspended', 'failed', 'disconnected']; 9 | const successStateEvents: ConnectionState[] = ['connected', 'closed']; 10 | 11 | export const useConnectionState = ( 12 | emitter?: EventEmitter, 13 | ) => { 14 | const [connectionError, setConnectionError] = useState(null); 15 | 16 | useEventListener( 17 | emitter, 18 | (stateChange) => { 19 | if (stateChange.reason) { 20 | setConnectionError(stateChange.reason); 21 | } 22 | }, 23 | failedStateEvents, 24 | ); 25 | 26 | useEventListener( 27 | emitter, 28 | () => { 29 | setConnectionError(null); 30 | }, 31 | successStateEvents, 32 | ); 33 | 34 | return connectionError; 35 | }; 36 | -------------------------------------------------------------------------------- /src/react/useCursors.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { Realtime } from 'ably'; 7 | import { it, beforeEach, describe, expect, vi } from 'vitest'; 8 | import { waitFor, renderHook, act } from '@testing-library/react'; 9 | import { SpacesProvider } from './contexts/SpacesContext.js'; 10 | import { SpaceProvider } from './contexts/SpaceContext.js'; 11 | import Spaces from '../index.js'; 12 | import Space from '../Space.js'; 13 | import { createPresenceEvent } from '../utilities/test/fakes.js'; 14 | import { useCursors } from './useCursors.js'; 15 | import type { PresenceMessage } from 'ably'; 16 | 17 | interface SpaceTestContext { 18 | spaces: Spaces; 19 | space: Space; 20 | presenceMap: Map; 21 | } 22 | 23 | vi.mock('ably'); 24 | vi.mock('nanoid'); 25 | 26 | describe('useCursors', () => { 27 | beforeEach((context) => { 28 | const client = new Realtime({ key: 'spaces-test' }); 29 | context.spaces = new Spaces(client); 30 | context.presenceMap = new Map(); 31 | 32 | const space = new Space('test', client); 33 | const presence = space.channel.presence; 34 | 35 | context.space = space; 36 | 37 | vi.spyOn(context.spaces, 'get').mockImplementation(async () => space); 38 | 39 | vi.spyOn(presence, 'get').mockImplementation(async () => { 40 | return Array.from(context.presenceMap.values()); 41 | }); 42 | }); 43 | 44 | it('invokes callback on cursor set', async ({ space, spaces, presenceMap }) => { 45 | const callbackSpy = vi.fn(); 46 | // @ts-ignore 47 | const { result } = renderHook(() => useCursors(callbackSpy), { 48 | wrapper: ({ children }) => ( 49 | 50 | {children} 51 | 52 | ), 53 | }); 54 | 55 | await waitFor(() => { 56 | expect(result.current.space).toBe(space); 57 | }); 58 | 59 | await createPresenceEvent(space, presenceMap, 'enter'); 60 | 61 | const dispensing = space.cursors.cursorDispensing; 62 | 63 | const fakeMessage = { 64 | connectionId: '1', 65 | clientId: '1', 66 | encoding: 'encoding', 67 | extras: null, 68 | id: '1', 69 | name: 'fake', 70 | timestamp: 1, 71 | data: [{ cursor: { position: { x: 1, y: 1 } } }], 72 | }; 73 | 74 | dispensing.processBatch(fakeMessage); 75 | 76 | await waitFor(() => { 77 | expect(callbackSpy).toHaveBeenCalledWith({ 78 | position: { x: 1, y: 1 }, 79 | data: undefined, 80 | clientId: '1', 81 | connectionId: '1', 82 | }); 83 | }); 84 | }); 85 | 86 | it('returns cursors', async ({ space, spaces, presenceMap }) => { 87 | // @ts-ignore 88 | const { result } = renderHook(() => useCursors({ returnCursors: true }), { 89 | wrapper: ({ children }) => ( 90 | 91 | {children} 92 | 93 | ), 94 | }); 95 | 96 | await waitFor(() => { 97 | expect(result.current.space).toBe(space); 98 | }); 99 | 100 | await createPresenceEvent(space, presenceMap, 'enter'); 101 | await createPresenceEvent(space, presenceMap, 'enter', { clientId: '2', connectionId: '2' }); 102 | const [member] = await space.members.getOthers(); 103 | 104 | const dispensing = space.cursors.cursorDispensing; 105 | 106 | const fakeMessage = { 107 | connectionId: '2', 108 | clientId: '2', 109 | encoding: 'encoding', 110 | extras: null, 111 | id: '1', 112 | name: 'fake', 113 | timestamp: 1, 114 | data: [{ cursor: { position: { x: 1, y: 1 } } }], 115 | }; 116 | 117 | await act(() => { 118 | dispensing.processBatch(fakeMessage); 119 | }); 120 | 121 | await waitFor(() => { 122 | expect(result.current.cursors).toEqual({ 123 | '2': { member, cursorUpdate: { clientId: '2', connectionId: '2', position: { x: 1, y: 1 } } }, 124 | }); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/react/useCursors.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useMemo, useRef, useState } from 'react'; 2 | import { SpaceContext } from './contexts/SpaceContext.js'; 3 | import { useMembers } from './useMembers.js'; 4 | import { useChannelState } from './useChannelState.js'; 5 | import { useConnectionState } from './useConnectionState.js'; 6 | import { isFunction } from '../utilities/is.js'; 7 | 8 | import type { CursorUpdate, SpaceMember } from '../types.js'; 9 | import type { ErrorInfo } from 'ably'; 10 | import type Cursors from '../Cursors.js'; 11 | import type { UseSpaceOptions } from './types.js'; 12 | import type { Space } from '..'; 13 | 14 | interface UseCursorsOptions extends UseSpaceOptions { 15 | /** 16 | * Whether to return the cursors object described in UseCursorsResult, defaults to false 17 | */ 18 | returnCursors?: boolean; 19 | } 20 | 21 | interface UseCursorsResult { 22 | space?: Space; 23 | connectionError: ErrorInfo | null; 24 | channelError: ErrorInfo | null; 25 | set?: Cursors['set']; 26 | /** 27 | * if UseCursorsOptions.returnCursors is truthy; a map from connectionId to associated space member and their cursor update 28 | */ 29 | cursors: Record; 30 | } 31 | 32 | type UseCursorsCallback = (params: CursorUpdate) => void; 33 | 34 | /** 35 | * Registers a subscription on the `Space.cursors` object 36 | */ 37 | function useCursors(options?: UseCursorsOptions): UseCursorsResult; 38 | function useCursors(callback: UseCursorsCallback, options?: UseCursorsOptions): UseCursorsResult; 39 | function useCursors( 40 | callbackOrOptions?: UseCursorsCallback | UseCursorsOptions, 41 | optionsOrNothing?: UseCursorsOptions, 42 | ): UseCursorsResult { 43 | const space = useContext(SpaceContext); 44 | const [cursors, setCursors] = useState>({}); 45 | const { members } = useMembers(); 46 | const channelError = useChannelState(space?.cursors.channel); 47 | const connectionError = useConnectionState(); 48 | 49 | const connectionIdToMember: Record = useMemo(() => { 50 | return members.reduce( 51 | (acc, member) => { 52 | acc[member.connectionId] = member; 53 | return acc; 54 | }, 55 | {} as Record, 56 | ); 57 | }, [members]); 58 | 59 | const callback = isFunction(callbackOrOptions) ? callbackOrOptions : undefined; 60 | const options = isFunction(callbackOrOptions) ? optionsOrNothing : callbackOrOptions; 61 | 62 | const callbackRef = useRef(callback); 63 | const optionsRef = useRef(options); 64 | 65 | useEffect(() => { 66 | callbackRef.current = callback; 67 | optionsRef.current = options; 68 | }, [callback, options]); 69 | 70 | useEffect(() => { 71 | if (!space || !connectionIdToMember) return; 72 | 73 | const listener: UseCursorsCallback = (cursorUpdate) => { 74 | if (!optionsRef.current?.skip) callbackRef.current?.(cursorUpdate); 75 | 76 | const { connectionId } = cursorUpdate; 77 | 78 | if (connectionId === space?.connectionId || !optionsRef.current?.returnCursors) return; 79 | 80 | setCursors((currentCursors) => ({ 81 | ...currentCursors, 82 | [connectionId]: { member: connectionIdToMember[connectionId], cursorUpdate }, 83 | })); 84 | }; 85 | 86 | space.cursors.subscribe('update', listener); 87 | 88 | return () => { 89 | space.cursors.unsubscribe('update', listener); 90 | }; 91 | }, [space, connectionIdToMember]); 92 | 93 | return { 94 | space, 95 | connectionError, 96 | channelError, 97 | set: space?.cursors.set.bind(space?.cursors), 98 | cursors, 99 | }; 100 | } 101 | 102 | export { useCursors }; 103 | -------------------------------------------------------------------------------- /src/react/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import type { ChannelState, ChannelStateChange, ConnectionState, ConnectionStateChange, EventEmitter } from 'ably'; 4 | 5 | type EventListener = (stateChange: T) => void; 6 | 7 | /** 8 | * todo use `ably/react` hooks instead 9 | */ 10 | export const useEventListener = < 11 | S extends ConnectionState | ChannelState, 12 | C extends ConnectionStateChange | ChannelStateChange, 13 | >( 14 | emitter?: EventEmitter, C, S>, 15 | listener?: EventListener, 16 | event?: S | S[], 17 | ) => { 18 | const listenerRef = useRef(listener); 19 | 20 | useEffect(() => { 21 | listenerRef.current = listener; 22 | }, [listener]); 23 | 24 | useEffect(() => { 25 | const callback: EventListener = (stateChange) => { 26 | listenerRef.current?.(stateChange); 27 | }; 28 | 29 | if (event) { 30 | emitter?.on(event as S, callback); 31 | } else { 32 | emitter?.on(callback); 33 | } 34 | 35 | return () => { 36 | if (event) { 37 | emitter?.off(event as S, callback); 38 | } else { 39 | emitter?.off(callback); 40 | } 41 | }; 42 | }, [emitter, event]); 43 | }; 44 | -------------------------------------------------------------------------------- /src/react/useLocations.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { Realtime } from 'ably'; 7 | import { it, beforeEach, describe, expect, vi } from 'vitest'; 8 | import { waitFor, renderHook } from '@testing-library/react'; 9 | import { SpacesProvider } from './contexts/SpacesContext.js'; 10 | import { SpaceProvider } from './contexts/SpaceContext.js'; 11 | import type { PresenceMessage } from 'ably'; 12 | import Spaces from '../index.js'; 13 | import { createLocationUpdate, createPresenceEvent } from '../utilities/test/fakes.js'; 14 | import Space from '../Space.js'; 15 | import { useLocations } from './useLocations.js'; 16 | 17 | interface SpaceTestContext { 18 | spaces: Spaces; 19 | space: Space; 20 | presenceMap: Map; 21 | } 22 | 23 | vi.mock('ably'); 24 | vi.mock('nanoid'); 25 | 26 | describe('useLocations', () => { 27 | beforeEach((context) => { 28 | const client = new Realtime({ key: 'spaces-test' }); 29 | context.spaces = new Spaces(client); 30 | context.presenceMap = new Map(); 31 | 32 | const space = new Space('test', client); 33 | const presence = space.channel.presence; 34 | 35 | context.space = space; 36 | 37 | vi.spyOn(context.spaces, 'get').mockImplementation(async () => space); 38 | 39 | vi.spyOn(presence, 'get').mockImplementation(async () => { 40 | return Array.from(context.presenceMap.values()); 41 | }); 42 | }); 43 | 44 | it('invokes callback with new location', async ({ space, spaces, presenceMap }) => { 45 | const callbackSpy = vi.fn(); 46 | // @ts-ignore 47 | const { result } = renderHook(() => useLocations(callbackSpy), { 48 | wrapper: ({ children }) => ( 49 | 50 | {children} 51 | 52 | ), 53 | }); 54 | 55 | await waitFor(() => { 56 | expect(result.current.space).toBe(space); 57 | }); 58 | 59 | await createPresenceEvent(space, presenceMap, 'enter'); 60 | const member = await space.members.getSelf(); 61 | await createPresenceEvent(space, presenceMap, 'update', { 62 | data: createLocationUpdate({ current: 'location1' }), 63 | }); 64 | 65 | await waitFor(() => { 66 | expect(callbackSpy).toHaveBeenCalledWith( 67 | expect.objectContaining({ 68 | member: { 69 | ...member, 70 | lastEvent: { 71 | name: 'update', 72 | timestamp: 1, 73 | }, 74 | location: 'location1', 75 | }, 76 | previousLocation: null, 77 | }), 78 | ); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/react/useLocations.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef } from 'react'; 2 | import { SpaceContext } from './contexts/SpaceContext.js'; 3 | import { isArray, isFunction, isString } from '../utilities/is.js'; 4 | 5 | import type Locations from '../Locations.js'; 6 | import type { SpaceMember } from '../types.js'; 7 | import type { UseSpaceOptions } from './types.js'; 8 | import type { Space } from '../'; 9 | 10 | interface UseLocationsResult { 11 | space?: Space; 12 | update?: Locations['set']; 13 | } 14 | 15 | type UseLocationCallback = (locationUpdate: { member: SpaceMember }) => void; 16 | 17 | export type LocationsEvent = 'update'; 18 | 19 | function useLocations(callback?: UseLocationCallback, options?: UseSpaceOptions): UseLocationsResult; 20 | function useLocations( 21 | event: LocationsEvent | LocationsEvent[], 22 | callback: UseLocationCallback, 23 | options?: UseSpaceOptions, 24 | ): UseLocationsResult; 25 | 26 | /* 27 | * Registers a subscription on the `Space.locations` object 28 | */ 29 | function useLocations( 30 | eventOrCallback?: LocationsEvent | LocationsEvent[] | UseLocationCallback, 31 | callbackOrOptions?: UseLocationCallback | UseSpaceOptions, 32 | optionsOrNothing?: UseSpaceOptions, 33 | ): UseLocationsResult { 34 | const space = useContext(SpaceContext); 35 | const locations = space?.locations; 36 | 37 | const callback = 38 | isString(eventOrCallback) || isArray(eventOrCallback) 39 | ? (callbackOrOptions as UseLocationCallback) 40 | : eventOrCallback; 41 | 42 | const options = isFunction(callbackOrOptions) ? optionsOrNothing : callbackOrOptions; 43 | 44 | const callbackRef = useRef(callback); 45 | 46 | useEffect(() => { 47 | callbackRef.current = callback; 48 | }, [callback]); 49 | 50 | useEffect(() => { 51 | if (callbackRef.current && locations && !options?.skip) { 52 | const listener: UseLocationCallback = (params) => { 53 | callbackRef.current?.(params); 54 | }; 55 | if (!isFunction(eventOrCallback) && eventOrCallback) { 56 | locations.subscribe(eventOrCallback, listener); 57 | } else { 58 | locations.subscribe(listener); 59 | } 60 | 61 | return () => { 62 | if (!isFunction(eventOrCallback) && eventOrCallback) { 63 | locations.unsubscribe(eventOrCallback, listener); 64 | } else { 65 | locations.unsubscribe(listener); 66 | } 67 | }; 68 | } 69 | }, [locations, options?.skip]); 70 | 71 | return { 72 | space, 73 | update: locations?.set.bind(locations), 74 | }; 75 | } 76 | 77 | export { useLocations }; 78 | -------------------------------------------------------------------------------- /src/react/useLock.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react'; 2 | import { SpaceContext } from './contexts/SpaceContext.js'; 3 | 4 | import type { LockStatus, SpaceMember, Lock } from '../types.js'; 5 | 6 | interface UseLockResult { 7 | status: LockStatus | null; 8 | member: SpaceMember | null; 9 | } 10 | 11 | /* 12 | * Returns the status of a lock and, if it has been acquired, the member holding the lock 13 | */ 14 | export function useLock(lockId: string): UseLockResult { 15 | const space = useContext(SpaceContext); 16 | const [status, setStatus] = useState(null); 17 | const [member, setMember] = useState(null); 18 | 19 | const initialized = status !== null; 20 | 21 | useEffect(() => { 22 | if (!space) return; 23 | 24 | const handler = (lock: Lock) => { 25 | if (lock.id !== lockId) return; 26 | 27 | if (lock.status === 'unlocked') { 28 | setStatus(null); 29 | setMember(null); 30 | } else { 31 | setStatus(lock.status); 32 | setMember(lock.member); 33 | } 34 | }; 35 | 36 | space.locks.subscribe('update', handler); 37 | 38 | return () => { 39 | space?.locks.unsubscribe('update', handler); 40 | }; 41 | }, [space, lockId]); 42 | 43 | useEffect(() => { 44 | if (initialized || !space) return; 45 | 46 | const lock = space?.locks.get(lockId); 47 | 48 | if (lock) { 49 | setMember(lock.member); 50 | setStatus(lock.status); 51 | } 52 | }, [initialized, space]); 53 | 54 | return { status, member }; 55 | } 56 | -------------------------------------------------------------------------------- /src/react/useLocks.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { Realtime } from 'ably'; 7 | import { it, beforeEach, describe, expect, vi } from 'vitest'; 8 | import { waitFor, renderHook } from '@testing-library/react'; 9 | import { SpacesProvider } from './contexts/SpacesContext.js'; 10 | import { SpaceProvider } from './contexts/SpaceContext.js'; 11 | import type { PresenceMessage } from 'ably'; 12 | import Spaces from '../index.js'; 13 | import { createPresenceEvent } from '../utilities/test/fakes.js'; 14 | import Space from '../Space.js'; 15 | import { useLocks } from './useLocks.js'; 16 | 17 | interface SpaceTestContext { 18 | spaces: Spaces; 19 | space: Space; 20 | presenceMap: Map; 21 | } 22 | 23 | vi.mock('ably'); 24 | vi.mock('nanoid'); 25 | 26 | describe('useLocks', () => { 27 | beforeEach((context) => { 28 | const client = new Realtime({ key: 'spaces-test' }); 29 | context.spaces = new Spaces(client); 30 | context.presenceMap = new Map(); 31 | 32 | const space = new Space('test', client); 33 | const presence = space.channel.presence; 34 | 35 | context.space = space; 36 | 37 | vi.spyOn(context.spaces, 'get').mockImplementation(async () => space); 38 | 39 | vi.spyOn(presence, 'get').mockImplementation(async () => { 40 | return Array.from(context.presenceMap.values()); 41 | }); 42 | }); 43 | 44 | it('invokes callback on lock', async ({ space, spaces, presenceMap }) => { 45 | const callbackSpy = vi.fn(); 46 | // @ts-ignore 47 | const { result } = renderHook(() => useLocks(callbackSpy), { 48 | wrapper: ({ children }) => ( 49 | 50 | {children} 51 | 52 | ), 53 | }); 54 | 55 | await waitFor(() => { 56 | expect(result.current.space).toBe(space); 57 | }); 58 | 59 | await createPresenceEvent(space, presenceMap, 'enter'); 60 | const member = await space.members.getSelf(); 61 | 62 | const msg = Realtime.PresenceMessage.fromValues({ 63 | action: 'update', 64 | connectionId: member.connectionId, 65 | extras: { 66 | locks: [ 67 | { 68 | id: 'lockID', 69 | status: 'pending', 70 | timestamp: Date.now(), 71 | }, 72 | ], 73 | }, 74 | }); 75 | await space.locks.processPresenceMessage(msg); 76 | 77 | expect(callbackSpy).toHaveBeenCalledWith( 78 | expect.objectContaining({ 79 | member: member, 80 | id: 'lockID', 81 | status: 'locked', 82 | }), 83 | ); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/react/useLocks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { isArray, isFunction, isString } from '../utilities/is.js'; 3 | import { useSpace, type UseSpaceResult } from './useSpace.js'; 4 | 5 | import type { UseSpaceOptions } from './types.js'; 6 | import type { LocksEventMap } from '../Locks.js'; 7 | import type { Lock } from '../types.js'; 8 | 9 | type LocksEvent = keyof LocksEventMap; 10 | 11 | type UseLocksCallback = (params: Lock) => void; 12 | 13 | /* 14 | * Registers a subscription on the `Space.locks` object 15 | */ 16 | function useLocks(callback?: UseLocksCallback, options?: UseSpaceOptions): UseSpaceResult; 17 | function useLocks( 18 | event: LocksEvent | LocksEvent[], 19 | callback: UseLocksCallback, 20 | options?: UseSpaceOptions, 21 | ): UseSpaceResult; 22 | function useLocks( 23 | eventOrCallback?: LocksEvent | LocksEvent[] | UseLocksCallback, 24 | callbackOrOptions?: UseLocksCallback | UseSpaceOptions, 25 | optionsOrNothing?: UseSpaceOptions, 26 | ): UseSpaceResult { 27 | const spaceContext = useSpace(); 28 | const { space } = spaceContext; 29 | 30 | const callback = 31 | isString(eventOrCallback) || isArray(eventOrCallback) ? (callbackOrOptions as UseLocksCallback) : eventOrCallback; 32 | 33 | const options = isFunction(callbackOrOptions) ? optionsOrNothing : callbackOrOptions; 34 | 35 | const callbackRef = useRef(callback); 36 | 37 | useEffect(() => { 38 | callbackRef.current = callback; 39 | }, [callback]); 40 | 41 | useEffect(() => { 42 | if (callbackRef.current && space?.locks && !options?.skip) { 43 | const listener: UseLocksCallback = (params) => { 44 | callbackRef.current?.(params); 45 | }; 46 | if (!isFunction(eventOrCallback) && eventOrCallback) { 47 | space?.locks.subscribe(eventOrCallback, listener); 48 | } else { 49 | space?.locks.subscribe(listener); 50 | } 51 | 52 | return () => { 53 | if (!isFunction(eventOrCallback) && eventOrCallback) { 54 | space?.locks.unsubscribe(eventOrCallback, listener); 55 | } else { 56 | space?.locks.unsubscribe(listener); 57 | } 58 | }; 59 | } 60 | }, [space?.locks, options?.skip]); 61 | 62 | return spaceContext; 63 | } 64 | 65 | export { useLocks }; 66 | -------------------------------------------------------------------------------- /src/react/useMembers.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { Realtime } from 'ably'; 7 | import { it, beforeEach, describe, expect, vi } from 'vitest'; 8 | import { waitFor, renderHook } from '@testing-library/react'; 9 | import { SpacesProvider } from './contexts/SpacesContext.js'; 10 | import { SpaceProvider } from './contexts/SpaceContext.js'; 11 | import type { PresenceMessage } from 'ably'; 12 | import Spaces from '../index.js'; 13 | import { useMembers } from './useMembers.js'; 14 | import { createLocationUpdate, createPresenceEvent } from '../utilities/test/fakes.js'; 15 | import Space from '../Space.js'; 16 | 17 | interface SpaceTestContext { 18 | spaces: Spaces; 19 | space: Space; 20 | presenceMap: Map; 21 | } 22 | 23 | vi.mock('ably'); 24 | vi.mock('nanoid'); 25 | 26 | describe('useMembers', () => { 27 | beforeEach((context) => { 28 | const client = new Realtime({ key: 'spaces-test' }); 29 | context.spaces = new Spaces(client); 30 | context.presenceMap = new Map(); 31 | 32 | const space = new Space('test', client); 33 | const presence = space.channel.presence; 34 | 35 | context.space = space; 36 | 37 | vi.spyOn(context.spaces, 'get').mockImplementation(async () => space); 38 | 39 | vi.spyOn(presence, 'get').mockImplementation(async () => { 40 | return Array.from(context.presenceMap.values()); 41 | }); 42 | }); 43 | 44 | it('invokes callback with enter and update on enter presence events', async ({ 45 | space, 46 | spaces, 47 | presenceMap, 48 | }) => { 49 | const callbackSpy = vi.fn(); 50 | // @ts-ignore 51 | const { result } = renderHook(() => useMembers(['enter', 'leave'], callbackSpy), { 52 | wrapper: ({ children }) => ( 53 | 54 | {children} 55 | 56 | ), 57 | }); 58 | 59 | await waitFor(() => { 60 | expect(result.current.space).toBe(space); 61 | }); 62 | 63 | await createPresenceEvent(space, presenceMap, 'enter'); 64 | const member = await space.members.getSelf(); 65 | 66 | await waitFor(() => { 67 | expect(callbackSpy).toHaveBeenCalledTimes(1); 68 | expect(result.current.members).toEqual([member]); 69 | expect(result.current.others).toEqual([]); 70 | expect(result.current.self).toEqual(member); 71 | expect(result.current.self.location).toBe(null); 72 | }); 73 | 74 | await createPresenceEvent(space, presenceMap, 'update', { 75 | data: createLocationUpdate({ current: 'location1' }), 76 | }); 77 | 78 | await waitFor(() => { 79 | expect(result.current.self.location).toBe('location1'); 80 | // callback hasn't been invoked 81 | expect(callbackSpy).toHaveBeenCalledTimes(1); 82 | }); 83 | 84 | await createPresenceEvent(space, presenceMap, 'leave'); 85 | await waitFor(() => { 86 | // callback has invoked 87 | expect(callbackSpy).toHaveBeenCalledTimes(2); 88 | }); 89 | }); 90 | 91 | it('skips callback if skip option provided', async ({ space, spaces, presenceMap }) => { 92 | const callbackSpy = vi.fn(); 93 | // @ts-ignore 94 | const { result } = renderHook(() => useMembers(callbackSpy, { skip: true }), { 95 | wrapper: ({ children }) => ( 96 | 97 | {children} 98 | 99 | ), 100 | }); 101 | 102 | await waitFor(() => { 103 | expect(result.current.space).toBe(space); 104 | }); 105 | 106 | await createPresenceEvent(space, presenceMap, 'enter'); 107 | const member = await space.members.getSelf(); 108 | 109 | await waitFor(() => { 110 | expect(result.current.members).toEqual([member]); 111 | expect(result.current.others).toEqual([]); 112 | expect(result.current.self).toEqual(member); 113 | expect(callbackSpy).toHaveBeenCalledTimes(0); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/react/useMembers.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { useSpace } from './useSpace.js'; 3 | import { isArray, isFunction, isString } from '../utilities/is.js'; 4 | 5 | import type { ErrorInfo } from 'ably'; 6 | import type { Space, SpaceMember } from '..'; 7 | import type { UseSpaceOptions } from './types.js'; 8 | import type { MembersEventMap } from '../Members.js'; 9 | 10 | interface UseMembersResult { 11 | space?: Space; 12 | /** 13 | * All members present in the space 14 | */ 15 | members: SpaceMember[]; 16 | /** 17 | * All members present in the space excluding the member associated with the spaces client 18 | */ 19 | others: SpaceMember[]; 20 | /** 21 | * The member associated with the spaces client 22 | */ 23 | self: SpaceMember | null; 24 | channelError: ErrorInfo | null; 25 | connectionError: ErrorInfo | null; 26 | } 27 | 28 | type UseMembersCallback = (params: SpaceMember) => void; 29 | 30 | type MembersEvent = keyof MembersEventMap; 31 | 32 | function useMembers(callback?: UseMembersCallback, options?: UseSpaceOptions): UseMembersResult; 33 | function useMembers( 34 | event: MembersEvent | MembersEvent[], 35 | callback: UseMembersCallback, 36 | options?: UseSpaceOptions, 37 | ): UseMembersResult; 38 | 39 | function useMembers( 40 | eventOrCallback?: MembersEvent | MembersEvent[] | UseMembersCallback, 41 | callbackOrOptions?: UseMembersCallback | UseSpaceOptions, 42 | optionsOrNothing?: UseSpaceOptions, 43 | ): UseMembersResult { 44 | const { space, connectionError, channelError } = useSpace(); 45 | const [members, setMembers] = useState([]); 46 | const [others, setOthers] = useState([]); 47 | const [self, setSelf] = useState(null); 48 | 49 | const callback = 50 | isString(eventOrCallback) || isArray(eventOrCallback) ? (callbackOrOptions as UseMembersCallback) : eventOrCallback; 51 | 52 | const options = isFunction(callbackOrOptions) ? optionsOrNothing : callbackOrOptions; 53 | 54 | const callbackRef = useRef(callback); 55 | 56 | useEffect(() => { 57 | callbackRef.current = callback; 58 | }, [callback]); 59 | 60 | useEffect(() => { 61 | if (callbackRef.current && space?.members && !options?.skip) { 62 | const listener: UseMembersCallback = (params) => { 63 | callbackRef.current?.(params); 64 | }; 65 | if (!isFunction(eventOrCallback) && eventOrCallback) { 66 | space?.members.subscribe(eventOrCallback, listener); 67 | } else { 68 | space?.members.subscribe(listener); 69 | } 70 | 71 | return () => { 72 | if (!isFunction(eventOrCallback) && eventOrCallback) { 73 | space?.members.unsubscribe(eventOrCallback, listener); 74 | } else { 75 | space?.members.unsubscribe(listener); 76 | } 77 | }; 78 | } 79 | }, [space?.members, options?.skip]); 80 | 81 | useEffect(() => { 82 | if (!space) return; 83 | let ignore: boolean = false; 84 | 85 | const updateState = (updatedSelf: SpaceMember | null, updatedMembers: SpaceMember[]) => { 86 | if (ignore) return; 87 | setSelf(updatedSelf); 88 | setMembers([...updatedMembers]); 89 | setOthers(updatedMembers.filter((member) => member.connectionId !== updatedSelf?.connectionId)); 90 | }; 91 | 92 | const handler = async ({ members: updatedMembers }: { members: SpaceMember[] }) => { 93 | const updatedSelf = await space.members.getSelf(); 94 | updateState(updatedSelf, updatedMembers); 95 | }; 96 | 97 | const init = async () => { 98 | const initSelf = await space.members.getSelf(); 99 | const initMembers = await space.members.getAll(); 100 | updateState(initSelf, initMembers); 101 | space.subscribe('update', handler); 102 | }; 103 | 104 | init(); 105 | 106 | return () => { 107 | ignore = true; 108 | space.unsubscribe('update', handler); 109 | }; 110 | }, [space]); 111 | 112 | return { 113 | space, 114 | members, 115 | others, 116 | self, 117 | connectionError, 118 | channelError, 119 | }; 120 | } 121 | 122 | export { useMembers }; 123 | -------------------------------------------------------------------------------- /src/react/useSpace.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import React from 'react'; 6 | import { Realtime } from 'ably'; 7 | import { it, beforeEach, describe, expect, vi } from 'vitest'; 8 | import { waitFor, renderHook } from '@testing-library/react'; 9 | import { SpacesProvider } from './contexts/SpacesContext.js'; 10 | import { SpaceProvider } from './contexts/SpaceContext.js'; 11 | import Spaces from '../index.js'; 12 | import { useSpace } from './useSpace.js'; 13 | 14 | interface SpaceTestContext { 15 | spaces: Spaces; 16 | } 17 | 18 | vi.mock('ably'); 19 | vi.mock('nanoid'); 20 | 21 | describe('useSpace', () => { 22 | beforeEach((context) => { 23 | const client = new Realtime({ key: 'spaces-test' }); 24 | context.spaces = new Spaces(client); 25 | }); 26 | 27 | it('creates and retrieves space successfully', async ({ spaces }) => { 28 | const spy = vi.spyOn(spaces, 'get'); 29 | 30 | // @ts-ignore 31 | const { result } = renderHook(() => useSpace(), { 32 | wrapper: ({ children }) => ( 33 | 34 | {children} 35 | 36 | ), 37 | }); 38 | 39 | expect(spy).toHaveBeenCalledTimes(1); 40 | expect(spy).toHaveBeenCalledWith('spaces-test', undefined); 41 | 42 | const space = await spaces.get('spaces-test'); 43 | 44 | await waitFor(() => { 45 | expect(result.current.space).toBe(space); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/react/useSpace.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef } from 'react'; 2 | import { SpaceContext } from './contexts/SpaceContext.js'; 3 | import { useChannelState } from './useChannelState.js'; 4 | import { useConnectionState } from './useConnectionState.js'; 5 | 6 | import type { ErrorInfo } from 'ably'; 7 | import type { Space } from '..'; 8 | import type { UseSpaceCallback, UseSpaceOptions } from './types.js'; 9 | 10 | export interface UseSpaceResult { 11 | space?: Space; 12 | enter?: Space['enter']; 13 | leave?: Space['leave']; 14 | updateProfileData?: Space['updateProfileData']; 15 | connectionError: ErrorInfo | null; 16 | channelError: ErrorInfo | null; 17 | } 18 | 19 | export const useSpace = (callback?: UseSpaceCallback, options?: UseSpaceOptions): UseSpaceResult => { 20 | const space = useContext(SpaceContext); 21 | const callbackRef = useRef(callback); 22 | 23 | const channelError = useChannelState(space?.channel); 24 | const connectionError = useConnectionState(); 25 | 26 | useEffect(() => { 27 | callbackRef.current = callback; 28 | }, [callback]); 29 | 30 | useEffect(() => { 31 | if (callbackRef.current && space && !options?.skip) { 32 | const listener: UseSpaceCallback = (params) => { 33 | callbackRef.current?.(params); 34 | }; 35 | space.subscribe('update', listener); 36 | return () => space.unsubscribe('update', listener); 37 | } 38 | }, [space, options?.skip]); 39 | 40 | return { 41 | space, 42 | enter: space?.enter.bind(space), 43 | leave: space?.leave.bind(space), 44 | updateProfileData: space?.updateProfileData.bind(space), 45 | connectionError, 46 | channelError, 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/react/useSpaces.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { SpacesContext } from './contexts/SpacesContext.js'; 3 | 4 | export const useSpaces = () => { 5 | return useContext(SpacesContext); 6 | }; 7 | -------------------------------------------------------------------------------- /src/utilities/Logger.ts: -------------------------------------------------------------------------------- 1 | export default class Logger { 2 | static logAction(level: LogLevel, action: string, message: string) { 3 | console.log(level, action, message); 4 | } 5 | } 6 | 7 | export enum LogLevel { 8 | LOG_ERROR, 9 | } 10 | -------------------------------------------------------------------------------- /src/utilities/is.ts: -------------------------------------------------------------------------------- 1 | function typeOf(arg: unknown): string { 2 | return Object.prototype.toString.call(arg).slice(8, -1); 3 | } 4 | 5 | // Equivalent of Util.isObject from ably-js 6 | function isObject(arg: unknown): arg is Record { 7 | return typeOf(arg) === 'Object'; 8 | } 9 | 10 | function isFunction(arg: unknown): arg is Function { 11 | return ['Function', 'AsyncFunction', 'GeneratorFunction', 'Proxy'].includes(typeOf(arg)); 12 | } 13 | 14 | function isString(arg: unknown): arg is string { 15 | return typeOf(arg) === 'String'; 16 | } 17 | 18 | function isArray(arg: unknown): arg is Array { 19 | return Array.isArray(arg); 20 | } 21 | 22 | export { isArray, isFunction, isObject, isString }; 23 | -------------------------------------------------------------------------------- /src/utilities/math.ts: -------------------------------------------------------------------------------- 1 | function clamp(num: number, min: number, max: number) { 2 | return Math.min(Math.max(num, min), max); 3 | } 4 | 5 | export { clamp }; 6 | -------------------------------------------------------------------------------- /src/utilities/test/fakes.ts: -------------------------------------------------------------------------------- 1 | import { PresenceMessage } from 'ably'; 2 | 3 | import Space from '../../Space.js'; 4 | 5 | import type { SpaceMember } from '../../types.js'; 6 | import type { PresenceMember } from '../types.js'; 7 | 8 | // import { nanoidId } from '../../../__mocks__/nanoid/index.js'; 9 | const nanoidId = 'NanoidID'; 10 | 11 | const enterPresenceMessage: PresenceMessage = { 12 | clientId: '1', 13 | data: { 14 | profileUpdate: { 15 | id: null, 16 | current: null, 17 | }, 18 | locationUpdate: { 19 | id: null, 20 | current: null, 21 | previous: null, 22 | }, 23 | }, 24 | extras: {}, 25 | action: 'enter', 26 | connectionId: '1', 27 | id: '1', 28 | encoding: 'json', 29 | timestamp: 1, 30 | }; 31 | 32 | const updatePresenceMessage: PresenceMessage = { 33 | ...enterPresenceMessage, 34 | action: 'update', 35 | }; 36 | 37 | const leavePresenceMessage: PresenceMessage = { 38 | ...enterPresenceMessage, 39 | action: 'leave', 40 | }; 41 | 42 | type MessageMap = { 43 | enter: typeof enterPresenceMessage; 44 | update: typeof updatePresenceMessage; 45 | leave: typeof leavePresenceMessage; 46 | }; 47 | 48 | const createPresenceMessage = (type: T, override?: Partial) => { 49 | switch (type) { 50 | case 'enter': 51 | return { ...enterPresenceMessage, ...override }; 52 | case 'update': 53 | return { ...updatePresenceMessage, ...override }; 54 | case 'leave': 55 | return { ...leavePresenceMessage, ...override }; 56 | default: 57 | throw new Error(`Invalid test event type argument: ${type}`); 58 | } 59 | }; 60 | 61 | const createPresenceEvent = async ( 62 | space: Space, 63 | presenceMap: Map, 64 | type: T, 65 | override?: Partial, 66 | ) => { 67 | const member = createPresenceMessage(type, override); 68 | if (type == 'leave') { 69 | presenceMap.delete(member.connectionId); 70 | } else { 71 | presenceMap.set(member.connectionId, member); 72 | } 73 | await space['onPresenceUpdate'](member); 74 | }; 75 | 76 | const createLocationUpdate = (update?: Partial): PresenceMember['data'] => { 77 | return { 78 | locationUpdate: { 79 | current: null, 80 | id: nanoidId, 81 | previous: null, 82 | ...update, 83 | }, 84 | profileUpdate: { 85 | current: null, 86 | id: null, 87 | }, 88 | }; 89 | }; 90 | 91 | const createProfileUpdate = (update?: Partial): PresenceMember['data'] => { 92 | return { 93 | locationUpdate: { 94 | current: null, 95 | id: null, 96 | previous: null, 97 | }, 98 | profileUpdate: { 99 | current: null, 100 | id: nanoidId, 101 | ...update, 102 | }, 103 | }; 104 | }; 105 | 106 | const createSpaceMember = (override?: Partial): SpaceMember => { 107 | return { 108 | clientId: '1', 109 | connectionId: '1', 110 | isConnected: true, 111 | profileData: null, 112 | location: null, 113 | lastEvent: { name: 'update', timestamp: 1 }, 114 | ...override, 115 | }; 116 | }; 117 | 118 | export { createPresenceMessage, createPresenceEvent, createSpaceMember, createLocationUpdate, createProfileUpdate }; 119 | -------------------------------------------------------------------------------- /src/utilities/types.ts: -------------------------------------------------------------------------------- 1 | import type { InboundMessage, PresenceMessage } from 'ably'; 2 | 3 | import type { ProfileData, Lock } from '../types.js'; 4 | 5 | export type PresenceMember = { 6 | data: { 7 | profileUpdate: { 8 | id: string | null; 9 | current: ProfileData; 10 | }; 11 | locationUpdate: { 12 | id: string | null; 13 | previous: unknown; 14 | current: unknown; 15 | }; 16 | }; 17 | extras?: { 18 | locks: Lock[]; 19 | }; 20 | } & Omit; 21 | 22 | /** 23 | * Given an object type `T`, `Subset` represents an object which has the same shape as `T`, but with some keys (at any level of nesting) potentially absent. 24 | * 25 | * @typeParam T The type from which `Subset` is derived. 26 | */ 27 | export type Subset = { 28 | [attr in keyof T]?: T[attr] extends object ? Subset : T[attr]; 29 | }; 30 | 31 | export type RealtimeInboundMessage = Omit & { 32 | clientId: string; 33 | connectionId: string; 34 | }; 35 | -------------------------------------------------------------------------------- /src/version.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { VERSION } from './version.js'; 4 | import packageJson from '../package.json'; 5 | 6 | describe('VERSION', () => { 7 | it('runtime version matches package.json entry', () => { 8 | expect(packageJson.version).toEqual(VERSION); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | // Manually update when bumping version 2 | const VERSION = '0.4.0'; 3 | export { VERSION }; 4 | -------------------------------------------------------------------------------- /test/cdn-bundle/lib/webServer.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import fs from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | import os from 'node:os'; 5 | 6 | async function createFakeCDNDirectory() { 7 | const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ably-spaces-test-cdn-bundle')); 8 | await fs.cp( 9 | path.join(__dirname, '..', '..', '..', 'dist', 'iife', 'index.bundle.js'), 10 | path.join(tmpDir, 'ably-spaces-cdn-bundle.js'), 11 | ); 12 | 13 | return tmpDir; 14 | } 15 | 16 | export async function startWebServer(listenPort: number) { 17 | const server = express(); 18 | server.use(express.static(__dirname + '/../resources')); 19 | 20 | server.get('/', function (_req, res) { 21 | res.redirect('/test.html'); 22 | }); 23 | 24 | server.use('/node_modules', express.static(__dirname + '/../../../node_modules')); 25 | 26 | const fakeCDNDirectory = await createFakeCDNDirectory(); 27 | server.use('/fake-cdn', express.static(fakeCDNDirectory)); 28 | 29 | server.listen(listenPort); 30 | } 31 | -------------------------------------------------------------------------------- /test/cdn-bundle/playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: 'test', 5 | webServer: { 6 | command: 'npm run test-support:cdn-server', 7 | url: 'http://localhost:4567', 8 | }, 9 | use: { 10 | baseURL: 'http://localhost:4567', 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /test/cdn-bundle/resources/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Spaces CDN build test 6 | 7 | 8 | 12 | 16 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /test/cdn-bundle/server.ts: -------------------------------------------------------------------------------- 1 | import { startWebServer } from './lib/webServer.js'; 2 | 3 | (async () => { 4 | await startWebServer(4567); 5 | })(); 6 | -------------------------------------------------------------------------------- /test/cdn-bundle/test/cdnBundle.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { createSandboxAblyAPIKey } from '../../lib/ablySandbox.js'; 3 | 4 | test.describe('CDN bundle', () => { 5 | /** 6 | * Tests the bundle that the .github/workflows/cdn.yml workflow will upload to the CDN. 7 | * 8 | * It does this by checking that a webpage which imports this bundle is able to create and use a Spaces client. 9 | */ 10 | test('browser can import and use the CDN bundle', async ({ page }) => { 11 | const pageResultPromise = new Promise((resolve, reject) => { 12 | page.exposeFunction('onResult', (error: Error | null) => { 13 | if (error) { 14 | reject(error); 15 | } else { 16 | resolve('Success'); 17 | } 18 | }); 19 | 20 | page.exposeFunction('createSandboxAblyAPIKey', createSandboxAblyAPIKey); 21 | }); 22 | 23 | await page.goto('/'); 24 | 25 | await expect(async () => { 26 | const result = await pageResultPromise; 27 | expect(result).toEqual('Success'); 28 | }).toPass(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/cdn-bundle/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true 5 | }, 6 | "ts-node": { 7 | "experimentalResolver": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/integration/utilities/setup.ts: -------------------------------------------------------------------------------- 1 | import { Realtime } from 'ably'; 2 | import { nanoid } from 'nanoid'; 3 | import { createSandboxAblyAPIKey } from '../../lib/ablySandbox.js'; 4 | import Spaces from '../../../src/Spaces.js'; 5 | 6 | /** 7 | * Fetches a key for the Ably sandbox environment. This key is shared between all callers of this function. 8 | */ 9 | const fetchSharedSandboxKey = (() => { 10 | const sandboxKeyPromise = createSandboxAblyAPIKey(); 11 | 12 | return async () => { 13 | return await sandboxKeyPromise; 14 | }; 15 | })(); 16 | 17 | /** 18 | * Performs the following part of a test setup: 19 | * 20 | * > Given $count Spaces clients, all configured to use the same API key, and each configured to use a different randomly-generated client ID... 21 | */ 22 | export async function createClients({ count }: { count: number }) { 23 | const sandboxKey = await fetchSharedSandboxKey(); 24 | 25 | return Array.from({ length: count }, () => { 26 | const clientId = nanoid(); 27 | const realtime = new Realtime({ 28 | environment: 'sandbox', 29 | key: sandboxKey, 30 | clientId: clientId, 31 | }); 32 | const spaces = new Spaces(realtime); 33 | 34 | return { spaces: spaces, clientId: clientId }; 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /test/lib/ablySandbox.ts: -------------------------------------------------------------------------------- 1 | import https from 'node:https'; 2 | import testAppSetup from '../ably-common/test-resources/test-app-setup.json'; 3 | 4 | export interface TestApp { 5 | keys: TestAppKey[]; 6 | } 7 | 8 | export interface TestAppKey { 9 | id: string; 10 | value: string; 11 | keyName: string; 12 | keySecret: string; 13 | keyStr: string; 14 | capability: string; 15 | expires: number; 16 | } 17 | 18 | export async function createSandboxAblyAPIKey() { 19 | const data = await new Promise((resolve, reject) => { 20 | const request = https.request( 21 | { 22 | hostname: 'sandbox-rest.ably.io', 23 | path: '/apps', 24 | port: 443, 25 | method: 'POST', 26 | headers: { 'Content-Type': 'application/json' }, 27 | }, 28 | (incomingMessage) => { 29 | if (!(incomingMessage.statusCode && incomingMessage.statusCode >= 200 && incomingMessage.statusCode < 300)) { 30 | throw new Error(`Unexpected status code ${incomingMessage.statusCode}`); 31 | } 32 | 33 | let data: Buffer | null; 34 | incomingMessage.on('error', reject); 35 | incomingMessage.on('data', (dataChunk: Buffer) => { 36 | if (data) { 37 | data = Buffer.concat([data, dataChunk]); 38 | } else { 39 | data = dataChunk; 40 | } 41 | }); 42 | incomingMessage.on('end', () => { 43 | resolve(data!); 44 | }); 45 | }, 46 | ); 47 | 48 | request.on('error', reject); 49 | 50 | request.write(JSON.stringify(testAppSetup.post_apps), (error) => { 51 | if (error) { 52 | reject(error); 53 | } 54 | request.end(); 55 | }); 56 | }); 57 | 58 | const testApp = JSON.parse(data.toString()) as TestApp; 59 | 60 | return testApp.keys[0].keyStr; 61 | } 62 | -------------------------------------------------------------------------------- /test/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*.ts"], 3 | "exclude": ["./src/**/*.test.tsx", "./src/**/*.test.ts", "./src/fakes/**/*.ts"], 4 | "compilerOptions": { 5 | "jsx": "react", 6 | "target": "es6", 7 | "rootDir": "./src", 8 | "sourceMap": true, 9 | "strict": true, 10 | "alwaysStrict": true, 11 | "noImplicitThis": true, 12 | "esModuleInterop": true, 13 | "declaration": true, 14 | "moduleResolution": "node", 15 | "skipLibCheck": true, 16 | "allowJs": true, 17 | "allowSyntheticDefaultImports": true, 18 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 19 | "types": [] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "dist/cjs" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.iife.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "strict": false, 5 | "importHelpers": true, 6 | "esModuleInterop": true, 7 | "declaration": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 12 | "types": [], 13 | "outDir": "dist/iife" 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["dist", "node_modules", "src/utilities/test", "./src/**/*.test.ts", "./src/fakes/**/*.ts", 17 | "src/react" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.mjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES6", 5 | "outDir": "dist/mjs" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["dist/mjs/index.d.ts"], 4 | "excludeInternal": true, 5 | "excludePrivate": true, 6 | "includeVersion": true, 7 | "out": "docs/typedoc/generated", 8 | "readme": "docs/typedoc/intro.md", 9 | "requiredToBeDocumented": [ 10 | "Accessor", 11 | "CallSignature", 12 | "Class", 13 | "Constructor", 14 | "ConstructorSignature", 15 | "Enum", 16 | "EnumMember", 17 | "Function", 18 | "GetSignature", 19 | "IndexSignature", 20 | "Interface", 21 | "Method", 22 | "Module", 23 | "Namespace", 24 | "Parameter", 25 | "Property", 26 | "Reference", 27 | "SetSignature", 28 | "TypeAlias", 29 | "TypeParameter", 30 | "Variable", 31 | ], 32 | "treatWarningsAsErrors": true, 33 | "validation": true 34 | } 35 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from 'vitest/config'; 2 | 3 | 4 | export default defineConfig({ 5 | test: { 6 | coverage: { 7 | reporter: ['text', 'json', 'html'], 8 | }, 9 | exclude: [...configDefaults.exclude, 'test/ably-common', 'test/cdn-bundle'] 10 | }, 11 | }); 12 | --------------------------------------------------------------------------------