diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index fc931b0..2306632 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -11,6 +11,9 @@ on: jobs: check: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read strategy: matrix: diff --git a/README.md b/README.md index f583d7d..14e4f4a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm install @solarpunkltd/swarm-collaborative-docs The primary class. Manages a Yjs document backed by Swarm and a pluggable transport. ```typescript -import { SwarmDoc, DocSettings, DOC_EVENTS, createSwarmFeedTransport } from '@solarpunkltd/swarm-collaborative-docs' +import { SwarmDoc, DocSettings, DOC_EVENTS, createSwarmPubSubTransport } from '@solarpunkltd/swarm-collaborative-docs' const settings: DocSettings = { user: { @@ -57,23 +57,23 @@ const settings: DocSettings = { infra: { beeUrl: 'http://localhost:1633', mutableStamp: 'your-mutable-batch-id', - topic: 'my-room', - transport: createSwarmFeedTransport(beeUrl, privateKey, mutableStamp, 'my-room'), + topic: 'my-document-id', + transport: createSwarmPubSubTransport('/ip4/1.2.3.4/tcp/1634/p2p/QmXxxx…'), }, } const swarmDoc = new SwarmDoc(settings) -swarmDoc.getEmitter().on(DOC_EVENTS.DOC_UPDATED, doc => { +swarmDoc.getEmitter().on(DOC_EVENTS.DOC_UPDATED, (doc: Y.Doc) => { /* re-render */ }) -swarmDoc.getEmitter().on(DOC_EVENTS.MEMBERS_UPDATED, members => { - /* update peer list */ +swarmDoc.getEmitter().on(DOC_EVENTS.MEMBERS_UPDATED, (members: Map) => { + /* update peer list — Map */ }) swarmDoc.getEmitter().on(DOC_EVENTS.PEERS_CONNECTED, () => { /* enable editor */ }) -swarmDoc.getEmitter().on(DOC_EVENTS.DOC_ERROR, err => { +swarmDoc.getEmitter().on(DOC_EVENTS.DOC_ERROR, (err: Error) => { /* show error */ }) @@ -107,8 +107,8 @@ interface DocSettings { infra: { beeUrl: string // e.g. "http://localhost:1633" mutableStamp?: string // mutable batch (immutableFlag=false) for all Swarm writes - topic: string // shared room identifier - members?: string[] // pre-seeded peer addresses + topic: string // shared document identifier (UUID recommended) + members?: Map // pre-seeded peers: Map transport: DocTransportFactory } } @@ -119,12 +119,12 @@ consensus member list. ### `DOC_EVENTS` -| Event | Payload | When | -| ---------------------------- | ---------- | ----------------------------------------- | -| `DOC_EVENTS.DOC_UPDATED` | `Y.Doc` | After every remote update is applied | -| `DOC_EVENTS.DOC_ERROR` | `Error` | Stamp validation failure or publish error | -| `DOC_EVENTS.MEMBERS_UPDATED` | `string[]` | Peer list changes | -| `DOC_EVENTS.PEERS_CONNECTED` | `true` | Transport has at least one connected peer | +| Event | Payload | When | +| ---------------------------- | --------------------- | ----------------------------------------- | +| `DOC_EVENTS.DOC_UPDATED` | `Y.Doc` | After every remote update is applied | +| `DOC_EVENTS.DOC_ERROR` | `Error` | Stamp validation failure or publish error | +| `DOC_EVENTS.MEMBERS_UPDATED` | `Map` | Peer list changes (address → username) | +| `DOC_EVENTS.PEERS_CONNECTED` | `true` | Transport has at least one connected peer | --- @@ -132,12 +132,38 @@ consensus member list. Each transport implements `DocTransport` and is passed to `DocSettings.infra.transport` as a factory function. +### `createSwarmPubSubTransport` + +**Best for**: low-latency real-time notifications over Swarm with no external signaling server. + +Uses Swarm's GSOC ephemeral pubsub (via the Bee node WebSocket endpoint). All peers subscribed to the same document ID +connect to the same GSOC address — derived deterministically from the `docFeedId` using `PubsubMode.GSOC_EPHEMERAL` +(keccak256 hash → ephemeral key → SOC address). Publish calls are buffered while the WebSocket is connecting and drained +as soon as the connection opens. If the WebSocket closes unexpectedly, the transport reconnects automatically after 10 +seconds. + +Peer discovery does **not** happen at the transport level — new peers become visible via the consensus Swarm feed or +through incoming JOIN notifications. + +```typescript +import { createSwarmPubSubTransport } from '@solarpunkltd/swarm-collaborative-docs' + +transport: createSwarmPubSubTransport('/ip4/1.2.3.4/tcp/1634/p2p/QmXxxx…') +``` + +The sole argument is the multiaddress of a Bee node that acts as the GSOC broker/relay. + +**Delivery model**: bidirectional WebSocket push over the Swarm GSOC pubsub channel. Messages are ephemeral — offline +peers rely on Swarm snapshot reads for recovery. Low latency: ~100ms. + +--- + ### `createSwarmFeedTransport` -**Best for**: reliable delivery, offline peers, production use without a signaling server. +**Best for**: reliable delivery to offline peers; production use without any external server. -Polls each peer's `_notify
` Swarm feed at 1.5s intervals (backs off to 5s when idle). Writes outgoing -payloads to own notification feed. +Polls each peer's `_notify
` Swarm feed at 1.5 s intervals (backs off to 5 s when idle). Writes outgoing +payloads to the local user's own notification feed. ```typescript import { createSwarmFeedTransport } from '@solarpunkltd/swarm-collaborative-docs' @@ -146,18 +172,18 @@ transport: createSwarmFeedTransport(beeUrl, privateKey, mutableStamp, topic) ``` **Delivery model**: store-and-forward over Swarm feeds. Messages persist for offline peers and are delivered on next -poll. Latency ~1.5–5s. +poll. Latency ~1.5–5 s. --- ### `createYWebrtcTransport` -**Best for**: low-latency sync in controlled environments with an available signaling server. +**Best for**: low-latency sync in controlled environments with an available WebSocket signaling server. Uses the [y-webrtc](https://github.com/yjs/y-webrtc) library. Peers are discovered via the `awareness` protocol through -a WebSocket signaling server. Y.js state is synchronised over WebRTC data channels. Cross-tab sync within the same -origin is handled automatically by y-webrtc's built-in BroadcastChannel — no separate `createBroadcastChannelTransport` -is needed. +a WebSocket signaling server. Yjs state is synchronised over WebRTC data channels. Cross-tab sync within the same origin +is handled automatically by y-webrtc's built-in BroadcastChannel — no separate `createBroadcastChannelTransport` is +needed. ```typescript import { createYWebrtcTransport } from '@solarpunkltd/swarm-collaborative-docs' @@ -176,8 +202,8 @@ over data channels. Swarm snapshot reads still provide fallback for offline hist SDP offer/answer records are written to and read from each peer's `_signal` Swarm mutable feed, replacing the traditional signaling server. ICE gathering runs to completion before the SDP is uploaded. Role assignment is -deterministic (lower address = initiator) to avoid duplicate connections. On ICE failure, the initiator retries after -10s. +deterministic (lower address = initiator) to avoid duplicate connections. On ICE failure, the initiator retries after 10 +s. ```typescript import { createSwarmRtcTransport } from '@solarpunkltd/swarm-collaborative-docs' @@ -208,7 +234,7 @@ transport: createWakuTransport() transport: createWakuTransport(['/ip4/...']) ``` -**Delivery model**: gossipsub pub/sub over Waku network. Near-real-time delivery for online peers. Messages are +**Delivery model**: gossipsub pub/sub over the Waku network. Near-real-time delivery for online peers. Messages are ephemeral — offline peers rely on Swarm snapshot reads for recovery. --- @@ -242,60 +268,65 @@ const { doc, error, members, connected, refreshMemberList, dismissError } = useS }) ``` -| Returned value | Type | Description | -| --------------------- | --------------- | ------------------------------------------- | -| `doc` | `Y.Doc \| null` | The Yjs document (null before init) | -| `error` | `Error \| null` | Latest error, or null | -| `members` | `string[]` | Current peer addresses | -| `connected` | `boolean` | Whether the transport has at least one peer | -| `refreshMemberList()` | `() => void` | Triggers an immediate member list refresh | -| `dismissError()` | `() => void` | Clears the current error | +| Returned value | Type | Description | +| --------------------- | ----------------------------- | ------------------------------------------- | +| `doc` | `Y.Doc \| null` | The Yjs document (null before init) | +| `error` | `Error \| null` | Latest error, or null | +| `members` | `Map \| null` | Connected peers: address → username | +| `connected` | `boolean` | Whether the transport has at least one peer | +| `refreshMemberList()` | `() => void` | Triggers an immediate member list refresh | +| `dismissError()` | `() => void` | Clears the current error | --- ## Example app (`src/app`) -A minimal test application demonstrating all transport options with a shared plain-text editor. +A minimal test application demonstrating all transport options with a shared text editor. ### Running locally ```bash pnpm install -npm run start +pnpm start ``` The app runs at `http://localhost:5002`. ### Login screen -Configure before joining a session: - -- **Bee URL** — your local Bee node API (e.g. `http://localhost:1633`) -- **Topic** — shared room name; all peers must use the same value -- **Mutable stamp** — postage batch for all Swarm writes -- **Transport selection**: - - _Swarm_ — SwarmFeed polling (offline-tolerant, no extra server) - - _y-webrtc_ — WebRTC via a WebSocket signaling URL - - _Swarm WebRTC_ — WebRTC with Swarm-based SDP signaling (STUN URL required) - - _Waku_ — Waku gossipsub network (optional bootstrap peer address) -- **Disable until connected** — prevents editing before the transport has a live peer +Enter a username, then configure: + +- **Document ID** — UUID that identifies the shared document; auto-generated and persisted in `localStorage`. Paste an + invite link to pre-fill this field. + - **Invite** button — copies a shareable link (`?doc=&trans=`) to the clipboard. + - **Generate new ID** — creates a fresh UUID and saves it. +- **Transport tabs** — select the active notification transport: + - _Swarm PubSub_ — GSOC ephemeral pubsub (requires a Bee broker peer, see Advanced Settings) + - _Waku_ — Waku gossipsub (no Bee node required) + - _WebRTC_ — y-webrtc via a signaling server or Swarm-based SDP signaling (see Advanced Settings) +- **Advanced Settings** (collapsible): + - Bee API URL + - `MUTABLE_STAMP` postage batch ID + - Disable editing until a peer is connected (WebRTC / Waku only) + - Broker Peer multiaddress (Swarm PubSub only) + - Signaling Server URL or Swarm Signaling STUN URL (WebRTC only) ### Session screen -- Shared textarea bound to a Yjs `Text` type -- Peer list showing connected Ethereum addresses (short display + copy on click) -- Config panel for live mutable stamp and topic changes without re-login +- Shared editor bound to a Yjs `Text` type +- Peer list showing connected members with their usernames (hover for full address, click to copy) - Transport badge showing the active transport ## Transport comparison -| | SwarmFeed | y-webrtc | SwarmRtc | Waku | BroadcastChannel | -| ----------------- | :-------: | :------: | :------: | :----: | :--------------: | -| Latency | ~1.5–5s | ~100ms | ~100ms | ~100ms | ~0ms | -| Offline delivery | ✓ | ✗ | ✗ | ✗ | ✗ | -| No central server | ✓ | ✗ | ✓ | ✓ | ✓ | -| Requires Bee node | ✓ | ✗ | ✓ | ✗ | ✗ | -| Cross-device | ✓ | ✓ | ✓ | ✓ | ✗ | +| | SwarmPubSub | SwarmFeed | y-webrtc | SwarmRtc | Waku | BroadcastChannel | +| -------------------- | :---------: | :-------: | :------: | :------: | :----: | :--------------: | +| Latency | ~100ms | ~1.5–5s | ~100ms | ~100ms | ~100ms | ~0ms | +| Offline delivery | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | +| No external server | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ | +| Requires Bee node | ✓ | ✓ | ✗ | ✓ | ✗ | ✗ | +| Requires broker peer | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | +| Cross-device | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | All transports fall back to Swarm snapshot reads for document history recovery regardless of notification delivery guarantees. diff --git a/eslint.config.mjs b/eslint.config.mjs index f8583c4..3ee58f8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -99,6 +99,8 @@ export default defineConfig([ TextEncoder: 'readonly', TextDecoder: 'readonly', CustomEvent: 'readonly', + URLSearchParams: 'readonly', + window: 'readonly', }, }, }, diff --git a/index.html b/index.html index 6223c69..906db66 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,51 @@ + Swarm Collaborative Docs + + + +
diff --git a/package.json b/package.json index b31c77a..eca5fd8 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,10 @@ "init:husky": "pnpm husky install" }, "dependencies": { - "@ethersphere/bee-js": "^11.1.1", - "@solarpunkltd/comment-system": "^1.9.2", + "@ethersphere/bee-js": "github:Apiary-Suite/bee-js", + "@solarpunkltd/comment-system": "^1.9.3", "@waku/sdk": "^0.0.36", + "lucide-react": "^1.8.0", "react": "^19.2.4", "react-dom": "^19.2.4", "y-webrtc": "^10.3.0", @@ -74,7 +75,11 @@ "vite-plugin-node-polyfills": "^0.26.0" }, "pnpm": { + "overrides": { + "@ethersphere/bee-js": "github:Apiary-Suite/bee-js" + }, "onlyBuiltDependencies": [ + "@ethersphere/bee-js", "@parcel/watcher", "unrs-resolver" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ea7909..2e3da9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,19 +4,25 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@ethersphere/bee-js': github:Apiary-Suite/bee-js + importers: .: dependencies: '@ethersphere/bee-js': - specifier: ^11.1.1 - version: 11.1.1 + specifier: github:Apiary-Suite/bee-js + version: https://codeload.github.com/Apiary-Suite/bee-js/tar.gz/7f35e652846284a40b2efce9de3e483d73592d3c '@solarpunkltd/comment-system': - specifier: ^1.9.2 - version: 1.9.2 + specifier: ^1.9.3 + version: 1.9.3 '@waku/sdk': specifier: ^0.0.36 version: 0.0.36(@multiformats/multiaddr@12.5.1) + lucide-react: + specifier: ^1.8.0 + version: 1.14.0(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -337,8 +343,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ethersphere/bee-js@11.1.1': - resolution: {integrity: sha512-s2IpuVpy74cJyhj5yugO3+c9eR/r9TZsVJABk2iHWtTVkoA1qZdvgUjzV0w962o1gXrmYdxX4NOmlyIEpY11iQ==} + '@ethersphere/bee-js@https://codeload.github.com/Apiary-Suite/bee-js/tar.gz/7f35e652846284a40b2efce9de3e483d73592d3c': + resolution: {tarball: https://codeload.github.com/Apiary-Suite/bee-js/tar.gz/7f35e652846284a40b2efce9de3e483d73592d3c} + version: 12.1.0 engines: {bee: 2.4.0-390a402e, beeApiVersion: 7.2.0} '@ethersproject/bytes@5.8.0': @@ -771,8 +778,8 @@ packages: resolution: {integrity: sha512-KV321z5m/0nuAg83W1dPLy85HpHDk7Sdi4fJbwvacWsEhAh+rZUW4ZfGcXmUIvjZg4ss2bcwNlRhJ7GBEUG08w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - '@solarpunkltd/comment-system@1.9.2': - resolution: {integrity: sha512-DLcdsDUU6rEgDs8U87PDi7hy073lV3erp+Z7BZpasRsAFNjvlnevwy0SWa2CWdMxnQr/fZMvBzcCd2KHquZ9ag==} + '@solarpunkltd/comment-system@1.9.3': + resolution: {integrity: sha512-wUPvyzSa5E+1JwlM6op9roD1qxG/rlrz21Dyz1ngPhgitvPUj0t5GvV6EED2TKKqiPsWpIdaYhHPUx+UftR78w==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1217,8 +1224,8 @@ packages: resolution: {integrity: sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==} engines: {node: '>=4'} - axios@0.30.3: - resolution: {integrity: sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -1302,8 +1309,8 @@ packages: builtin-status-codes@3.0.0: resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} - cafe-utility@33.8.0: - resolution: {integrity: sha512-qfbG8nHTY5jBfCSedG9+b57avhKePdiHfj2w0FMGokJuQU+3dxeT9+qasMReoXHBDp38CFHMSEvz5X0SDCXbWA==} + cafe-utility@33.9.0: + resolution: {integrity: sha512-MrTuoypjKKnfJg7CqbPbN2ugXuPp0qG5mLdJMjG9GzeGhMDBxD3xhM4EnmPh7+rH4sFqx29c4caxg3oU+eT/Zw==} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -1860,8 +1867,8 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2545,6 +2552,11 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lucide-react@1.14.0: + resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2861,8 +2873,9 @@ packages: protons-runtime@6.0.1: resolution: {integrity: sha512-ONL+jDj143WA1m+WKLuuqBIaDKxm32dx6HfJdyujrRcni/6KkhXzVnyg22nH/Wwqmbwnd1BKUVkD1hMEWZFeww==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} public-encrypt@4.0.3: resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} @@ -3841,10 +3854,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@ethersphere/bee-js@11.1.1': + '@ethersphere/bee-js@https://codeload.github.com/Apiary-Suite/bee-js/tar.gz/7f35e652846284a40b2efce9de3e483d73592d3c': dependencies: - axios: 0.30.3(debug@4.4.3) - cafe-utility: 33.8.0 + axios: 1.16.0(debug@4.4.3) + cafe-utility: 33.9.0 debug: 4.4.3 isomorphic-ws: 4.0.1(ws@8.20.0) semver: 7.7.4 @@ -4409,10 +4422,10 @@ snapshots: '@sindresorhus/fnv1a@3.1.0': {} - '@solarpunkltd/comment-system@1.9.2': + '@solarpunkltd/comment-system@1.9.3': dependencies: - '@ethersphere/bee-js': 11.1.1 - cafe-utility: 33.8.0 + '@ethersphere/bee-js': https://codeload.github.com/Apiary-Suite/bee-js/tar.gz/7f35e652846284a40b2efce9de3e483d73592d3c + cafe-utility: 33.9.0 ethers: 6.16.0 uuid: 11.1.0 transitivePeerDependencies: @@ -4938,11 +4951,11 @@ snapshots: axe-core@4.11.2: {} - axios@0.30.3(debug@4.4.3): + axios@1.16.0(debug@4.4.3): dependencies: - follow-redirects: 1.15.11(debug@4.4.3) + follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -5049,7 +5062,7 @@ snapshots: builtin-status-codes@3.0.0: {} - cafe-utility@33.8.0: {} + cafe-utility@33.9.0: {} call-bind-apply-helpers@1.0.2: dependencies: @@ -5793,7 +5806,7 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.15.11(debug@4.4.3): + follow-redirects@1.16.0(debug@4.4.3): optionalDependencies: debug: 4.4.3 @@ -6499,6 +6512,10 @@ snapshots: dependencies: yallist: 4.0.0 + lucide-react@1.14.0(react@19.2.4): + dependencies: + react: 19.2.4 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6848,7 +6865,7 @@ snapshots: uint8arraylist: 2.4.9 uint8arrays: 5.1.1 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} public-encrypt@4.0.3: dependencies: diff --git a/src/app/components/DocEditor/DocEditor.scss b/src/app/components/DocEditor/DocEditor.scss index fc036ec..472c2b4 100644 --- a/src/app/components/DocEditor/DocEditor.scss +++ b/src/app/components/DocEditor/DocEditor.scss @@ -1,35 +1,83 @@ +$swarm-orange: #f76808; +$swarm-dark: #050509; +$swarm-surface: #0e0e1a; +$swarm-border: rgba(120, 120, 180, 0.14); +$swarm-muted: #8a8aa3; +$swarm-text: #f8f8ff; + .doc-editor { display: flex; flex-direction: column; height: 100%; + background: $swarm-dark; &--loading { - padding: 16px; - color: #888; + padding: 24px; + color: $swarm-muted; font-style: italic; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Inter', system-ui, sans-serif; + + &::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: $swarm-orange; + margin-right: 10px; + animation: doc-editor-pulse 1.5s ease-in-out infinite; + } } &__textarea { flex: 1; width: 100%; - padding: 16px; - font-family: 'Courier New', Courier, monospace; + padding: 28px 32px; + font-family: 'JetBrains Mono', 'Courier New', Courier, monospace; font-size: 14px; - line-height: 1.6; - background: #1a1a1a; - color: #e8e8e8; + line-height: 1.7; + background: $swarm-surface; + color: $swarm-text; border: none; + border-top: 1px solid $swarm-border; outline: none; resize: none; box-sizing: border-box; + caret-color: $swarm-orange; + transition: background 0.2s ease; + + &:focus { + background: rgba(14, 14, 26, 0.6); + } &:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; } &::placeholder { - color: #555; + color: rgba(138, 138, 163, 0.4); + font-style: italic; + font-family: 'Inter', system-ui, sans-serif; } + + &::selection { + background: rgba(247, 104, 8, 0.25); + } + } +} + +@keyframes doc-editor-pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.2); } } diff --git a/src/app/components/LoginView/LoginView.scss b/src/app/components/LoginView/LoginView.scss index c2f0ee5..b3d7dca 100644 --- a/src/app/components/LoginView/LoginView.scss +++ b/src/app/components/LoginView/LoginView.scss @@ -1,38 +1,133 @@ +$swarm-orange: #f76808; +$swarm-rose: #f43f5e; +$swarm-dark: #050509; +$swarm-surface: #0e0e1a; +$swarm-elevated: #16162a; +$swarm-border: rgba(120, 120, 180, 0.14); +$swarm-muted: #8a8aa3; +$swarm-text: #f8f8ff; +$swarm-subtle: rgba(120, 120, 180, 0.08); + .login-view { - padding: 32px; + min-height: 100vh; + min-height: 100dvh; display: flex; - flex-direction: column; - gap: 16px; - max-width: 400px; + align-items: center; + justify-content: center; + padding: 24px; + background: $swarm-dark; + background-image: radial-gradient(ellipse 80% 50% at 50% 0%, rgba(247, 104, 8, 0.07), transparent 60%); + color: $swarm-text; + font-family: 'Inter', system-ui, sans-serif; + + &__container { + width: 100%; + max-width: 440px; + display: flex; + flex-direction: column; + gap: 20px; + } + + &__brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-bottom: 4px; + } + + &__logo { + width: 52px; + height: 52px; + border-radius: 16px; + background: linear-gradient(135deg, $swarm-orange 0%, $swarm-rose 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + box-shadow: 0 10px 30px -8px rgba(247, 104, 8, 0.45); + } &__title { margin: 0; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + } + + &__subtitle { + margin: 0; + font-size: 12px; + color: $swarm-muted; + } + + &__card { + background: $swarm-surface; + border: 1px solid $swarm-border; + border-radius: 20px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.6); } &__input { - padding: 8px; - font-size: 16px; + width: 100%; + background: $swarm-elevated; + border: 1px solid $swarm-border; + border-radius: 12px; + padding: 12px 14px; + font-size: 14px; + color: $swarm-text; + font-family: inherit; + outline: none; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + + &::placeholder { + color: rgba(138, 138, 163, 0.6); + } + + &:focus { + border-color: rgba(247, 104, 8, 0.5); + box-shadow: 0 0 0 3px rgba(247, 104, 8, 0.12); + } } &__tab-bar { display: flex; - border-radius: 4px; - overflow: hidden; - border: 1px solid #555; + padding: 4px; + background: $swarm-elevated; + border: 1px solid $swarm-border; + border-radius: 12px; + gap: 4px; } &__tab-btn { flex: 1; - padding: 6px 0; + padding: 8px 12px; font-size: 13px; + font-weight: 500; border: none; + border-radius: 8px; cursor: pointer; - background: #2a2a2a; - color: #aaa; + background: transparent; + color: $swarm-muted; + font-family: inherit; + transition: + background 0.15s ease, + color 0.15s ease; + + &:hover:not(&--active) { + color: $swarm-text; + } &--active { - background: #4f8ef7; - color: #fff; + background: rgba(247, 104, 8, 0.12); + color: $swarm-orange; + box-shadow: inset 0 0 0 1px rgba(247, 104, 8, 0.25); } } @@ -43,32 +138,70 @@ } &__url-input { - padding: 6px; - font-size: 13px; - font-family: monospace; + width: 100%; + background: $swarm-elevated; + border: 1px solid $swarm-border; + border-radius: 12px; + padding: 10px 12px; + font-size: 12px; + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; + color: $swarm-text; + outline: none; + transition: border-color 0.15s ease; + + &::placeholder { + color: rgba(138, 138, 163, 0.5); + } + + &:focus { + border-color: rgba(247, 104, 8, 0.5); + } } &__field { display: flex; flex-direction: column; - gap: 4px; + gap: 6px; } &__field-label { - font-size: 12px; - opacity: 0.6; + font-size: 11px; + font-weight: 500; + color: $swarm-muted; + text-transform: uppercase; + letter-spacing: 0.06em; } &__field-input { - padding: 6px; + width: 100%; + background: $swarm-elevated; + border: 1px solid $swarm-border; + border-radius: 12px; + padding: 10px 12px; font-size: 13px; + color: $swarm-text; + font-family: inherit; + outline: none; + transition: border-color 0.15s ease; + + &::placeholder { + color: rgba(138, 138, 163, 0.5); + } + + &:focus { + border-color: rgba(247, 104, 8, 0.5); + } &--mono { - font-family: monospace; + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; + font-size: 12px; } } &__stamp-warning { + display: inline-flex; + align-items: center; + gap: 6px; font-size: 11px; color: #fbbf24; } @@ -78,10 +211,11 @@ align-items: center; gap: 8px; font-size: 12px; - color: #f87171; - padding: 6px 10px; - background: #2a1010; - border-radius: 4px; + color: #fca5a5; + padding: 10px 12px; + background: rgba(244, 63, 94, 0.08); + border: 1px solid rgba(244, 63, 94, 0.2); + border-radius: 12px; &-text { flex: 1; @@ -91,12 +225,122 @@ &__checkbox-label { display: flex; align-items: center; - gap: 8px; + gap: 10px; font-size: 13px; + color: $swarm-muted; cursor: pointer; + user-select: none; + + input[type='checkbox'] { + accent-color: $swarm-orange; + width: 14px; + height: 14px; + cursor: pointer; + } } &__submit { - padding: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 16px; + border: none; + border-radius: 12px; + background: linear-gradient(135deg, $swarm-orange 0%, $swarm-rose 100%); + color: #fff; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 10px 24px -10px rgba(247, 104, 8, 0.55); + transition: + transform 0.08s ease, + opacity 0.15s ease; + + &:hover:not(:disabled) { + opacity: 0.94; + } + + &:active:not(:disabled) { + transform: scale(0.98); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; + } + } + + &__footer { + text-align: center; + font-size: 11px; + color: rgba(138, 138, 163, 0.5); + margin-top: 4px; + } + + &__doc-id-row { + display: flex; + gap: 8px; + align-items: flex-end; + } + + &__invite-btn { + padding: 8px 10px; + border-radius: 10px; + border: 1px solid $swarm-border; + background: transparent; + color: $swarm-muted; + font-weight: 600; + cursor: pointer; + transition: + background 0.12s ease, + color 0.12s ease; + } + + &__invite-btn--copied { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; + border-color: rgba(34, 197, 94, 0.18); + } + + &__new-id-btn { + padding: 8px 10px; + border-radius: 10px; + border: 1px solid $swarm-border; + background: transparent; + color: $swarm-muted; + font-weight: 600; + cursor: pointer; + transition: + background 0.12s ease, + color 0.12s ease; + } + + &__new-id-btn--clicked { + background: rgba(34, 197, 94, 0.12); + color: #c59a22; + border-color: rgba(34, 197, 94, 0.18); + } + + &__advanced-toggle { + display: flex; + justify-content: flex-end; + } + + &__advanced-toggle-btn { + background: transparent; + border: none; + color: $swarm-muted; + cursor: pointer; + font-size: 13px; + padding: 6px 8px; + } + + &__advanced { + display: flex; + flex-direction: column; + gap: 10px; } } diff --git a/src/app/components/LoginView/LoginView.tsx b/src/app/components/LoginView/LoginView.tsx index 57b9cd0..9b70eda 100644 --- a/src/app/components/LoginView/LoginView.tsx +++ b/src/app/components/LoginView/LoginView.tsx @@ -1,22 +1,37 @@ -import { PLACEHOLDER_STAMP, validateStamps } from 'lib' -import React, { useState } from 'react' +import { PLACEHOLDER_STAMP, uuidV4, validateStamps } from 'lib' +import { AlertCircle, AlertTriangle, FileText, LogIn } from 'lucide-react' +import React, { useCallback, useState } from 'react' import { BEE_URL_KEY, + BROKER_PEER_KEY, DEFAULT_BEE_API_URL, DEFAULT_ICE_SERVER_URL, DEFAULT_SIGNALING_SERVER_URL, DEFAULT_TOPIC, MUTABLE_STAMP_KEY, + SESSION_KEY, SIGNALING_URL_KEY, STUN_URL_KEY, TOPIC_KEY, + TRANSPORT_KEY, } from '../../utils/constants' -import { loadStunUrl } from '../../utils/localStorage' +import { loadBrokerPeer, loadSession, loadStunUrl, loadTransport } from '../../utils/localStorage' import { Transport, TRANSPORT_LABELS, WebrtcMode } from '../../utils/types' import './LoginView.scss' +const BUTTON_TIMEOUT_MS = 1500 + +const origin = typeof window !== 'undefined' ? window.location.origin : '' + +const buildInviteLink = (docId: string, transport: string) => { + const m = typeof window !== 'undefined' ? window.location.pathname.match(/^\/bzz\/([^/]+)/) : null + const base = m && m[1] ? `${origin}/bzz/${m[1]}/` : `${origin}/` + + return `${base}?doc=${encodeURIComponent(docId)}&trans=${transport}` +} + interface LoginViewProps { username?: string beeUrl: string @@ -34,9 +49,12 @@ interface LoginViewProps { signalingUrl?: string, stunUrl?: string, wakuAddress?: string, + brokerPeer?: string, ) => void } +const Transports = [Transport.SWARM_PUBSUB, Transport.WAKU, Transport.WEBRTC] as const + export const LoginView: React.FC = ({ username, beeUrl, @@ -50,13 +68,51 @@ export const LoginView: React.FC = ({ onLogin, }) => { const [inputName, setInputName] = useState(username ?? '') - const [transport, setTransport] = useState(Transport.WEBRTC) - const [serverUrl, setServerUrl] = useState(loadStunUrl() || DEFAULT_ICE_SERVER_URL) - const [webrtcMode, setWebrtcMode] = useState(loadStunUrl() ? WebrtcMode.SWARM : WebrtcMode.SIGNALING) + const [transport, setTransport] = useState(loadTransport()) + const [serverUrl, setServerUrl] = useState(loadStunUrl()) + const [webrtcMode, setWebrtcMode] = useState( + loadStunUrl() ? WebrtcMode.SWARM_SIGNAL_FEED : WebrtcMode.SIGNALING_SERVER, + ) + const [brokerPeer, setBrokerPeer] = useState(loadBrokerPeer()) const [validating, setValidating] = useState(false) const [pageError, setPageError] = useState(null) + const [advancedOpen, setAdvancedOpen] = useState(false) + const [copied, setCopied] = useState(false) + const [newDocIdGenerated, setNewDocIdGenerated] = useState(false) + + const handleTransportChange = (t: Transport) => { + setTransport(t) + localStorage.setItem(TRANSPORT_KEY, t) + } + + const handleCopyInvite = useCallback(async () => { + try { + const link = buildInviteLink(topic, transport) + await navigator.clipboard.writeText(link) + setCopied(true) + setTimeout(() => setCopied(false), BUTTON_TIMEOUT_MS) + } catch { + // ignore + } + }, [topic, transport]) + + const handleGenerateNewDocId = useCallback(() => { + const newDocId = uuidV4() + onTopicChange(newDocId) + setNewDocIdGenerated(true) + + localStorage.setItem(TOPIC_KEY, newDocId) + const existingSession = loadSession() - const submit = async () => { + if (existingSession) { + existingSession.topic = newDocId + localStorage.setItem(SESSION_KEY, JSON.stringify(existingSession)) + } + + setTimeout(() => setNewDocIdGenerated(false), BUTTON_TIMEOUT_MS) + }, [onTopicChange]) + + const submit = useCallback(async () => { const name = inputName.trim() if (!name) return @@ -70,6 +126,15 @@ export const LoginView: React.FC = ({ } } + if (transport === Transport.SWARM_PUBSUB) { + if (!brokerPeer.trim()) { + setPageError('Broker peer multiaddress is required for Swarm Pubsub!') + setValidating(false) + + return + } + } + setPageError(null) setValidating(true) @@ -87,7 +152,7 @@ export const LoginView: React.FC = ({ let signalingUrl: string | undefined = undefined let stunUrl: string | undefined = undefined - if (webrtcMode === WebrtcMode.SIGNALING) { + if (webrtcMode === WebrtcMode.SIGNALING_SERVER) { signalingUrl = serverUrl localStorage.setItem(STUN_URL_KEY, '') } else { @@ -95,121 +160,199 @@ export const LoginView: React.FC = ({ localStorage.setItem(SIGNALING_URL_KEY, '') } - onLogin(name, transport, topic, signalingUrl, stunUrl) - } + const peer = brokerPeer.trim() || undefined + + onLogin(name, transport, topic, signalingUrl, stunUrl, undefined, peer) + }, [inputName, transport, topic, webrtcMode, brokerPeer, beeUrl, mutableStamp, onLogin, serverUrl]) return (
-

Swarm Collab Doc

- setInputName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && submit()} - placeholder={username ?? 'Enter username'} - className="login-view__input" - autoFocus - /> -
- {([Transport.SWARM, Transport.BROADCAST, Transport.WEBRTC, Transport.WAKU] as const).map(t => ( - - ))} -
- {transport === Transport.WEBRTC && ( -
+
+
+ +

Swarm Collab Doc

+

Real-time collaborative docs over Swarm

+
+ +
+ setInputName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && submit()} + placeholder={username ?? 'Enter username'} + className="login-view__input" + autoFocus + /> + +
+
+ + onTopicChange(e.target.value)} + onBlur={() => localStorage.setItem(TOPIC_KEY, topic)} + placeholder={DEFAULT_TOPIC} + className={`login-view__field-input login-view__field-input--mono`} + /> +
+
+ +
+ + +
+
- {([WebrtcMode.SIGNALING, WebrtcMode.SWARM] as const).map(mode => ( + {Transports.map(t => ( ))}
- { - setServerUrl(e.target.value)} - onBlur={() => - localStorage.setItem(webrtcMode === WebrtcMode.SIGNALING ? SIGNALING_URL_KEY : STUN_URL_KEY, serverUrl) - } - placeholder={webrtcMode === WebrtcMode.SIGNALING ? DEFAULT_SIGNALING_SERVER_URL : DEFAULT_ICE_SERVER_URL} - className="login-view__url-input" - /> - } -
- )} - {( - [ - { - key: BEE_URL_KEY, - label: 'Bee API URL', - value: beeUrl, - onChange: onBeeUrlChange, - placeholder: DEFAULT_BEE_API_URL, - mono: false, - }, - { - key: MUTABLE_STAMP_KEY, - label: 'MUTABLE_STAMP', - value: mutableStamp, - onChange: onMutableStampChange, - placeholder: PLACEHOLDER_STAMP, - mono: true, - }, - { - key: TOPIC_KEY, - label: 'TOPIC', - value: topic, - onChange: onTopicChange, - placeholder: DEFAULT_TOPIC, - mono: true, - }, - ] as const - ).map(({ key, label, value, onChange, placeholder, mono }) => ( -
- - onChange(e.target.value)} - onBlur={() => localStorage.setItem(key, value)} - placeholder={placeholder} - className={`login-view__field-input${mono ? ' login-view__field-input--mono' : ''}`} - /> - {mono && (!value || value === PLACEHOLDER_STAMP) && ( - ⚠ No stamp set — uploads will rely on a smart gateway + +
+ +
+ + {advancedOpen && ( +
+ {transport === Transport.WEBRTC && ( +
+
+ {([WebrtcMode.SIGNALING_SERVER, WebrtcMode.SWARM_SIGNAL_FEED] as const).map(mode => ( + + ))} +
+ setServerUrl(e.target.value)} + onBlur={() => + localStorage.setItem( + webrtcMode === WebrtcMode.SIGNALING_SERVER ? SIGNALING_URL_KEY : STUN_URL_KEY, + serverUrl, + ) + } + placeholder={ + webrtcMode === WebrtcMode.SIGNALING_SERVER ? DEFAULT_SIGNALING_SERVER_URL : DEFAULT_ICE_SERVER_URL + } + className="login-view__url-input" + /> +
+ )} +
+ + onBeeUrlChange(e.target.value)} + onBlur={() => localStorage.setItem(BEE_URL_KEY, beeUrl)} + placeholder={DEFAULT_BEE_API_URL} + className="login-view__field-input" + /> + {beeUrl === DEFAULT_BEE_API_URL && ( + + + Default Gateway is used + + )} +
+ +
+ + onMutableStampChange(e.target.value)} + onBlur={() => localStorage.setItem(MUTABLE_STAMP_KEY, mutableStamp)} + placeholder={PLACEHOLDER_STAMP} + className="login-view__field-input login-view__field-input--mono" + /> + {(!mutableStamp || mutableStamp === PLACEHOLDER_STAMP) && ( + + + No stamp set — uploads will rely on a gateway + + )} +
+ + {transport !== Transport.SWARM_FEED_POLL && ( + + )} + + {transport === Transport.SWARM_PUBSUB && ( +
+ + setBrokerPeer(e.target.value)} + onBlur={() => localStorage.setItem(BROKER_PEER_KEY, brokerPeer)} + placeholder="/ip4/1.2.3.4/tcp/1634/p2p/QmXxxx…" + className="login-view__url-input" + /> +
+ )} +
)} + + {pageError && ( +
+ + {pageError} +
+ )} + +
- ))} - {pageError && ( -
- ⚠ {pageError} -
- )} - {transport !== Transport.SWARM && ( - - )} - + +

Powered by Ethereum Swarm

+
) } diff --git a/src/app/components/SessionView/SessionView.scss b/src/app/components/SessionView/SessionView.scss index 8b12277..d53d2d7 100644 --- a/src/app/components/SessionView/SessionView.scss +++ b/src/app/components/SessionView/SessionView.scss @@ -1,44 +1,117 @@ +$swarm-orange: #f76808; +$swarm-rose: #f43f5e; +$swarm-dark: #050509; +$swarm-surface: #0e0e1a; +$swarm-elevated: #16162a; +$swarm-border: rgba(120, 120, 180, 0.14); +$swarm-muted: #8a8aa3; +$swarm-text: #f8f8ff; +$swarm-subtle: rgba(120, 120, 180, 0.08); + .session-view { height: 100vh; + height: 100dvh; display: flex; flex-direction: column; + background: $swarm-dark; + color: $swarm-text; + font-family: 'Inter', system-ui, sans-serif; &__error-bar { - padding: 16px; - color: #f87171; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + color: #fca5a5; + background: rgba(244, 63, 94, 0.08); + border-bottom: 1px solid rgba(244, 63, 94, 0.2); + font-size: 13px; + + button { + margin-left: auto; + background: transparent; + border: 1px solid rgba(244, 63, 94, 0.3); + color: #fca5a5; + border-radius: 8px; + padding: 6px 12px; + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: background 0.15s ease; + + &:hover { + background: rgba(244, 63, 94, 0.12); + } + } } &__doc-block { - height: 100vh; + height: 100%; display: flex; flex-direction: column; } &__header { - background: #222; - color: #fff; + background: rgba(14, 14, 26, 0.85); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + color: $swarm-text; font-size: 13px; + border-bottom: 1px solid $swarm-border; + position: relative; + z-index: 2; } &__header-row { - padding: 8px 16px; + padding: 10px 20px; display: flex; - gap: 12px; + gap: 10px; align-items: center; flex-wrap: wrap; } + &__logo { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, $swarm-orange 0%, $swarm-rose 100%); + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; + box-shadow: 0 4px 12px -4px rgba(247, 104, 8, 0.5); + } + + &__username { + font-weight: 600; + font-size: 14px; + letter-spacing: -0.01em; + } + &__pubkey { + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 11px; - opacity: 0.6; + color: $swarm-muted; + background: $swarm-elevated; + border: 1px solid $swarm-border; + padding: 4px 8px; + border-radius: 8px; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } &__transport-badge { font-size: 11px; - opacity: 0.5; - padding: 1px 6px; - border: 1px solid #555; - border-radius: 3px; + font-weight: 500; + color: $swarm-orange; + background: rgba(247, 104, 8, 0.1); + border: 1px solid rgba(247, 104, 8, 0.25); + padding: 3px 8px; + border-radius: 999px; + letter-spacing: 0.02em; } &__members { @@ -46,91 +119,190 @@ gap: 6px; align-items: center; margin-left: auto; + flex-wrap: wrap; + } + + &__members-label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: $swarm-muted; } &__member-chip { - background: #333; - border-radius: 3px; - padding: 1px 6px; + background: $swarm-elevated; + border: 1px solid $swarm-border; + border-radius: 999px; + padding: 3px 10px; font-size: 11px; + display: inline-flex; + align-items: center; + gap: 4px; + } + + &__member-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2); } &__member-code { - opacity: 0.8; + color: $swarm-text; + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; } &__btn { - font-size: 11px; - padding: 2px 6px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + padding: 6px 12px; + border: 1px solid $swarm-border; + background: $swarm-elevated; + color: $swarm-text; + border-radius: 10px; + cursor: pointer; + font-family: inherit; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; - &--logout { - font-size: 12px; - padding: 2px 8px; + &:hover { + background: rgba(120, 120, 180, 0.12); + border-color: rgba(120, 120, 180, 0.25); } &--refresh { - opacity: 0.7; + color: $swarm-muted; + + &:hover { + color: $swarm-text; + } } &--config { - opacity: 0.6; + color: $swarm-muted; &-open { - opacity: 1; + color: $swarm-orange; + border-color: rgba(247, 104, 8, 0.35); + background: rgba(247, 104, 8, 0.08); + } + } + + &--logout { + color: #fca5a5; + border-color: rgba(244, 63, 94, 0.25); + background: rgba(244, 63, 94, 0.06); + + &:hover { + background: rgba(244, 63, 94, 0.14); + border-color: rgba(244, 63, 94, 0.4); } } } &__config-panel { - padding: 8px 16px; - border-top: 1px solid #333; + padding: 14px 20px 16px; + border-top: 1px solid $swarm-border; + background: rgba(5, 5, 9, 0.6); display: flex; flex-direction: column; - gap: 6px; + gap: 10px; + animation: session-view-slide 0.2s ease-out; } &__config-field { display: flex; - gap: 8px; + gap: 10px; align-items: center; } &__config-label { font-size: 11px; - opacity: 0.7; + font-weight: 500; + color: $swarm-muted; + text-transform: uppercase; + letter-spacing: 0.06em; white-space: nowrap; width: 160px; } &__config-input { flex: 1; - padding: 3px 6px; + padding: 8px 12px; font-size: 12px; - background: #111; - color: #fff; - border: 1px solid #555; - border-radius: 3px; + background: $swarm-elevated; + color: $swarm-text; + border: 1px solid $swarm-border; + border-radius: 10px; + font-family: inherit; + outline: none; + transition: border-color 0.15s ease; + + &::placeholder { + color: rgba(138, 138, 163, 0.5); + } + + &:focus { + border-color: rgba(247, 104, 8, 0.5); + } &--mono { - font-family: monospace; + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; } } &__config-reset { font-size: 11px; - padding: 3px 8px; - opacity: 0.6; + padding: 6px 10px; + background: transparent; + color: $swarm-muted; + border: 1px solid $swarm-border; + border-radius: 8px; + font-family: inherit; + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: $swarm-text; + } } &__config-actions { display: flex; justify-content: flex-end; gap: 8px; + margin-top: 2px; } &__config-apply { - font-size: 11px; - padding: 3px 10px; + font-size: 12px; + font-weight: 600; + padding: 8px 16px; + background: linear-gradient(135deg, $swarm-orange 0%, $swarm-rose 100%); + color: #fff; + border: none; + border-radius: 10px; + font-family: inherit; + cursor: pointer; + box-shadow: 0 6px 16px -6px rgba(247, 104, 8, 0.5); + transition: + transform 0.08s ease, + opacity 0.15s ease; + + &:hover { + opacity: 0.94; + } + + &:active { + transform: scale(0.98); + } } &__doc { @@ -138,3 +310,14 @@ overflow: hidden; } } + +@keyframes session-view-slide { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/app/components/SessionView/SessionView.tsx b/src/app/components/SessionView/SessionView.tsx index ad5014b..8b07421 100644 --- a/src/app/components/SessionView/SessionView.tsx +++ b/src/app/components/SessionView/SessionView.tsx @@ -2,13 +2,15 @@ import { PrivateKey } from '@ethersphere/bee-js' import { createBroadcastChannelTransport, createSwarmFeedTransport, + createSwarmPubSubTransport, createSwarmRtcTransport, createWakuTransport, createYWebrtcTransport, DocSettings, PLACEHOLDER_STAMP, } from 'lib' -import React, { useMemo, useState } from 'react' +import { Copy, FileText, LogOut, RefreshCw, Settings, Users } from 'lucide-react' +import React, { ReactNode, useCallback, useMemo, useState } from 'react' import { useSwarmDoc } from '../../hooks/useSwarmDoc' import { @@ -69,11 +71,11 @@ export const SessionView: React.FC = ({ const docConfig: DocSettings = useMemo(() => { const getTransport = () => { - if (session.transport === Transport.BROADCAST) { + if (session.transport === Transport.BROADCAST_CHANNEL) { return createBroadcastChannelTransport() } - if (session.transport === Transport.SWARM) { + if (session.transport === Transport.SWARM_FEED_POLL) { return createSwarmFeedTransport(beeUrl, signer.toHex(), mutableStamp, topic) } @@ -87,6 +89,10 @@ export const SessionView: React.FC = ({ return createWakuTransport(wakuAddress) } + if (session.transport === Transport.SWARM_PUBSUB) { + return createSwarmPubSubTransport(session.brokerPeer ?? '') + } + if (session.signalingUrl) { return createYWebrtcTransport(session.signalingUrl) } @@ -114,6 +120,7 @@ export const SessionView: React.FC = ({ } }, [ session.username, + session.brokerPeer, session.transport, session.signalingUrl, session.stunUrl, @@ -144,29 +151,54 @@ export const SessionView: React.FC = ({ ) } + const memberList = useCallback((): ReactNode | null => { + if (!members) return null + + const block: ReactNode[] = [] + + for (const [addr, username] of members) { + block.push( + + , + ) + } + + return block + }, [members]) + return (
{/* Header */}
- {session.username} - {session.pubKey} + + {session.username} + + {session.pubKey.slice(0, 8)} + {transportLabel} - {members.length > 0 && ( + {members && members.size > 0 && (
- {members.map(m => ( - - {m.slice(0, 8)}… - - ))} + + + {members.size} + + {memberList()}
)} @@ -175,7 +207,8 @@ export const SessionView: React.FC = ({ className="session-view__btn session-view__btn--refresh" title="Re-read member list" > - Refresh Members + + Refresh -
diff --git a/src/app/hooks/useSession.ts b/src/app/hooks/useSession.ts index 22cbc25..9ac2776 100644 --- a/src/app/hooks/useSession.ts +++ b/src/app/hooks/useSession.ts @@ -1,7 +1,8 @@ import { getSigner, uuidV4 } from 'lib' import { useState } from 'react' -import { SESSION_KEY } from '../utils/constants' +import { SESSION_KEY, TRANSPORT_KEY, USERNAME_KEY } from '../utils/constants' +import { loadSession } from '../utils/localStorage' import { Session, Transport } from '../utils/types' function createSession( @@ -11,6 +12,7 @@ function createSession( signalingUrl?: string, stunUrl?: string, wakuAddress?: string, + brokerPeer?: string, ): Session { const existing = loadSession() @@ -24,6 +26,7 @@ function createSession( signalingUrl, stunUrl, wakuAddress, + brokerPeer, } } @@ -38,16 +41,7 @@ function createSession( signalingUrl, stunUrl, wakuAddress, - } -} - -function loadSession(): Session | null { - try { - const raw = localStorage.getItem(SESSION_KEY) - - return raw ? (JSON.parse(raw) as Session) : null - } catch { - return null + brokerPeer, } } @@ -61,9 +55,12 @@ export function useSession() { signalingUrl?: string, stunUrl?: string, wakuAddress?: string, + brokerPeer?: string, ) => { - const s = createSession(username, transport, topic, signalingUrl, stunUrl, wakuAddress) + const s = createSession(username, transport, topic, signalingUrl, stunUrl, wakuAddress, brokerPeer) localStorage.setItem(SESSION_KEY, JSON.stringify(s)) + localStorage.setItem(TRANSPORT_KEY, transport) + localStorage.setItem(USERNAME_KEY, username) setSession(s) } diff --git a/src/app/hooks/useSwarmDoc.tsx b/src/app/hooks/useSwarmDoc.tsx index 2d1286e..7921a10 100644 --- a/src/app/hooks/useSwarmDoc.tsx +++ b/src/app/hooks/useSwarmDoc.tsx @@ -5,7 +5,7 @@ import * as Y from 'yjs' export interface SwarmDocContext { doc: Y.Doc | null error: Error | null - members: string[] + members: Map | null connected: boolean refreshMemberList: () => void dismissError: () => void @@ -16,11 +16,11 @@ export const useSwarmDoc = ({ user, infra }: DocSettings): SwarmDocContext => { const [doc, setDoc] = useState(null) const [{ error, members, connected }, setStatus] = useState<{ error: Error | null - members: string[] + members: Map | null connected: boolean }>({ error: null, - members: [], + members: null, connected: false, }) @@ -38,7 +38,9 @@ export const useSwarmDoc = ({ user, infra }: DocSettings): SwarmDocContext => { docRef.current = swarmDoc swarmDoc.getEmitter().on(DOC_EVENTS.DOC_ERROR, (err: Error) => setStatus(s => ({ ...s, error: err }))) - swarmDoc.getEmitter().on(DOC_EVENTS.MEMBERS_UPDATED, (m: string[]) => setStatus(s => ({ ...s, members: m }))) + swarmDoc + .getEmitter() + .on(DOC_EVENTS.MEMBERS_UPDATED, (m: Map) => setStatus(s => ({ ...s, members: m }))) swarmDoc.getEmitter().on(DOC_EVENTS.PEERS_CONNECTED, () => setStatus(s => ({ ...s, connected: true }))) swarmDoc.start() @@ -48,12 +50,12 @@ export const useSwarmDoc = ({ user, infra }: DocSettings): SwarmDocContext => { swarmDoc.stop() docRef.current = null setDoc(null) - setStatus({ error: null, members: [], connected: false }) + setStatus({ error: null, members: null, connected: false }) } }, [user, infra]) - const refreshMemberList = () => { - docRef.current?.refreshMemberList() + const refreshMemberList = async () => { + await docRef.current?.refreshMemberList() } return { doc, error, members, connected, refreshMemberList, dismissError } diff --git a/src/app/main.tsx b/src/app/main.tsx index 132d1da..cde8140 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import TestPage from './pages/TestPage' +// TODO: react strict mode closes the WS connection const root = createRoot(document.getElementById('root') as HTMLElement) root.render( diff --git a/src/app/pages/TestPage.tsx b/src/app/pages/TestPage.tsx index d6c8e77..a06b563 100644 --- a/src/app/pages/TestPage.tsx +++ b/src/app/pages/TestPage.tsx @@ -3,23 +3,36 @@ import React, { useCallback, useState } from 'react' import { LoginView } from '../components/LoginView/LoginView' import { SessionView } from '../components/SessionView/SessionView' import { useSession } from '../hooks/useSession' -import { DISABLE_UNTIL_CONNECTED_KEY } from '../utils/constants' -import { loadBeeUrl, loadDisableUntilConnected, loadMutableStamp, loadTopic } from '../utils/localStorage' +import { DISABLE_UNTIL_CONNECTED_KEY, TOPIC_KEY, TRANSPORT_KEY } from '../utils/constants' +import { loadBeeUrl, loadDisableUntilConnected, loadMutableStamp, loadTopic, loadUsername } from '../utils/localStorage' import { Transport } from '../utils/types' const TestPage: React.FC = () => { const { session, login, logout } = useSession() + + const docIdParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('doc') : null + const transportParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('trans') : null + + if (transportParam) { + localStorage.setItem(TRANSPORT_KEY, transportParam) + } + + if (docIdParam) { + localStorage.setItem(TOPIC_KEY, docIdParam) + } + const [beeUrl, setBeeUrl] = useState(loadBeeUrl()) const [topic, setTopic] = useState(loadTopic()) const [mutableStamp, setMutableStamp] = useState(loadMutableStamp()) + const [username, setUsername] = useState(loadUsername()) const [isLoggedIn, setIsLoggedIn] = useState(false) const [disableUntilConnected, setDisableUntilConnected] = useState( - session?.transport !== Transport.SWARM ? loadDisableUntilConnected() : false, + session?.transport !== Transport.SWARM_FEED_POLL ? loadDisableUntilConnected() : false, ) const handleDisableUntilConnectedChange = useCallback( (v: boolean) => { - if (session?.transport !== Transport.SWARM) { + if (session?.transport !== Transport.SWARM_FEED_POLL) { setDisableUntilConnected(v) localStorage.setItem(DISABLE_UNTIL_CONNECTED_KEY, String(v)) } @@ -30,7 +43,7 @@ const TestPage: React.FC = () => { if (!isLoggedIn || !session) { return ( { onMutableStampChange={setMutableStamp} onTopicChange={setTopic} onDisableUntilConnectedChange={handleDisableUntilConnectedChange} - onLogin={(username, transport, topic, signalingUrl, stunUrl, wakuAddress) => { - login(username, transport, topic, signalingUrl, stunUrl, wakuAddress) + onLogin={(username, transport, topic, signalingUrl, stunUrl, wakuAddress, brokerPeer) => { + login(username, transport, topic, signalingUrl, stunUrl, wakuAddress, brokerPeer) + setUsername(username) setIsLoggedIn(true) }} /> diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index 2025b8a..8553537 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -1,12 +1,16 @@ export const SESSION_KEY = 'test_session' +export const USERNAME_KEY = 'username' export const TOPIC_KEY = 'topic' +export const TRANSPORT_KEY = 'transport' export const BEE_URL_KEY = 'bee_url' export const MUTABLE_STAMP_KEY = 'mutable_stamp' export const SIGNALING_URL_KEY = 'signaling_url' export const STUN_URL_KEY = 'stun_url' export const WAKU_ADDRESS_KEY = 'waku_address' +export const BROKER_PEER_KEY = 'broker_peer' export const DISABLE_UNTIL_CONNECTED_KEY = 'disable_until_connected' export const DEFAULT_ICE_SERVER_URL = 'stun:stun.l.google.com:19302' export const DEFAULT_SIGNALING_SERVER_URL = 'ws://localhost:4444' export const DEFAULT_BEE_API_URL = 'http://localhost:1633' export const DEFAULT_TOPIC = 'test-topic-1' +export const DEFAULT_BROKER_PEER = '/ip4/111.111.111.111/tcp/1634/p2p/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' diff --git a/src/app/utils/localStorage.ts b/src/app/utils/localStorage.ts index e7327db..7894fb4 100644 --- a/src/app/utils/localStorage.ts +++ b/src/app/utils/localStorage.ts @@ -1,25 +1,44 @@ -import { PLACEHOLDER_STAMP } from 'lib' +import { PLACEHOLDER_STAMP, uuidV4 } from 'lib' import { BEE_URL_KEY, + BROKER_PEER_KEY, DEFAULT_BEE_API_URL, + DEFAULT_BROKER_PEER, DEFAULT_ICE_SERVER_URL, DEFAULT_SIGNALING_SERVER_URL, - DEFAULT_TOPIC, DISABLE_UNTIL_CONNECTED_KEY, MUTABLE_STAMP_KEY, + SESSION_KEY, SIGNALING_URL_KEY, STUN_URL_KEY, TOPIC_KEY, + TRANSPORT_KEY, + USERNAME_KEY, WAKU_ADDRESS_KEY, } from './constants' +import { Session, Transport } from './types' + +export function loadSession(): Session | null { + try { + const raw = localStorage.getItem(SESSION_KEY) + + return raw ? (JSON.parse(raw) as Session) : null + } catch { + return null + } +} export function loadBeeUrl(): string { return localStorage.getItem(BEE_URL_KEY) ?? DEFAULT_BEE_API_URL } +export function loadUsername(): string { + return localStorage.getItem(USERNAME_KEY) ?? '' +} + export function loadTopic(): string { - return localStorage.getItem(TOPIC_KEY) ?? DEFAULT_TOPIC + return localStorage.getItem(TOPIC_KEY) ?? uuidV4() } export function loadMutableStamp(): string { @@ -34,10 +53,18 @@ export function loadStunUrl(): string { return localStorage.getItem(STUN_URL_KEY) ?? DEFAULT_ICE_SERVER_URL } +export function loadTransport(): Transport { + return (localStorage.getItem(TRANSPORT_KEY) as Transport) ?? Transport.SWARM_PUBSUB +} + export function loadWakuAddress(): string { return localStorage.getItem(WAKU_ADDRESS_KEY) || '' } +export function loadBrokerPeer(): string { + return localStorage.getItem(BROKER_PEER_KEY) || DEFAULT_BROKER_PEER +} + export function loadDisableUntilConnected(): boolean { return localStorage.getItem(DISABLE_UNTIL_CONNECTED_KEY) === 'true' } diff --git a/src/app/utils/types.ts b/src/app/utils/types.ts index 7c60aae..43cf1c1 100644 --- a/src/app/utils/types.ts +++ b/src/app/utils/types.ts @@ -1,8 +1,9 @@ export enum Transport { - SWARM = 'swarm', - BROADCAST = 'broadcast', + SWARM_FEED_POLL = 'swarm-feed-poll', + BROADCAST_CHANNEL = 'broadcast', WEBRTC = 'webrtc', WAKU = 'waku', + SWARM_PUBSUB = 'swarm-pubsub', } export interface Session { @@ -14,16 +15,18 @@ export interface Session { signalingUrl?: string stunUrl?: string wakuAddress?: string + brokerPeer?: string } export const TRANSPORT_LABELS: Record = { - [Transport.SWARM]: 'Swarm Feed', - [Transport.BROADCAST]: 'BroadcastChannel', + [Transport.SWARM_FEED_POLL]: 'Swarm Feed', + [Transport.BROADCAST_CHANNEL]: 'BroadcastChannel', [Transport.WEBRTC]: 'WebRTC', [Transport.WAKU]: 'Waku', + [Transport.SWARM_PUBSUB]: 'Swarm Pubsub', } export enum WebrtcMode { - SIGNALING = 'signaling', - SWARM = 'swarm', + SIGNALING_SERVER = 'signaling-server', + SWARM_SIGNAL_FEED = 'swarm-singal-feed', } diff --git a/src/lib/doc/doc.ts b/src/lib/doc/doc.ts index 786bdc7..b843168 100644 --- a/src/lib/doc/doc.ts +++ b/src/lib/doc/doc.ts @@ -8,7 +8,7 @@ import { } from '@solarpunkltd/comment-system' import * as Y from 'yjs' -import { DocSettings, DocTransport } from '../interfaces' +import { DocSettings, DocTransport, NotificationHandler, NotificationPayload } from '../interfaces' import { validateStamps } from '../utils/bee' import { decode, encode, indexStrToBigint, remove0x, retryAwaitableAsync, uuidV4 } from '../utils/common' import { API_VERSION, DOC_FEED_SUFFIX, JOIN_FEED_INDEX, PLACEHOLDER_STAMP } from '../utils/constants' @@ -47,6 +47,7 @@ export class SwarmDoc { private emitter: EventEmitter private signer: PrivateKey private ownAddress: string + private username: string private ownIndex: bigint = -1n private docFeedId: string private docTopic: string @@ -67,6 +68,7 @@ export class SwarmDoc { this.signer = new PrivateKey(remove0x(settings.user.privateKey)) this.ownAddress = this.signer.publicKey().address().toString() + this.username = settings.user.nickname this.beeApiUrl = settings.infra.beeUrl this.mutableStampId = settings.infra.mutableStamp || PLACEHOLDER_STAMP @@ -75,14 +77,15 @@ export class SwarmDoc { this.members = new Members(this.docFeedId, this.beeApiUrl, this.mutableStampId) - const members = (settings.infra.members || []) - .map(addr => remove0x(addr.toLowerCase())) - .filter(addr => addr !== this.ownAddress) + const configuredMembers = settings.infra.members + ? Array.from(settings.infra.members.entries()).map(([addr, username]) => [remove0x(addr.toLowerCase()), username]) + : [] + const members = configuredMembers.filter(([addr]) => addr !== this.ownAddress) console.log(`${TAG} ownAddress: ${this.ownAddress}`) console.log(`${TAG} feedNamespace: ${this.docFeedId}`) console.log(`${TAG} topic identifier: ${Topic.fromString(this.docFeedId + this.ownAddress).toString()}`) - console.log(`${TAG} members configured: ${members.length === 0 ? '(none)' : members.join(', ')}`) + console.log(`${TAG} members configured: ${members.length === 0 ? '(none)' : members.map(m => m).join(', ')}`) console.log(`${TAG} mutable stamp: ${this.mutableStampId}`) this.transport = settings.infra.transport({ @@ -91,8 +94,8 @@ export class SwarmDoc { members: this.members, ownAddress: this.ownAddress, nickname: settings.user.nickname, - onPeerDiscovered: (address: string) => { - this.registerMember(address) + onPeerDiscovered: (address: string, username: string) => { + this.registerMember(address, username) this.emitter.emit(DOC_EVENTS.MEMBERS_UPDATED, this.members.all()) this.fetchLatestFromMember(address) }, @@ -102,8 +105,8 @@ export class SwarmDoc { mutableStampId: this.mutableStampId, }) - for (const memberAddress of members) { - this.registerMember(memberAddress) + for (const [memberAddress, memberUsername] of members) { + this.registerMember(memberAddress, memberUsername) } } @@ -129,8 +132,8 @@ export class SwarmDoc { } // Register a peer address so we can read their doc feed. No-op if already registered. - private registerMember(address: string): void { - if (!this.members.register(address)) { + private registerMember(address: string, username: string): void { + if (!this.members.register(address, username)) { return } @@ -226,9 +229,10 @@ export class SwarmDoc { `${TAG} publishSnapshot → index: ${nextIndex}, snapshot: ${(snapshot.length * 0.75) | 0}B, delta: ${(delta.length * 0.75) | 0}B`, ) + // TODO: sign messages ? const messageObj: MessageData = { id: uuidV4(), - username: this.ownAddress, + username: this.username, address: this.ownAddress, topic: this.docTopic, signature: '', @@ -246,6 +250,7 @@ export class SwarmDoc { v: API_VERSION, topic: this.docTopic, author: this.ownAddress, + username: this.username, feedIndex: Number(nextIndex), delta, }) @@ -281,7 +286,7 @@ export class SwarmDoc { console.log(`${TAG} init: done — ownIndex: ${this.ownIndex}`) // No peers at init time — editor is immediately usable, no WebRTC handshake needed - if (this.members.all().length === 0) { + if (this.members.all().size === 0) { this.emitter.emit(DOC_EVENTS.PEERS_CONNECTED, true) } } @@ -309,9 +314,11 @@ export class SwarmDoc { private async initMemberList(): Promise { // Register own address in the consensus memberList, then fetch latest state // from every peer listed there (merged with any statically configured members). - const membersList = await this.members.add(this.ownAddress) - for (const addr of membersList) { - if (addr !== this.ownAddress) this.registerMember(addr) + const membersList = await this.members.add(this.ownAddress, this.username) + for (const [addr, username] of membersList) { + if (addr !== this.ownAddress) { + this.registerMember(addr, username) + } } this.emitter.emit(DOC_EVENTS.MEMBERS_UPDATED, this.members.all()) @@ -321,13 +328,16 @@ export class SwarmDoc { v: API_VERSION, topic: this.docTopic, author: this.ownAddress, + username: this.username, feedIndex: Number(JOIN_FEED_INDEX), }) console.log(`${TAG} initMemberList: join notification sent`) const members = this.members.all() - console.log(`${TAG} initMemberList: ${members.length} peer(s) to fetch`) - await Promise.allSettled(members.map(addr => this.fetchLatestFromMember(addr))) + console.log(`${TAG} initMemberList: ${members.size} peer(s) to fetch`) + const memberPromises: Promise[] = [] + members.forEach((addr: string, _username: string) => memberPromises.push(this.fetchLatestFromMember(addr))) + await Promise.allSettled(memberPromises) } // Dispatcher: routes to the fast (delta) or slow (Swarm fetch) path. @@ -408,11 +418,20 @@ export class SwarmDoc { public async refreshMemberList(): Promise { try { const members = await this.members.read() - console.log(`${TAG} refreshMemberList: got [${members.join(', ')}]`) + + console.log(`${TAG} refreshMemberList members: `, members) + + if (!members || members.size === 0 || Object.keys(members).length === 0) { + console.log(`${TAG} refreshMemberList: empty member list`) + + return + } + + console.log(`${TAG} refreshMemberList: got [${Array.from(Object.keys(members)).join(', ')}]`) let changed = false - for (const addr of members) { + for (const [addr, username] of members) { if (addr !== this.ownAddress && !this.members.has(addr)) { - this.registerMember(addr) + this.registerMember(addr, username) this.fetchLatestFromMember(addr) changed = true } @@ -432,10 +451,15 @@ export class SwarmDoc { this.memberListPollTimer = setInterval(async () => { try { const members = await this.members.read() + + if (!members) { + return + } + let changed = false - for (const addr of members) { + for (const [addr, username] of members) { if (addr !== this.ownAddress && !this.members.has(addr)) { - this.registerMember(addr) + this.registerMember(addr, username) this.fetchLatestFromMember(addr) changed = true } @@ -450,10 +474,13 @@ export class SwarmDoc { private startFetchProcess(): void { if (this.fetchProcessRunning) return + this.fetchProcessRunning = true + console.log(`${TAG} subscribing to topic: ${this.docTopic}`) - console.log(`${TAG} known members: ${this.members.all().join(', ') || '(none)'}`) - this.transport.subscribe(this.docTopic, payload => { + console.log(`${TAG} known members: ${Array.from(Object.keys(this.members.all())).join(', ') || '(none)'}`) + + const handler: NotificationHandler = (payload: NotificationPayload): void => { const author = remove0x(payload.author.toLowerCase()) if (author === this.ownAddress) return @@ -461,7 +488,7 @@ export class SwarmDoc { // JOIN_FEED_INDEX: join notification — register peer and fetch their latest snapshot if (payload.feedIndex === Number(JOIN_FEED_INDEX)) { console.log(`${TAG} notification: join from ${author.slice(0, 8)}…`) - this.registerMember(author) + this.registerMember(author, payload.username) this.emitter.emit(DOC_EVENTS.MEMBERS_UPDATED, this.members.all()) this.fetchLatestFromMember(author) @@ -472,6 +499,8 @@ export class SwarmDoc { `${TAG} notification: author=${author.slice(0, 8)}…, feedIndex=${payload.feedIndex}, hasDelta=${Boolean(payload.delta)}`, ) this.fetchLatestFromMember(author, BigInt(payload.feedIndex), payload.delta) - }) + } + + this.transport.subscribe(this.docTopic, handler) } } diff --git a/src/lib/doc/events.ts b/src/lib/doc/events.ts index cf16ac9..395b698 100644 --- a/src/lib/doc/events.ts +++ b/src/lib/doc/events.ts @@ -4,7 +4,7 @@ * ```ts * swarmDoc.getEmitter().on(DOC_EVENTS.DOC_UPDATED, (doc: Y.Doc) => ...) * swarmDoc.getEmitter().on(DOC_EVENTS.DOC_ERROR, (err: Error) => ...) - * swarmDoc.getEmitter().on(DOC_EVENTS.MEMBERS_UPDATED, (members: string[]) => ...) + * swarmDoc.getEmitter().on(DOC_EVENTS.MEMBERS_UPDATED, (members: Map) => ...) * swarmDoc.getEmitter().on(DOC_EVENTS.PEERS_CONNECTED, (connected: true) => ...) * ``` */ @@ -13,7 +13,7 @@ export const DOC_EVENTS = { DOC_UPDATED: 'docUpdated', /** Fired on stamp validation failure or publish error. Payload: `Error`. */ DOC_ERROR: 'docError', - /** Fired when the peer list changes. Payload: `string[]` (Ethereum addresses). */ + /** Fired when the peer list changes. Payload: `Map` (Ethereum address and username pairs). */ MEMBERS_UPDATED: 'membersUpdated', /** Fired once when the transport has at least one connected peer. Payload: `true`. */ PEERS_CONNECTED: 'peersConnected', diff --git a/src/lib/doc/members.ts b/src/lib/doc/members.ts index e35734e..77c8f6a 100644 --- a/src/lib/doc/members.ts +++ b/src/lib/doc/members.ts @@ -30,9 +30,9 @@ export class Members { // Swarm consensus state private currentIndex: bigint = -1n - // Local session tracking - private readonly addresses: Set = new Set() - private readonly indices: Map = new Map() + // Local session tracking: address - username mapping + private readonly members: Map = new Map() + private readonly indices: Map = new Map() constructor(rawTopic: string, beeUrl: string, stamp: string) { const memberFeedId = Topic.fromString(rawTopic + MEMBERS_FEED_SUFFIX).toString() @@ -50,9 +50,10 @@ export class Members { * Adds `address` to the local peer set. * @returns `true` if the address was newly added, `false` if already present. */ - register(address: string): boolean { - if (this.addresses.has(address)) return false - this.addresses.add(address) + register(address: string, username: string): boolean { + if (this.members.has(address)) return false + + this.members.set(address, username) this.indices.set(address, -1n) return true @@ -60,12 +61,12 @@ export class Members { /** Returns `true` if `address` is in the local peer set. */ has(address: string): boolean { - return this.addresses.has(address) + return this.members.has(address) } - /** Returns all registered peer addresses. */ - all(): string[] { - return [...this.addresses] + /** Returns a shallow copy of the registered peer map. */ + all(): ReadonlyMap { + return new Map(this.members) } /** Returns the last feed index applied from this peer, or -1n if none yet. */ @@ -82,19 +83,21 @@ export class Members { /** * Reads the current member list from the Swarm consensus feed. - * @returns Array of Ethereum addresses, or `[]` if the feed does not exist yet. + * @returns Map of Ethereum addresses and usernames or null if the feed does not exist yet. */ - async read(): Promise { + async read(): Promise | null> { try { const reader = this.bee.makeFeedReader(this.topic, this.address) const result = await reader.downloadPayload() this.currentIndex = result.feedIndex.toBigInt() - return JSON.parse(result.payload.toUtf8()) as string[] + const parsed = JSON.parse(result.payload.toUtf8()) as Record + + return new Map(Object.entries(parsed)) } catch (err) { if (!isNotFoundError(err)) this.errorHandler.handleError(err, `${TAG}.read`) - return [] + return null } } @@ -103,34 +106,40 @@ export class Members { * Verifies by reading back the specific index to detect last-write-wins conflicts. * Returns the confirmed list, or the optimistic list if verification times out. */ - async add(address: string): Promise { + async add(address: string, username: string): Promise> { const normalizedAddress = remove0x(address.toLowerCase()) const reader = this.bee.makeFeedReader(this.topic, this.address) // Always read latest — another peer may have added a member since our last write - let members: string[] = [] + let members: Map = new Map() try { const result = await reader.downloadPayload() - members = JSON.parse(result.payload.toUtf8()) as string[] - this.currentIndex = result.feedIndex.toBigInt() + + if (result.payload.toUtf8().length) { + const parsed = JSON.parse(result.payload.toUtf8()) as Record + members = new Map(Object.entries(parsed)) + this.currentIndex = result.feedIndex.toBigInt() + } } catch (err) { if (!isNotFoundError(err)) this.errorHandler.handleError(err, `${TAG}.add read`) // Not found → fresh list, start at index 0 } - if (members.includes(normalizedAddress)) { + if (members.has(normalizedAddress)) { console.log(`${TAG} add: ${normalizedAddress.slice(0, 8)}… already in list`) return members } - const nextMembers = [...members, normalizedAddress] + members.set(normalizedAddress, username) const nextIndex = this.currentIndex === -1n ? 0n : this.currentIndex + 1n - console.log(`${TAG} add: writing index ${nextIndex}, total: ${nextMembers.length}`) + console.log( + `${TAG} add: writing index ${nextIndex}, total: ${members.size}, members: ${JSON.stringify(Object.fromEntries(members))}`, + ) const writer = this.bee.makeFeedWriter(this.topic, this.signer) try { - await writer.uploadPayload(this.stamp, JSON.stringify(nextMembers), { + await writer.uploadPayload(this.stamp, JSON.stringify(Object.fromEntries(members)), { index: FeedIndex.fromBigInt(nextIndex), deferred: false, }) @@ -146,19 +155,20 @@ export class Members { const verified = await retryAwaitableAsync( async () => { const r = await reader.downloadPayload({ index: FeedIndex.fromBigInt(nextIndex) }) + const parsed = JSON.parse(r.payload.toUtf8()) as Record - return JSON.parse(r.payload.toUtf8()) as string[] + return new Map(Object.entries(parsed)) }, 3, 500, ) - console.log(`${TAG} add: verified — ${verified.join(', ')}`) + console.log(`${TAG} add: verified — ${Array.from(verified.keys()).join(', ')}`) return verified } catch { console.log(`${TAG} add: verify timed out, using optimistic list`) - return nextMembers + return members } } } diff --git a/src/lib/index.ts b/src/lib/index.ts index 0763266..43a6468 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -9,6 +9,7 @@ export { createYWebrtcTransport } from './notification/yWebrtcTransport' export { createBroadcastChannelTransport } from './notification/broadcastChannel' export { createSwarmFeedTransport } from './notification/swarmFeed' export { createWakuTransport } from './notification/wakuTransport' +export { createSwarmPubSubTransport } from './notification/swarmPubSubTransport' export type { DocSettings } from './interfaces' export type { NotificationPayload, NotificationHandler } from './interfaces' diff --git a/src/lib/interfaces/docTransport.ts b/src/lib/interfaces/docTransport.ts index d05aa6a..8f24e00 100644 --- a/src/lib/interfaces/docTransport.ts +++ b/src/lib/interfaces/docTransport.ts @@ -45,7 +45,7 @@ export interface DocTransportDeps { /** Read-only accessor for the current peer set and per-peer feed index state. */ members: { /** Returns all registered peer addresses. */ - all(): string[] + all(): ReadonlyMap /** Returns `true` if `address` is in the registered set. */ has(address: string): boolean /** Returns the last Swarm feed index applied from `address`, or `-1n` if none. */ @@ -61,7 +61,7 @@ export interface DocTransportDeps { * Called when the transport discovers a peer not yet in the member set. * The transport should invoke this on awareness events or similar peer-discovery signals. */ - onPeerDiscovered: (address: string) => void + onPeerDiscovered: (address: string, username: string) => void /** Topic namespace used to derive per-user Swarm feed identifiers. Swarm transports only. */ docFeedId: string /** Bee node HTTP API URL. Swarm transports only. */ diff --git a/src/lib/interfaces/notification.ts b/src/lib/interfaces/notification.ts index 2adb26b..36ab3f2 100644 --- a/src/lib/interfaces/notification.ts +++ b/src/lib/interfaces/notification.ts @@ -6,6 +6,8 @@ export interface NotificationPayload { topic: string /** Ethereum address of the publishing peer (hex, no 0x prefix). */ author: string + /** Nickname of the publishing peer */ + username: string /** * Swarm doc-feed index written by the author. * Set to `JOIN_FEED_INDEX` (-1) to signal a peer join event (no doc update). diff --git a/src/lib/interfaces/settings.ts b/src/lib/interfaces/settings.ts index f40497b..a6f8813 100644 --- a/src/lib/interfaces/settings.ts +++ b/src/lib/interfaces/settings.ts @@ -22,8 +22,8 @@ export interface DocSettings { mutableStamp?: string /** Shared room identifier. All peers in the same room must use the same `topic`. */ topic: string - /** Pre-seeded peer Ethereum addresses. Merged with the Swarm consensus member list at init. */ - members?: string[] + /** Pre-seeded peer Ethereum addresses with usernames. Merged with the Swarm consensus member list at init. */ + members?: Map /** * Transport factory. Determines how peers exchange notifications and/or sync state. * Use one of the built-in factories: diff --git a/src/lib/notification/swarmPubSubTransport.ts b/src/lib/notification/swarmPubSubTransport.ts new file mode 100644 index 0000000..b468d3b --- /dev/null +++ b/src/lib/notification/swarmPubSubTransport.ts @@ -0,0 +1,140 @@ +import { Bee, PubsubMode, PubsubSubscription } from '@ethersphere/bee-js' + +import { DOC_EVENTS } from '../doc/events' +import type { DocTransport, DocTransportDeps, DocTransportFactory } from '../interfaces/docTransport' +import type { NotificationHandler, NotificationPayload } from '../interfaces/notification' +import { ErrorHandler } from '../utils/error' + +const TAG = 'SwarmNotifTransport' +const WS_RECONNECT_TIMEOUT_MS = 10_000 + +class SwarmPubSubDocTransport implements DocTransport { + private errorHandler = ErrorHandler.getInstance() + private subscription: PubsubSubscription | null = null + private stopped = false + private isConnecting = false + private isConnected = false + + private handler: NotificationHandler | null = null + private pendingPublishes: NotificationPayload[] = [] + + constructor( + private readonly deps: DocTransportDeps, + private readonly brokerPeer: string, + ) {} + + start(): void { + this.stopped = false + this.connect() + } + + stop(): void { + this.stopped = true + this.isConnecting = false + this.isConnected = false + this.subscription?.cancel() + this.subscription = null + } + + subscribe(_topic: string, handler: NotificationHandler): void { + this.handler = handler + } + + publish(payload: NotificationPayload): void { + if (this.isConnected) { + this.sendPayload(payload).catch(err => this.errorHandler.handleError(err, `${TAG}.sendPayload`)) + } else { + this.pendingPublishes.push(payload) + } + } + + connectToPeer(_address: string): void {} + + isRemoteOrigin(_origin: unknown): boolean { + return false + } + + private connect(): void { + if (this.stopped || this.isConnecting) return + + this.isConnecting = true + + const bee = new Bee(this.deps.beeApiUrl) + + const subscription = bee.pubsubConnect( + PubsubMode.GSOC_EPHEMERAL, + { + onOpen: _sub => { + this.isConnecting = false + this.isConnected = true + this.deps.emitter.emit(DOC_EVENTS.PEERS_CONNECTED, true) + console.log(`${TAG} connected, topicAddress derived from docFeedId=${this.deps.docFeedId}`) + + // Drain buffered publishes now that the WebSocket is open + const toSend = this.pendingPublishes.splice(0) + for (const payload of toSend) { + this.sendPayload(payload).catch(err => this.errorHandler.handleError(err, `${TAG}.sendPayload`)) + } + }, + onMessage: (message, _sub) => { + if (!this.handler) return + + try { + const text = new TextDecoder().decode(message.toUint8Array()) + const payload = JSON.parse(text) as NotificationPayload + this.handler(payload) + } catch (err) { + this.errorHandler.handleError(err, `${TAG}.onMessage`) + } + }, + onError: (err, _sub) => { + if (!this.stopped) { + this.errorHandler.handleError(err, `${TAG}.onError`) + this.isConnecting = false + this.isConnected = false + } + }, + onClose: _sub => { + if (!this.stopped) { + console.warn(`${TAG} connection closed, reconnecting…`) + this.subscription = null + this.isConnecting = false + this.isConnected = false + setTimeout(() => this.connect(), WS_RECONNECT_TIMEOUT_MS) + } + }, + }, + this.brokerPeer, + { topic: this.deps.docFeedId }, + ) + + this.subscription = subscription + } + + private async sendPayload(payload: NotificationPayload): Promise { + if (!this.subscription) return + + const text = JSON.stringify(payload) + await this.subscription.send(text) + } +} + +/** + * Creates a `DocTransportFactory` using Swarm's GSOC pubsub for real-time notifications. + * + * Connects to the local Bee node's pubsub WebSocket endpoint and subscribes to a + * content topic derived deterministically from the doc's feed ID using + * `PubsubMode.GSOC_EPHEMERAL` (keccak256 of the topic string → ephemeral key → SOC address). + * All peers using the same topic string subscribe to the same address, enabling + * bidirectional push delivery without polling. + * + * Connection is established immediately in `start()`. If the WebSocket closes unexpectedly + * it reconnects automatically after 3 seconds. `subscribe` and `publish` calls made before + * the connection is ready are buffered and drained on connect. + * + * @param brokerPeer Multiaddress of the Bee node acting as the GSOC pubsub broker. + * Example: `/ip4/1.2.3.4/tcp/1634/p2p/QmXxxx…` + */ +export function createSwarmPubSubTransport(brokerPeer: string): DocTransportFactory { + return (deps: DocTransportDeps) => new SwarmPubSubDocTransport(deps, brokerPeer) +} diff --git a/src/lib/notification/swarmRtcTransport.ts b/src/lib/notification/swarmRtcTransport.ts index 49d7b86..3a2bccf 100644 --- a/src/lib/notification/swarmRtcTransport.ts +++ b/src/lib/notification/swarmRtcTransport.ts @@ -267,16 +267,18 @@ class SwarmRtcTransport implements DocTransport { this.signalCheckInFlight = true const peers = this.deps.members.all() - if (peers.length === 0) { + if (peers.size === 0) { this.signalCheckInFlight = false return } - console.log(`${TAG} checking signals for ${peers.length} peer(s): ${peers.map(a => a.slice(0, 8)).join(', ')}`) + const peerAddrs = Array.from(peers.keys()) + + console.log(`${TAG} checking signals for ${peers.size} peer(s): ${peerAddrs.map(a => a.slice(0, 8)).join(', ')}`) try { - await Promise.allSettled(peers.map(addr => this.checkPeerSignals(addr))) + await Promise.allSettled(peerAddrs.map(addr => this.checkPeerSignals(addr))) } finally { this.signalCheckInFlight = false } diff --git a/src/lib/notification/swarmSignal.ts b/src/lib/notification/swarmSignal.ts index 9b3d8a0..631be3b 100644 --- a/src/lib/notification/swarmSignal.ts +++ b/src/lib/notification/swarmSignal.ts @@ -7,12 +7,6 @@ import { ErrorHandler } from '../utils/error' const TAG = 'SwarmSignal' -function isServerError(error: unknown): boolean { - if (!(error instanceof Error)) return false - - return error.message?.includes('500') || false -} - /** * Reads and writes WebRTC signaling records to a per-user Swarm mutable feed. * @@ -60,7 +54,7 @@ export class SwarmSignal { return JSON.parse(result.payload.toUtf8()) as SignalFeedPayload } catch (err) { - if (!isNotFoundError(err) && !isServerError(err)) { + if (!isNotFoundError(err)) { this.errorHandler.handleError(err, `${TAG}.read(${peerAddress.slice(0, 8)}…)`) } @@ -75,6 +69,7 @@ export class SwarmSignal { * Deduplication key: type + toAddress — only one active offer/answer per peer. * Enqueued so it never runs concurrently with clearOwn. */ + // eslint-disable-next-line require-await async writeRecord(record: SignalRecord): Promise { console.log( `${TAG} writeRecord called — type=${record.type} to=${record.toAddress.slice(0, 8)}… sessionId=${record.sessionId.slice(0, 8)} ts=${record.timestamp}`, @@ -92,6 +87,7 @@ export class SwarmSignal { /** Writes an empty payload to own feed, removing all stale records from the previous session. * Enqueued so it never runs concurrently with writeRecord. */ + // eslint-disable-next-line require-await async clearOwn(): Promise { console.log(`${TAG} clearOwn: starting`) this.writeQueue = this.writeQueue.then(async () => { @@ -117,7 +113,7 @@ export class SwarmSignal { return JSON.parse(result.payload.toUtf8()) as SignalFeedPayload } catch (err) { - if (!isNotFoundError(err) && !isServerError(err)) { + if (!isNotFoundError(err)) { this.errorHandler.handleError(err, `${TAG}.readOwn`) } diff --git a/src/lib/notification/yWebrtcTransport.ts b/src/lib/notification/yWebrtcTransport.ts index 4b8670c..c686f0a 100644 --- a/src/lib/notification/yWebrtcTransport.ts +++ b/src/lib/notification/yWebrtcTransport.ts @@ -38,7 +38,8 @@ class YWebrtcTransport implements DocTransport { if (!isSelf && address && address !== this.deps.ownAddress && !this.deps.members.has(address)) { console.log(`${TAG} awareness: new peer ${address.slice(0, 8)}…`) - this.deps.onPeerDiscovered(address) + // TODO: use username + this.deps.onPeerDiscovered(address, 'unknown') } } }) diff --git a/tsconfig.lib.json b/tsconfig.lib.json index ba618ab..6bc6960 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -8,4 +8,3 @@ "include": ["src/lib"], "exclude": ["node_modules", "dist", "src/app"] } -