mirror of
https://github.com/logos-messaging/lab.waku.org.git
synced 2026-01-03 22:33:09 +00:00
feat(sds): initial commit for sds demo
add legend explaining sds concepts add store queries for missed messages add history visualization partitioned and ordered by lamport timestamp matchmaking and message sending scenarios add a theme better theme support more than 2 participants
This commit is contained in:
parent
233623b136
commit
f6d477d534
25
examples/sds-demo/.gitignore
vendored
Normal file
25
examples/sds-demo/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
node_modules
|
||||
|
||||
.cursorrules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
examples/sds-demo/.npmrc
Normal file
1
examples/sds-demo/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
4
examples/sds-demo/.prettierignore
Normal file
4
examples/sds-demo/.prettierignore
Normal file
@ -0,0 +1,4 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
15
examples/sds-demo/.prettierrc
Normal file
15
examples/sds-demo/.prettierrc
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
examples/sds-demo/README.md
Normal file
38
examples/sds-demo/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
39
examples/sds-demo/eslint.config.js
Normal file
39
examples/sds-demo/eslint.config.js
Normal file
@ -0,0 +1,39 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import js from '@eslint/js';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default ts.config(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
ignores: ['eslint.config.js', 'svelte.config.js'],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
9250
examples/sds-demo/package-lock.json
generated
Normal file
9250
examples/sds-demo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
examples/sds-demo/package.json
Normal file
75
examples/sds-demo/package.json
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "sds-demo",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^3.2.6",
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@storybook/addon-essentials": "^8.6.11",
|
||||
"@storybook/addon-interactions": "^8.6.11",
|
||||
"@storybook/addon-links": "^8.6.11",
|
||||
"@storybook/addon-svelte-csf": "^5.0.0-next.0",
|
||||
"@storybook/blocks": "^8.6.11",
|
||||
"@storybook/experimental-addon-test": "^8.6.11",
|
||||
"@storybook/svelte": "^8.6.11",
|
||||
"@storybook/sveltekit": "^8.6.11",
|
||||
"@storybook/test": "^8.6.11",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/protobufjs": "^6.0.0",
|
||||
"@vitest/browser": "^3.1.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@waku/interfaces": "0.0.31-006cd41.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-storybook": "^0.12.0",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"playwright": "^1.51.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"storybook": "^8.6.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.7.1",
|
||||
"@tanstack/svelte-virtual": "^3.13.5",
|
||||
"@types/identicon.js": "^2.3.5",
|
||||
"@waku/message-hash": "0.1.20-006cd41.0",
|
||||
"@waku/sdk": "0.0.32-006cd41.0",
|
||||
"@waku/sds": "0.0.4-006cd41.0",
|
||||
"@waku/utils": "0.0.24-006cd41.0",
|
||||
"effect": "^3.14.1",
|
||||
"identicon.js": "^2.3.3",
|
||||
"protobufjs": "^7.4.0",
|
||||
"svelte-sonner": "^0.3.28"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"plugin:storybook/recommended"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
examples/sds-demo/src/app.css
Normal file
1
examples/sds-demo/src/app.css
Normal file
@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
13
examples/sds-demo/src/app.d.ts
vendored
Normal file
13
examples/sds-demo/src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
examples/sds-demo/src/app.html
Normal file
12
examples/sds-demo/src/app.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
247
examples/sds-demo/src/lib/components/ActionButtons.svelte
Normal file
247
examples/sds-demo/src/lib/components/ActionButtons.svelte
Normal file
@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import {
|
||||
startSending as startSendingHistory,
|
||||
subscribe,
|
||||
sweepIn,
|
||||
sweepOut
|
||||
} from '$lib/sds.svelte';
|
||||
import { wakuNode } from '$lib/waku/waku.svelte';
|
||||
import { getOrCreateChannel } from '$lib/sds/channel.svelte';
|
||||
import { send } from '$lib/waku/pingpong.svelte';
|
||||
import { randomBytes } from '@noble/hashes/utils';
|
||||
import { getMatch } from '$lib/utils/match.svelte';
|
||||
import type { MatchParams } from '$lib/waku/waku.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let match = $state<MatchParams | undefined>(undefined);
|
||||
let sendingInterval: NodeJS.Timeout | null = null;
|
||||
let isSending = false;
|
||||
let isListening = false;
|
||||
let messageOption = '1'; // Options: '1', '5', '10', 'continuous'
|
||||
let showDropdown = false;
|
||||
let sweepInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
function startSending() {
|
||||
match = getMatch();
|
||||
if (!match) {
|
||||
startSendingHistory();
|
||||
} else {
|
||||
const channel = getOrCreateChannel(match.matchId);
|
||||
send(channel, randomBytes(32));
|
||||
}
|
||||
}
|
||||
|
||||
function sweep() {
|
||||
sweepIn();
|
||||
sweepOut();
|
||||
}
|
||||
|
||||
// Set up periodic sweep on component mount
|
||||
onMount(() => {
|
||||
page
|
||||
match = getMatch();
|
||||
sweepInterval = setInterval(sweep, 5000); // Call sweep every 5 seconds
|
||||
return () => {
|
||||
if (sweepInterval) clearInterval(sweepInterval);
|
||||
};
|
||||
});
|
||||
|
||||
function toggleSending() {
|
||||
if (isSending) {
|
||||
// Stop sending
|
||||
if (sendingInterval) {
|
||||
clearInterval(sendingInterval);
|
||||
sendingInterval = null;
|
||||
}
|
||||
isSending = false;
|
||||
} else {
|
||||
// Handle different message count options
|
||||
if (messageOption === 'continuous') {
|
||||
// Start sending periodically (every 2 seconds)
|
||||
startSending(); // Send immediately once
|
||||
sendingInterval = setInterval(() => {
|
||||
startSending();
|
||||
}, 2000);
|
||||
isSending = true;
|
||||
} else {
|
||||
// Send a specific number of messages
|
||||
const count = parseInt(messageOption);
|
||||
let sent = 0;
|
||||
|
||||
const sendBatch = () => {
|
||||
startSending();
|
||||
sent++;
|
||||
|
||||
if (sent >= count) {
|
||||
clearInterval(sendingInterval!);
|
||||
sendingInterval = null;
|
||||
isSending = false;
|
||||
}
|
||||
};
|
||||
|
||||
sendBatch(); // Send first message immediately
|
||||
if (count > 1) {
|
||||
sendingInterval = setInterval(sendBatch, 2000);
|
||||
isSending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(option: string) {
|
||||
messageOption = option;
|
||||
showDropdown = false;
|
||||
}
|
||||
|
||||
function toggleListening() {
|
||||
match = getMatch();
|
||||
if (match) {
|
||||
if (listening.listening) {
|
||||
listening.listening = false;
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
if (!isListening) {
|
||||
subscribe();
|
||||
isListening = true;
|
||||
} else {
|
||||
wakuNode.unsubscribe();
|
||||
isListening = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up interval when component is destroyed
|
||||
if (sendingInterval) {
|
||||
clearInterval(sendingInterval);
|
||||
}
|
||||
// Clean up sweep interval
|
||||
if (sweepInterval) {
|
||||
clearInterval(sweepInterval);
|
||||
}
|
||||
// Ensure we unsubscribe when component is destroyed
|
||||
if (isListening) {
|
||||
wakuNode.unsubscribe();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="action-buttons">
|
||||
{#if !match}
|
||||
<button class="listen-button" on:click={toggleListening}>
|
||||
{isListening ? 'Stop Listening' : 'Listen'}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="send-dropdown-container">
|
||||
<button class="send-button" on:click={toggleSending}>
|
||||
{isSending ? 'Stop Sending' : 'Send'}
|
||||
</button>
|
||||
|
||||
<button class="dropdown-toggle" on:click={() => (showDropdown = !showDropdown)}>
|
||||
{messageOption === 'continuous' ? '∞' : messageOption}
|
||||
<span class="arrow">▼</span>
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" on:click={() => selectOption('1')}>1</div>
|
||||
<div class="dropdown-item" on:click={() => selectOption('5')}>5</div>
|
||||
<div class="dropdown-item" on:click={() => selectOption('10')}>10</div>
|
||||
<div class="dropdown-item" on:click={() => selectOption('continuous')}>∞</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.listen-button {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.listen-button:hover:not(:disabled) {
|
||||
background-color: #059669;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.send-button:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.send-dropdown-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.6rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.dropdown-toggle .arrow {
|
||||
font-size: 0.7rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
min-width: 120px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
</style>
|
||||
41
examples/sds-demo/src/lib/components/ActionModule.svelte
Normal file
41
examples/sds-demo/src/lib/components/ActionModule.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import ActionButtons from './ActionButtons.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { processQueue } from '$lib/sds.svelte';
|
||||
import { connectionState } from '../waku/waku.svelte';
|
||||
|
||||
let isComponentMounted = false;
|
||||
|
||||
async function runProcessQueue() {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
await processQueue();
|
||||
|
||||
// Schedule the next run if component is still mounted
|
||||
if (isComponentMounted) {
|
||||
setTimeout(runProcessQueue, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
isComponentMounted = true;
|
||||
runProcessQueue();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
isComponentMounted = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $connectionState.status === "connected"}
|
||||
<div class="compact-action-module">
|
||||
<ActionButtons />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.compact-action-module {
|
||||
display: flex;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
</style>
|
||||
19
examples/sds-demo/src/lib/components/CallToAction.svelte
Normal file
19
examples/sds-demo/src/lib/components/CallToAction.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let message: string;
|
||||
export let linkText: string = "";
|
||||
export let linkHref: string = "";
|
||||
export let marginTop: string = "mt-6";
|
||||
export let messageMarginBottom: string = "mb-2";
|
||||
export let useSlot: boolean = false;
|
||||
</script>
|
||||
|
||||
<div class="{marginTop} pt-6 border-t border-gray-200 text-center w-full">
|
||||
<p class="text-gray-600 {messageMarginBottom}">{message}</p>
|
||||
{#if useSlot}
|
||||
<slot />
|
||||
{:else}
|
||||
<a href={linkHref} class="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>{linkText} →</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
141
examples/sds-demo/src/lib/components/ConnectionButton.svelte
Normal file
141
examples/sds-demo/src/lib/components/ConnectionButton.svelte
Normal file
@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { startWaku, connectionState } from '../waku/waku.svelte';
|
||||
|
||||
let {
|
||||
size = 'normal',
|
||||
afterConnect
|
||||
}: {
|
||||
size?: 'normal' | 'large';
|
||||
afterConnect: (afterConnectState: (state: string) => void) => void;
|
||||
} = $props();
|
||||
let afterConnectState = $state('');
|
||||
|
||||
async function handleConnect() {
|
||||
try {
|
||||
await startWaku();
|
||||
} catch (error) {
|
||||
// Error is already handled in startWaku function
|
||||
console.error('Connection error in component:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Call afterConnect when status changes to connected
|
||||
$effect(() => {
|
||||
if ($connectionState.status === 'connected') {
|
||||
if (afterConnect) {
|
||||
afterConnect((state: string) => {
|
||||
afterConnectState = state;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="connection-ui {size}">
|
||||
{#if $connectionState.status === 'disconnected'}
|
||||
<button on:click={handleConnect} class="font-medium text-blue-600 hover:text-blue-800"
|
||||
>Connect →</button
|
||||
>
|
||||
{:else if $connectionState.status === 'connecting'}
|
||||
<span class="status animated-dots">Starting node . . .</span>
|
||||
{:else if $connectionState.status === 'waiting_for_peers'}
|
||||
<span class="status animated-dots">Waiting for peers . . .</span>
|
||||
{:else if $connectionState.status === 'setting_up_subscriptions'}
|
||||
<span class="status animated-dots">Setting up subscriptions . . .</span>
|
||||
{:else if $connectionState.status === 'connected'}
|
||||
{#if afterConnectState !== ''}
|
||||
<span class="status animated-dots">{afterConnectState}</span>
|
||||
{:else}
|
||||
<span class="status connected">Connected</span>
|
||||
{/if}
|
||||
{:else if $connectionState.status === 'error'}
|
||||
<div class="error-container">
|
||||
<span class="error">Error: {$connectionState.error}</span>
|
||||
<button on:click={handleConnect} class="font-medium text-blue-600 hover:text-blue-800"
|
||||
>Retry</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.connection-ui {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connection-ui.large button {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.connection-ui.large .status {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.connection-ui.normal .error-container {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
background-color: #fee2e2;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.connection-ui.large .error {
|
||||
font-size: 1rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.animated-dots {
|
||||
animation: dotAnimation 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes dotAnimation {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
243
examples/sds-demo/src/lib/components/ConnectionIndicator.svelte
Normal file
243
examples/sds-demo/src/lib/components/ConnectionIndicator.svelte
Normal file
@ -0,0 +1,243 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { connectionState, startWaku } from "../waku/waku.svelte";
|
||||
import { HealthStatus } from "@waku/sdk";
|
||||
import { health, unregisterHealthListener } from "../waku/waku.svelte";
|
||||
import { page } from '$app/state';
|
||||
|
||||
import { listening } from "../waku/pingpong.svelte";
|
||||
|
||||
let healthStatus = $state(HealthStatus.Unhealthy);
|
||||
let isHomePage = $state(false);
|
||||
let shouldShowConnectButton = $state(false);
|
||||
|
||||
function startHealthCheck() {
|
||||
health((health: HealthStatus) => {
|
||||
healthStatus = health;
|
||||
});
|
||||
}
|
||||
|
||||
function stopHealthCheck() {
|
||||
unregisterHealthListener((health: HealthStatus) => {
|
||||
healthStatus = health;
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($connectionState.status === "connected") {
|
||||
startHealthCheck();
|
||||
} else {
|
||||
stopHealthCheck();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if ($connectionState.status === "connected") {
|
||||
startHealthCheck();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopHealthCheck();
|
||||
});
|
||||
|
||||
function getHealthColor() {
|
||||
const status = listening.listening ? HealthStatus.SufficientlyHealthy : HealthStatus.Unhealthy;
|
||||
if ($connectionState.status !== "connected") {
|
||||
return "gray";
|
||||
}
|
||||
switch (status) {
|
||||
case HealthStatus.SufficientlyHealthy:
|
||||
return "green";
|
||||
// case HealthStatus.MinimallyHealthy:
|
||||
// return "goldenrod";
|
||||
case HealthStatus.Unhealthy:
|
||||
default:
|
||||
return "red";
|
||||
}
|
||||
}
|
||||
|
||||
function getHealthText(status: HealthStatus) {
|
||||
if ($connectionState.status !== "connected") {
|
||||
return "Node is not connected";
|
||||
}
|
||||
switch (status) {
|
||||
case HealthStatus.SufficientlyHealthy:
|
||||
return "Node is healthy";
|
||||
case HealthStatus.MinimallyHealthy:
|
||||
return "Node is minimally healthy";
|
||||
case HealthStatus.Unhealthy:
|
||||
default:
|
||||
return "Node is unhealthy";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
try {
|
||||
await startWaku();
|
||||
} catch (error) {
|
||||
console.error("Connection error in header:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current route is not the home page
|
||||
$effect(() => {
|
||||
isHomePage = page.url.pathname ? page.url.pathname === "/" : true;
|
||||
updateButtonVisibility();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
updateButtonVisibility();
|
||||
});
|
||||
|
||||
function updateButtonVisibility() {
|
||||
shouldShowConnectButton = !isHomePage && $connectionState.status !== "connected";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="connection-status">
|
||||
{#if shouldShowConnectButton}
|
||||
<div class="header-connection-ui">
|
||||
{#if $connectionState.status === "disconnected"}
|
||||
<button
|
||||
on:click={handleConnect}
|
||||
class="connect-button px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600 transition-colors duration-200 mr-2"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
{:else if $connectionState.status === "connecting"}
|
||||
<span class="status animated-dots">Starting node</span>
|
||||
{:else if $connectionState.status === "waiting_for_peers"}
|
||||
<span class="status animated-dots">Waiting for peers</span>
|
||||
{:else if $connectionState.status === "setting_up_subscriptions"}
|
||||
<span class="status animated-dots">Setting up subscriptions</span>
|
||||
{:else if $connectionState.status === "error"}
|
||||
<div class="error-container">
|
||||
<button
|
||||
on:click={handleConnect}
|
||||
class="connect-button px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors duration-200 mr-2"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="status-wrapper">
|
||||
<div
|
||||
class="health-indicator"
|
||||
style="background-color: {getHealthColor()}"
|
||||
>
|
||||
<span class="tooltip">{getHealthText(healthStatus)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-wrapper {
|
||||
position: relative;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.health-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s ease;
|
||||
cursor: help;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Position the tooltip below */
|
||||
top: 100%;
|
||||
right: 0; /* Align to the right instead of center since we're near screen edge */
|
||||
transform: translateX(0); /* Remove horizontal centering */
|
||||
margin-top: 8px;
|
||||
|
||||
/* Ensure tooltip stays in viewport */
|
||||
max-width: calc(100vw - 2rem); /* Leave 1rem padding on each side */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
/* Add a small triangle pointer */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 2px; /* Align arrow with the indicator */
|
||||
transform: translateX(0);
|
||||
border-width: 4px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent #333 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add animation for smooth appearance */
|
||||
.health-indicator:hover .tooltip {
|
||||
visibility: visible;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.header-connection-ui {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connect-button {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.animated-dots {
|
||||
animation: dotAnimation 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes dotAnimation {
|
||||
0% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
examples/sds-demo/src/lib/components/Header.svelte
Normal file
19
examples/sds-demo/src/lib/components/Header.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
// Simple header component that accepts a slot
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
317
examples/sds-demo/src/lib/components/History.svelte
Normal file
317
examples/sds-demo/src/lib/components/History.svelte
Normal file
@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { subscribeToAllEventsStream, subscribeToChannelEvents } from '$lib/sds/stream.svelte';
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getIdenticon } from '$lib/identicon.svelte';
|
||||
import { getMessageId } from '$lib/sds/message';
|
||||
import type { MessageChannelEventObject } from '$lib/sds/stream';
|
||||
import HistoryItem from './HistoryItem.svelte';
|
||||
import LegendModal from './LegendModal.svelte';
|
||||
import { matchesIdFilter, currentIdFilter } from '$lib/utils/event.svelte';
|
||||
import { hash } from '$lib/utils/hash';
|
||||
|
||||
// Store for history items
|
||||
let history: Array<MessageChannelEventObject> = $state([]);
|
||||
let identicon: any = $state(null);
|
||||
let currentFilter: string = $state('all');
|
||||
let showLegend: boolean = $state(false);
|
||||
|
||||
let { channelId = null }: { channelId: string | null } = $props();
|
||||
|
||||
// Map of filter values to event types
|
||||
const filterMap: { [key: string]: string | null } = {
|
||||
all: null,
|
||||
sent: MessageChannelEvent.MessageSent,
|
||||
received: MessageChannelEvent.MessageReceived,
|
||||
delivered: MessageChannelEvent.MessageDelivered,
|
||||
acknowledged: MessageChannelEvent.MessageAcknowledged,
|
||||
syncSent: MessageChannelEvent.SyncSent,
|
||||
syncReceived: MessageChannelEvent.SyncReceived
|
||||
};
|
||||
|
||||
// Calculate counts of each event type
|
||||
let eventCounts = $derived({
|
||||
all: history.length,
|
||||
sent: history.filter((event) => event.type === MessageChannelEvent.MessageSent).length,
|
||||
received: history.filter((event) => event.type === MessageChannelEvent.MessageReceived).length,
|
||||
delivered: history.filter((event) => event.type === MessageChannelEvent.MessageDelivered)
|
||||
.length,
|
||||
acknowledged: history.filter((event) => event.type === MessageChannelEvent.MessageAcknowledged)
|
||||
.length,
|
||||
syncSent: history.filter((event) => event.type === MessageChannelEvent.SyncSent).length,
|
||||
syncReceived: history.filter((event) => event.type === MessageChannelEvent.SyncReceived).length
|
||||
});
|
||||
|
||||
// Filtered history based on selected filter and ID filter
|
||||
let filteredHistory = $derived(
|
||||
(() => {
|
||||
// First filter by type
|
||||
let result =
|
||||
currentFilter === 'all'
|
||||
? [...history]
|
||||
: history.filter((event) => event.type === filterMap[currentFilter]);
|
||||
|
||||
// Then filter by ID if present
|
||||
if (currentIdFilter.id) {
|
||||
result = result.filter(matchesIdFilter);
|
||||
}
|
||||
|
||||
return result;
|
||||
})()
|
||||
);
|
||||
|
||||
let identicons = $derived(
|
||||
identicon &&
|
||||
filteredHistory.map((event: MessageChannelEventObject) => {
|
||||
const id = getMessageId(event);
|
||||
// Handle the case where id could be null
|
||||
return new identicon(id || '', { size: 40, format: 'svg' }).toString();
|
||||
})
|
||||
);
|
||||
|
||||
// Unsubscribe function
|
||||
let unsubscribe: (() => void) | null = $state(null);
|
||||
|
||||
// Handle filter change
|
||||
function handleFilterChange(event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
currentFilter = select.value;
|
||||
}
|
||||
|
||||
// Handle event item click to filter by ID
|
||||
function handleEventClick(id: string | null) {
|
||||
if (id !== null) {
|
||||
currentIdFilter.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dependency click to filter by dependency messageId
|
||||
function handleDependencyClick(messageId: string, event: Event) {
|
||||
// Stop event propagation so it doesn't trigger parent click handler
|
||||
event.stopPropagation();
|
||||
currentIdFilter.id = messageId;
|
||||
}
|
||||
|
||||
// Clear ID filter
|
||||
function clearIdFilter() {
|
||||
currentIdFilter.id = null;
|
||||
}
|
||||
|
||||
// Toggle legend display
|
||||
function toggleLegend() {
|
||||
showLegend = !showLegend;
|
||||
}
|
||||
|
||||
function eventStreamCallback(event: MessageChannelEventObject) {
|
||||
// Add event to history with newest at the top
|
||||
if (event.type === MessageChannelEvent.MissedMessages) {
|
||||
return;
|
||||
}
|
||||
if (event.type === MessageChannelEvent.SyncSent || event.type === MessageChannelEvent.SyncReceived) {
|
||||
event.payload.messageId = hash(event.payload.messageId + event.payload.causalHistory[0].messageId)
|
||||
}
|
||||
history = [event, ...history];
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
identicon = await getIdenticon();
|
||||
// Subscribe to the event stream and collect events
|
||||
unsubscribe = channelId
|
||||
? subscribeToChannelEvents(channelId, eventStreamCallback)
|
||||
: subscribeToAllEventsStream(eventStreamCallback);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up the subscription when component is destroyed
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="history-container">
|
||||
<div class="header">
|
||||
<button class="help-button" onclick={toggleLegend}>?</button>
|
||||
<select class="item-filter" onchange={handleFilterChange} value={currentFilter}>
|
||||
<option value="all">All ({eventCounts.all})</option>
|
||||
<option value="sent">Sent ({eventCounts.sent})</option>
|
||||
<option value="received">Received ({eventCounts.received})</option>
|
||||
<option value="delivered">Delivered ({eventCounts.delivered})</option>
|
||||
<option value="acknowledged">Acknowledged ({eventCounts.acknowledged})</option>
|
||||
<option value="syncSent">Sync Sent ({eventCounts.syncSent})</option>
|
||||
<option value="syncReceived">Sync Received ({eventCounts.syncReceived})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if currentIdFilter.id }
|
||||
<div class="id-filter-badge">
|
||||
<span class="id-label">ID: {currentIdFilter.id}</span>
|
||||
<button class="clear-filter-btn" onclick={clearIdFilter}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="history-items-container">
|
||||
{#each filteredHistory as event, index}
|
||||
<HistoryItem
|
||||
{event}
|
||||
identicon={identicons[index]}
|
||||
currentIdFilter={currentIdFilter.id}
|
||||
onEventClick={handleEventClick}
|
||||
onDependencyClick={handleDependencyClick}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="bottom-fade"></div>
|
||||
|
||||
<LegendModal bind:isOpen={showLegend} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.history-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-width: 400px;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
border: 1px solid #e0ddd4;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.history-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-items-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
padding-bottom: 30px; /* Add space for the fade effect */
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bottom-fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1));
|
||||
pointer-events: none; /* Allows interaction with elements underneath */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.virtualizer-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 8px 16px 8px;
|
||||
border-bottom: 1px solid #e0ddd4;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: #f5f2e8;
|
||||
border: 1px solid #e0ddd4;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-right: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.help-button:hover {
|
||||
background-color: #e8e5db;
|
||||
}
|
||||
|
||||
.item-filter {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e0ddd4;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.item-filter:hover, .item-filter:focus {
|
||||
border-color: #ccc9c2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.id-filter-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f5f2e8;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
margin: 0 8px 12px 8px;
|
||||
max-width: fit-content;
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
border: 1px solid #e0ddd4;
|
||||
}
|
||||
|
||||
.id-label {
|
||||
font-family: monospace;
|
||||
margin-right: 8px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.clear-filter-btn {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clear-filter-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Ensure all child elements don't cause horizontal overflow */
|
||||
.id-filter-badge, .header, .item-filter {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
230
examples/sds-demo/src/lib/components/HistoryItem.svelte
Normal file
230
examples/sds-demo/src/lib/components/HistoryItem.svelte
Normal file
@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
import type { MessageChannelEventObject } from '$lib/sds/stream';
|
||||
import { getMessageId } from '$lib/sds/message';
|
||||
import { eventColors, eventNames } from '$lib/utils/event.svelte';
|
||||
|
||||
export let event: MessageChannelEventObject | undefined = undefined;
|
||||
export let identicon: string = '';
|
||||
export let currentIdFilter: string | null = null;
|
||||
export let onEventClick: (id: string | null) => void = () => {};
|
||||
export let onDependencyClick: (messageId: string, event: Event) => void = () => {};
|
||||
export let width: number = 340;
|
||||
export let height: number = 178;
|
||||
|
||||
export let overflow: boolean = true;
|
||||
|
||||
$: id = event ? getMessageId(event) : null;
|
||||
$: color = event ? eventColors[event.type] || '#888' : '#f0f0f0';
|
||||
$: name = event ? eventNames[event.type] || event.type : '';
|
||||
$: matchesFilter = currentIdFilter && id === currentIdFilter;
|
||||
|
||||
function handleEventClick() {
|
||||
if (event && id) {
|
||||
onEventClick(id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDependencyClick(messageId: string, e: Event) {
|
||||
onDependencyClick(messageId, e);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="history-item {!event ? 'empty' : ''}"
|
||||
style="width: 100%;"
|
||||
on:click={event ? handleEventClick : undefined}
|
||||
>
|
||||
{#if event}
|
||||
<div class="item-container">
|
||||
<div class="event-box {matchesFilter ? 'highlight' : ''}" style="background-color: {color};">
|
||||
<div class="identicon">
|
||||
<img src="data:image/svg+xml;base64,{identicon}" alt="Identicon" />
|
||||
</div>
|
||||
<div class="event-info" style="overflow: {overflow ? 'visible' : 'hidden'};">
|
||||
<div class="event-type">
|
||||
{name}
|
||||
</div>
|
||||
<div class="event-id">
|
||||
{id}
|
||||
</div>
|
||||
</div>
|
||||
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived || event.type === MessageChannelEvent.SyncSent || event.type === MessageChannelEvent.SyncReceived}
|
||||
<div class="lamport-timestamp">
|
||||
{event.payload.lamportTimestamp}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived || event.type === MessageChannelEvent.SyncSent || event.type === MessageChannelEvent.SyncReceived}
|
||||
{#each event.payload.causalHistory as dependency}
|
||||
{@const dependencyMatchesFilter =
|
||||
currentIdFilter && dependency.messageId === currentIdFilter}
|
||||
<div
|
||||
class="dependency-box {dependencyMatchesFilter ? 'highlight' : ''}"
|
||||
style="background-color: {color};"
|
||||
on:click={(e) => handleDependencyClick(dependency.messageId, e)}
|
||||
>
|
||||
{dependency.messageId}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.history-item {
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.2s ease;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.history-item:not(.empty):hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.history-item:not(.empty) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty {
|
||||
border: 1px dashed rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.item-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
color: white;
|
||||
padding: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dependency-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: auto;
|
||||
max-width: 80%;
|
||||
min-height: 30px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
opacity: 0.9;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
border-left: 2px solid #db8d43;
|
||||
border-right: 2px solid #db8d43;
|
||||
}
|
||||
|
||||
.highlight .event-type {
|
||||
font-size: 15px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.highlight .event-id,
|
||||
.dependency-box.highlight {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dependency-box.highlight {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.identicon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.identicon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-id {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
max-width: 220px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lamport-timestamp {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sent-or-received {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
64
examples/sds-demo/src/lib/components/LegendDemo.svelte
Normal file
64
examples/sds-demo/src/lib/components/LegendDemo.svelte
Normal file
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import LegendModal from './LegendModal.svelte';
|
||||
|
||||
let showLegend = $state(false);
|
||||
|
||||
function toggleLegend() {
|
||||
showLegend = !showLegend;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="legend-demo">
|
||||
<h2>Message Events Visualization</h2>
|
||||
|
||||
<div class="actions">
|
||||
<button class="info-button" on:click={toggleLegend}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
Show Event Types Legend
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LegendModal bind:isOpen={showLegend} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.legend-demo {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.info-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.info-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
371
examples/sds-demo/src/lib/components/LegendModal.svelte
Normal file
371
examples/sds-demo/src/lib/components/LegendModal.svelte
Normal file
@ -0,0 +1,371 @@
|
||||
<script lang="ts">
|
||||
import { historyJson } from "$lib/data/history_sample";
|
||||
import type { MessageChannelEventObject } from "$lib/sds/stream";
|
||||
import { MessageChannelEvent } from "@waku/sds";
|
||||
import HistoryItem from "$lib/components/HistoryItem.svelte";
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { createTooltip } from '$lib/utils/tooltipUtils';
|
||||
|
||||
// Parse history data
|
||||
const history: MessageChannelEventObject[] = JSON.parse(historyJson);
|
||||
|
||||
// Create sample identicons (using a placeholder)
|
||||
const placeholderIdenticon = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNkZGQiLz48L3N2Zz4=";
|
||||
|
||||
// Sample handler functions - they don't need to do anything in the legend
|
||||
function handleItemClick(id: string | null) {
|
||||
// No action needed for the legend
|
||||
}
|
||||
|
||||
function handleDependencyClick(messageId: string, event: Event) {
|
||||
// Prevent event bubbling
|
||||
event.stopPropagation();
|
||||
// No action needed for the legend
|
||||
}
|
||||
|
||||
// Get one sample of each event type for the legend
|
||||
const eventTypes = [
|
||||
MessageChannelEvent.MessageSent,
|
||||
MessageChannelEvent.MessageDelivered,
|
||||
MessageChannelEvent.MessageReceived,
|
||||
MessageChannelEvent.MessageAcknowledged,
|
||||
MessageChannelEvent.PartialAcknowledgement,
|
||||
MessageChannelEvent.MissedMessages
|
||||
];
|
||||
|
||||
// Find one event of each type to display in the legend
|
||||
let legendItems = history.slice(2, 6);
|
||||
|
||||
// Simple tooltip text
|
||||
const leftTooltipText = "Messages are sent between peers in the network. Each message has a unique ID and can depend on other messages. Events like Sent, Received, Delivered and Acknowledged show the status of messages.";
|
||||
|
||||
const rightTooltipText = "Dependencies represent messages that a current message depends on. They appear as smaller boxes below the main message.";
|
||||
|
||||
const lamportTooltipText = "<b>Lamport timestamps</b> provide a way to order events in a distributed system. They increment with each message, ensuring a consistent ordering across all peers in the network, even without synchronized clocks.";
|
||||
|
||||
const eventIdTooltipText = "Unique <b>Message ID</b> assigned to each message.";
|
||||
|
||||
const dependencyContainerTooltipText = "Each message comes with <b>causal history</b> attached, containing the last two message IDs from the local history of the sender.";
|
||||
|
||||
// Custom highlight classes
|
||||
const tooltipHighlightClass = "tooltip-highlight";
|
||||
|
||||
console.log(legendItems);
|
||||
|
||||
// Make isOpen bindable so parent can track when modal is closed
|
||||
export let isOpen = false;
|
||||
|
||||
// Reference elements for tooltips
|
||||
let modalElement: HTMLElement;
|
||||
let leftAnchorElement: HTMLElement;
|
||||
let rightAnchorElement: HTMLElement;
|
||||
let lamportTimestampElement: HTMLElement | null = null;
|
||||
let eventIdElement: HTMLElement | null = null;
|
||||
let dependencyContainerElement: HTMLElement | null = null;
|
||||
// let leftTooltipRef: ReturnType<typeof createTooltip>;
|
||||
// let rightTooltipRef: ReturnType<typeof createTooltip>;
|
||||
let lamportTooltipRef: ReturnType<typeof createTooltip>;
|
||||
let eventIdTooltipRef: ReturnType<typeof createTooltip>;
|
||||
let dependencyContainerTooltipRef: ReturnType<typeof createTooltip>;
|
||||
|
||||
// Handles closing the modal and updates the parent via bindable prop
|
||||
function closeModal() {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
// Cleanup tooltips when component is destroyed
|
||||
function cleanupTooltips() {
|
||||
// leftTooltipRef?.destroy();
|
||||
// rightTooltipRef?.destroy();
|
||||
lamportTooltipRef?.destroy();
|
||||
eventIdTooltipRef?.destroy();
|
||||
dependencyContainerTooltipRef?.destroy();
|
||||
}
|
||||
|
||||
// Create tooltips once elements are mounted and modal is open
|
||||
function setupTooltips() {
|
||||
// Clean up any existing tooltips first
|
||||
cleanupTooltips();
|
||||
|
||||
if (!isOpen || !leftAnchorElement || !rightAnchorElement) return;
|
||||
|
||||
// Create left tooltip
|
||||
// leftTooltipRef = createTooltip(leftAnchorElement, {
|
||||
// position: 'left',
|
||||
// content: leftTooltipText,
|
||||
// width: 200,
|
||||
// showOnHover: false,
|
||||
// visible: true,
|
||||
// offset: 20
|
||||
// });
|
||||
|
||||
// Create right tooltip
|
||||
// rightTooltipRef = createTooltip(rightAnchorElement, {
|
||||
// position: 'right',
|
||||
// content: rightTooltipText,
|
||||
// width: 200,
|
||||
// showOnHover: false,
|
||||
// visible: true,
|
||||
// offset: 20
|
||||
// });
|
||||
|
||||
// Wait a moment to find and add tooltip to the specific elements
|
||||
setTimeout(() => {
|
||||
// Find the first lamport timestamp element
|
||||
lamportTimestampElement = document.querySelector('.legend-content .lamport-timestamp');
|
||||
|
||||
if (lamportTimestampElement) {
|
||||
// Create lamport timestamp tooltip
|
||||
lamportTooltipRef = createTooltip(lamportTimestampElement, {
|
||||
position: 'right',
|
||||
content: lamportTooltipText,
|
||||
width: 220,
|
||||
showOnHover: false,
|
||||
visible: true,
|
||||
offset: 10,
|
||||
verticalOffset: -100,
|
||||
highlightTarget: true,
|
||||
highlightClass: tooltipHighlightClass
|
||||
});
|
||||
}
|
||||
|
||||
// Find the first event-id element
|
||||
eventIdElement = document.querySelector('.legend-content .event-id');
|
||||
|
||||
if (eventIdElement) {
|
||||
// Create event-id tooltip
|
||||
eventIdTooltipRef = createTooltip(eventIdElement, {
|
||||
position: 'left',
|
||||
content: eventIdTooltipText,
|
||||
width: 180,
|
||||
showOnHover: false,
|
||||
visible: true,
|
||||
offset: 10,
|
||||
highlightTarget: true,
|
||||
highlightClass: tooltipHighlightClass
|
||||
});
|
||||
}
|
||||
|
||||
// Find the first dependency-container element
|
||||
dependencyContainerElement = document.querySelector('.legend-content .dependency-box');
|
||||
|
||||
if (dependencyContainerElement) {
|
||||
// Create dependency-container tooltip
|
||||
dependencyContainerTooltipRef = createTooltip(dependencyContainerElement, {
|
||||
position: 'left',
|
||||
content: dependencyContainerTooltipText,
|
||||
width: 250,
|
||||
showOnHover: false,
|
||||
visible: true,
|
||||
offset: 20,
|
||||
verticalOffset: 40,
|
||||
highlightTarget: true,
|
||||
highlightClass: tooltipHighlightClass
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isOpen) {
|
||||
// Need to wait for the DOM to update
|
||||
setTimeout(setupTooltips, 0);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupTooltips();
|
||||
});
|
||||
|
||||
// Watch for changes to isOpen state
|
||||
$: if (isOpen) {
|
||||
// Need to wait for the DOM to update
|
||||
setTimeout(setupTooltips, 0);
|
||||
} else {
|
||||
cleanupTooltips();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="legend-modal-backdrop" on:click={closeModal}>
|
||||
<div class="legend-container">
|
||||
<!-- Main Modal (Center) -->
|
||||
<div class="legend-modal" bind:this={modalElement} on:click|stopPropagation>
|
||||
<!-- Tooltip anchor elements -->
|
||||
<div class="tooltip-anchor left-anchor" bind:this={leftAnchorElement}></div>
|
||||
<div class="tooltip-anchor right-anchor" bind:this={rightAnchorElement}></div>
|
||||
|
||||
<div class="legend-header">
|
||||
<h2>Legend</h2>
|
||||
<button class="close-button" on:click={closeModal}>×</button>
|
||||
</div>
|
||||
<div class="legend-content">
|
||||
{#each legendItems as event}
|
||||
<div class="legend-item">
|
||||
<HistoryItem
|
||||
{event}
|
||||
identicon={placeholderIdenticon}
|
||||
onEventClick={handleItemClick}
|
||||
onDependencyClick={handleDependencyClick}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.legend-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.legend-modal {
|
||||
position: relative;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tooltip-anchor {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 40%;
|
||||
top: 30%;
|
||||
}
|
||||
|
||||
.left-anchor {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right-anchor {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.legend-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.legend-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.legend-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Custom highlight for lamport timestamp */
|
||||
:global(.tooltip-highlight) {
|
||||
background-color: rgba(253, 230, 138, 0.9) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 0 0 2px #f59e0b !important;
|
||||
color: #000 !important;
|
||||
font-weight: bold !important;
|
||||
animation: pulse 1.5s infinite !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Custom highlight for event ID */
|
||||
:global(.event-id-highlight) {
|
||||
background-color: rgba(191, 219, 254, 0.9) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 0 0 2px #3b82f6 !important;
|
||||
color: #000 !important;
|
||||
font-weight: bold !important;
|
||||
animation: pulseCyan 1.5s infinite !important;
|
||||
}
|
||||
|
||||
/* Custom highlight for dependency container */
|
||||
:global(.dependency-container-highlight) {
|
||||
background-color: rgba(220, 252, 231, 0.7) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 0 0 2px #10b981 !important;
|
||||
padding: 4px !important;
|
||||
animation: pulseGreen 1.5s infinite !important;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseCyan {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(59, 130, 246, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseGreen {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.tooltip-anchor {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Message } from "@waku/sds";
|
||||
|
||||
const { message }: { message: Message } = $props();
|
||||
</script>
|
||||
|
||||
<div class="message-details">
|
||||
<h1>{message.messageId}</h1>
|
||||
</div>
|
||||
461
examples/sds-demo/src/lib/components/Missing.svelte
Normal file
461
examples/sds-demo/src/lib/components/Missing.svelte
Normal file
@ -0,0 +1,461 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
subscribeToAllEventsStream,
|
||||
subscribeToChannelEvents,
|
||||
subscribeToMissingMessageStream
|
||||
} from '$lib/sds/stream.svelte';
|
||||
import { MessageChannelEvent, type HistoryEntry } from '@waku/sds';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getIdenticon } from '$lib/identicon.svelte';
|
||||
import { getMessageId } from '$lib/sds/message';
|
||||
import type { MessageChannelEventObject } from '$lib/sds/stream';
|
||||
import HistoryItem from './HistoryItem.svelte';
|
||||
import LegendModal from './LegendModal.svelte';
|
||||
import { currentIdFilter, eventColors } from '$lib/utils/event.svelte';
|
||||
import { hash } from '$lib/utils/hash';
|
||||
import { encodeBase64 } from 'effect/Encoding';
|
||||
|
||||
// Store for history items
|
||||
let history: HistoryEntry[] = $state([]);
|
||||
let identicon: any = $state(null);
|
||||
let currentFilter: string = $state('all');
|
||||
let showLegend: boolean = $state(false);
|
||||
|
||||
let { channelId = null }: { channelId: string | null } = $props();
|
||||
|
||||
// Filtered history based on selected filter and ID filter
|
||||
let filteredHistory = $derived(
|
||||
(() => {
|
||||
// Then filter by ID if present
|
||||
if (currentIdFilter.id) {
|
||||
return history.filter((event) => event.messageId === currentIdFilter.id);
|
||||
}
|
||||
|
||||
return history;
|
||||
})()
|
||||
);
|
||||
|
||||
let identicons = $derived(
|
||||
identicon &&
|
||||
filteredHistory.map((event: HistoryEntry) => {
|
||||
const id = event.messageId;
|
||||
// Handle the case where id could be null
|
||||
return new identicon(id || '', { size: 40, format: 'svg' }).toString();
|
||||
})
|
||||
);
|
||||
|
||||
// Unsubscribe function
|
||||
let unsubscribe: (() => void) | null = $state(null);
|
||||
|
||||
// Handle filter change
|
||||
function handleFilterChange(event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
currentFilter = select.value;
|
||||
}
|
||||
|
||||
// Handle event item click to filter by ID
|
||||
function handleEventClick(id: string | null) {
|
||||
if (id !== null) {
|
||||
currentIdFilter.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dependency click to filter by dependency messageId
|
||||
function handleDependencyClick(messageId: string, event: Event) {
|
||||
// Stop event propagation so it doesn't trigger parent click handler
|
||||
event.stopPropagation();
|
||||
currentIdFilter.id = messageId;
|
||||
}
|
||||
|
||||
// Clear ID filter
|
||||
function clearIdFilter() {
|
||||
currentIdFilter.id = null;
|
||||
}
|
||||
|
||||
// Toggle legend display
|
||||
function toggleLegend() {
|
||||
showLegend = !showLegend;
|
||||
}
|
||||
|
||||
const active: { [messageId: string]: boolean } = $state({});
|
||||
const log: Set<string> = $state(new Set());
|
||||
|
||||
function eventStreamCallback(event: MessageChannelEventObject) {
|
||||
if (event.type !== MessageChannelEvent.MissedMessages) {
|
||||
return;
|
||||
}
|
||||
console.log('missed messages', event);
|
||||
event.payload.forEach((message) => {
|
||||
if (!log.has(message.messageId)) {
|
||||
history.push(message);
|
||||
log.add(message.messageId);
|
||||
}
|
||||
});
|
||||
// history = event.payload;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
identicon = await getIdenticon();
|
||||
// Subscribe to the event stream and collect events
|
||||
unsubscribe = channelId
|
||||
? subscribeToChannelEvents(channelId, eventStreamCallback)
|
||||
: subscribeToMissingMessageStream(eventStreamCallback);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up the subscription when component is destroyed
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
const color = eventColors[MessageChannelEvent.MissedMessages];
|
||||
</script>
|
||||
|
||||
<div class="history-container">
|
||||
|
||||
{#if currentIdFilter.id}
|
||||
<div class="id-filter-badge">
|
||||
<span class="id-label">ID: {currentIdFilter.id}</span>
|
||||
<button class="clear-filter-btn" onclick={clearIdFilter}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="history-items-container">
|
||||
{#each filteredHistory as event, index}
|
||||
<div class="history-item {!event ? 'empty' : ''}" style="width: 100%; height: 100px;">
|
||||
{#if event}
|
||||
<div class="item-container">
|
||||
<div class="event-box" style="background-color: {color};">
|
||||
<div class="identicon">
|
||||
<img src="data:image/svg+xml;base64,{identicons[index]}" alt="Identicon" />
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<div class="event-type"></div>
|
||||
<div class="event-id">
|
||||
{event.messageId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- {#if event.retrievalHint}
|
||||
<div class="dependency-box" style="background-color: {color};">
|
||||
{encodeBase64(event.retrievalHint)}
|
||||
</div>
|
||||
{/if} -->
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="bottom-fade"></div>
|
||||
|
||||
<LegendModal bind:isOpen={showLegend} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.history-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-width: 400px;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
border: 1px solid #e0ddd4;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.history-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-items-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
padding-bottom: 30px; /* Add space for the fade effect */
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bottom-fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1));
|
||||
pointer-events: none; /* Allows interaction with elements underneath */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.virtualizer-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 8px 16px 8px;
|
||||
border-bottom: 1px solid #e0ddd4;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: #f5f2e8;
|
||||
border: 1px solid #e0ddd4;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-right: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.help-button:hover {
|
||||
background-color: #e8e5db;
|
||||
}
|
||||
|
||||
.item-filter {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e0ddd4;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.item-filter:hover,
|
||||
.item-filter:focus {
|
||||
border-color: #ccc9c2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.id-filter-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f5f2e8;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
margin: 0 8px 12px 8px;
|
||||
max-width: fit-content;
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
border: 1px solid #e0ddd4;
|
||||
}
|
||||
|
||||
.id-label {
|
||||
font-family: monospace;
|
||||
margin-right: 8px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.clear-filter-btn {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clear-filter-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Ensure all child elements don't cause horizontal overflow */
|
||||
.id-filter-badge,
|
||||
.header,
|
||||
.item-filter {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.2s ease;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.history-item:not(.empty):hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.history-item:not(.empty) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty {
|
||||
border: 1px dashed rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.item-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
color: white;
|
||||
padding: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dependency-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: auto;
|
||||
max-width: 80%;
|
||||
min-height: 30px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
opacity: 0.9;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
border-left: 2px solid #db8d43;
|
||||
border-right: 2px solid #db8d43;
|
||||
}
|
||||
|
||||
.highlight .event-type {
|
||||
font-size: 15px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.highlight .event-id,
|
||||
.dependency-box.highlight {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dependency-box.highlight {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.identicon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.identicon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-id {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
max-width: 220px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lamport-timestamp {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sent-or-received {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
34
examples/sds-demo/src/lib/components/PageLayout.svelte
Normal file
34
examples/sds-demo/src/lib/components/PageLayout.svelte
Normal file
@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
type MaxWidthOption = "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
|
||||
export let maxWidth: MaxWidthOption = "md";
|
||||
export let padding = "p-8";
|
||||
export let margin = "sm:my-12 my-4";
|
||||
export let title = "";
|
||||
|
||||
const maxWidthClasses: Record<MaxWidthOption, string> = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
"2xl": "max-w-2xl",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="relative min-h-screen">
|
||||
<div
|
||||
class="{maxWidthClasses[maxWidth]} mx-auto {padding} bg-white rounded-lg sm:shadow-md absolute top-0 left-0 right-0 {margin}"
|
||||
in:fade={{ duration: 300 }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
{#if title}
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6 tracking-tight text-center">
|
||||
{title}
|
||||
</h1>
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
165
examples/sds-demo/src/lib/components/StateGraphSummary.svelte
Normal file
165
examples/sds-demo/src/lib/components/StateGraphSummary.svelte
Normal file
@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { actual_grid, update_virtual_grid } from '$lib/utils/stateGraph.svelte';
|
||||
import { subscribeToAllEventsStream, subscribeToChannelEvents } from '$lib/sds/stream.svelte';
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
import { eventColors, eventNames, currentIdFilter, matchesIdFilter } from '$lib/utils/event.svelte';
|
||||
import type { MessageChannelEventObject } from '$lib/sds/stream';
|
||||
import { hash } from '$lib/utils/hash';
|
||||
|
||||
let { channelId = null }: { channelId: string | null } = $props();
|
||||
let unsubscribe: (() => void) | null = $state(null);
|
||||
|
||||
function eventStreamCallback(event: MessageChannelEventObject) {
|
||||
if (event.type === MessageChannelEvent.MissedMessages) {
|
||||
return;
|
||||
}
|
||||
if (event.type === MessageChannelEvent.SyncSent || event.type === MessageChannelEvent.SyncReceived) {
|
||||
event.payload.messageId = hash(event.payload.messageId + event.payload.causalHistory[0].messageId)
|
||||
}
|
||||
console.log('updating virtual grid', event);
|
||||
update_virtual_grid(event);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
unsubscribe = channelId
|
||||
? subscribeToChannelEvents(channelId, eventStreamCallback)
|
||||
: subscribeToAllEventsStream(eventStreamCallback);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="summary-grid">
|
||||
{#each actual_grid as row}
|
||||
{@const length = row.columns.filter((c) => c !== null).length}
|
||||
{@const empty = 4 - length}
|
||||
{@const isEmptyColumn = length === 0}
|
||||
<div class="column mr-2 mb-4 p-4 state-column {isEmptyColumn ? 'empty-column' : ''}">
|
||||
<div class="lamport-timestamp">
|
||||
<span>{row.lamportTimestamp}</span>
|
||||
</div>
|
||||
<div class="queue-container">
|
||||
{#each row.columns as cell}
|
||||
{@const filtered = currentIdFilter.id && cell && matchesIdFilter(cell)}
|
||||
{#if cell?.type}
|
||||
<div
|
||||
class={`cell ${currentIdFilter.id ? (filtered ? 'filtered' : 'filtered-out') : ''}`}
|
||||
style="background-color: {eventColors[cell.type]};"
|
||||
>
|
||||
<p class="cell-text">{eventNames[cell.type]}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if empty > 0}
|
||||
{#each Array(empty) as _}
|
||||
<div class="cell empty-cell"></div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.summary-grid {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lamport-timestamp {
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.queue-container {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 100px;
|
||||
min-height: 40px;
|
||||
border: none;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filtered {
|
||||
box-shadow: 0 0 0 2px #DB8D43;
|
||||
}
|
||||
|
||||
.filtered-out {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
border: 1px dashed rgba(0, 0, 0, 0.1);
|
||||
background-color: rgba(240, 240, 240, 0.5);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.column {
|
||||
max-width: 280px;
|
||||
min-width: 200px;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
border: 1px solid #e0ddd4;
|
||||
}
|
||||
|
||||
.state-column {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.state-column:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.cell-text {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.empty-column {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-column .lamport-timestamp {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.empty-column .empty-cell {
|
||||
border: 1px dashed rgba(0, 0, 0, 0.05);
|
||||
background-color: rgba(240, 240, 240, 0.3);
|
||||
}
|
||||
</style>
|
||||
129
examples/sds-demo/src/lib/components/Tooltip.svelte
Normal file
129
examples/sds-demo/src/lib/components/Tooltip.svelte
Normal file
@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let targetElement: HTMLElement | null = null;
|
||||
export let position: 'left' | 'right' = 'right';
|
||||
export let content: string = '';
|
||||
export let offset: number = 20;
|
||||
export let verticalOffset: number = 0;
|
||||
export let width: number = 200;
|
||||
export let showOnHover: boolean = true;
|
||||
export let visible: boolean = false;
|
||||
export let highlightTarget: boolean = false;
|
||||
export let highlightClass: string = 'tooltip-target-highlight';
|
||||
|
||||
let tooltipElement: HTMLElement;
|
||||
let originalTargetClasses: string = '';
|
||||
|
||||
// Position the tooltip relative to the target element
|
||||
function positionTooltip() {
|
||||
if (!targetElement || !tooltipElement) return;
|
||||
|
||||
const targetRect = targetElement.getBoundingClientRect();
|
||||
|
||||
// Calculate vertical center alignment with optional vertical offset
|
||||
const top = targetRect.top + (targetRect.height / 2) + verticalOffset;
|
||||
|
||||
// Position horizontally based on the selected position
|
||||
if (position === 'left') {
|
||||
tooltipElement.style.right = `${window.innerWidth - targetRect.left + offset}px`;
|
||||
} else {
|
||||
tooltipElement.style.left = `${targetRect.right + offset}px`;
|
||||
}
|
||||
|
||||
tooltipElement.style.top = `${top}px`;
|
||||
tooltipElement.style.transform = 'translateY(-50%)';
|
||||
}
|
||||
|
||||
// Reposition on window resize
|
||||
function handleResize() {
|
||||
if (visible) {
|
||||
positionTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function showTooltip() {
|
||||
visible = true;
|
||||
// Position after becoming visible
|
||||
setTimeout(positionTooltip, 0);
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function applyHighlight() {
|
||||
if (targetElement && highlightTarget) {
|
||||
// Store original classes before adding highlight
|
||||
originalTargetClasses = targetElement.className;
|
||||
targetElement.classList.add(highlightClass);
|
||||
}
|
||||
}
|
||||
|
||||
function removeHighlight() {
|
||||
if (targetElement && highlightTarget) {
|
||||
// If we stored original classes, restore them
|
||||
if (originalTargetClasses) {
|
||||
// Remove highlight class
|
||||
targetElement.classList.remove(highlightClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
applyHighlight();
|
||||
|
||||
if (visible) {
|
||||
positionTooltip();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
// Ensure we remove highlight when component is destroyed
|
||||
removeHighlight();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="tooltip {position}-tooltip"
|
||||
bind:this={tooltipElement}
|
||||
style="width: {width}px;"
|
||||
>
|
||||
{@html content}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #4b5563;
|
||||
z-index: 1000;
|
||||
max-width: 300px;
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.2s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.tooltip-target-highlight) {
|
||||
outline: 2px solid rgba(252, 211, 77, 0.8) !important;
|
||||
outline-offset: 2px !important;
|
||||
transition: outline-color 0.2s ease !important;
|
||||
z-index: 2 !important;
|
||||
position: relative !important;
|
||||
}
|
||||
</style>
|
||||
35
examples/sds-demo/src/lib/components/WakuAwareButton.svelte
Normal file
35
examples/sds-demo/src/lib/components/WakuAwareButton.svelte
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { wakuConnection } from "../connectionUtils";
|
||||
|
||||
export let onClick: () => void;
|
||||
export let label: string = "Action";
|
||||
export let extraCondition: boolean = true;
|
||||
export let className: string = "";
|
||||
export let requireConnection: boolean = true;
|
||||
|
||||
// Whether to check connection status for this button
|
||||
$: buttonProps = requireConnection
|
||||
? $wakuConnection.getButtonProps(extraCondition)
|
||||
: {
|
||||
disabled: !extraCondition,
|
||||
'aria-disabled': !extraCondition,
|
||||
title: !extraCondition ? 'This action requires additional conditions to be met' : '',
|
||||
class: !extraCondition ? 'opacity-50 cursor-not-allowed' : ''
|
||||
};
|
||||
|
||||
function handleClick() {
|
||||
if (!buttonProps.disabled) {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 rounded bg-blue-500 text-white hover:bg-blue-600 {buttonProps.class} {className}"
|
||||
on:click={handleClick}
|
||||
disabled={buttonProps.disabled}
|
||||
aria-disabled={buttonProps.disabled}
|
||||
title={buttonProps.title}
|
||||
>
|
||||
<slot>{label}</slot>
|
||||
</button>
|
||||
45
examples/sds-demo/src/lib/connectionUtils.ts
Normal file
45
examples/sds-demo/src/lib/connectionUtils.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { derived } from 'svelte/store';
|
||||
import { connectionState } from './waku/waku.svelte';
|
||||
|
||||
// A derived store that provides both connection state and utility functions
|
||||
export const wakuConnection = derived(
|
||||
connectionState,
|
||||
($connectionState) => {
|
||||
const isConnected = $connectionState.status === 'connected';
|
||||
const isConnecting = $connectionState.status === 'connecting' || $connectionState.status === 'waiting_for_peers' || $connectionState.status === 'setting_up_subscriptions';
|
||||
const hasError = $connectionState.status === 'error';
|
||||
// const errorMessage = $connectionState.error;
|
||||
|
||||
return {
|
||||
// Current connection state
|
||||
status: $connectionState.status,
|
||||
error: $connectionState.error,
|
||||
|
||||
// Utility getters
|
||||
isConnected,
|
||||
isConnecting,
|
||||
hasError,
|
||||
|
||||
// Utility functions
|
||||
disableIfNotConnected: (extraCondition = true) => {
|
||||
return !isConnected || !extraCondition;
|
||||
},
|
||||
|
||||
// Function to get button attributes based on connection state
|
||||
getButtonProps: (extraDisableCondition = true) => {
|
||||
const disabled = !isConnected || !extraDisableCondition;
|
||||
|
||||
return {
|
||||
disabled,
|
||||
title: disabled && !extraDisableCondition
|
||||
? 'This action requires additional conditions to be met'
|
||||
: disabled
|
||||
? 'This action requires a connected Waku node'
|
||||
: '',
|
||||
'aria-disabled': disabled,
|
||||
class: disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
2
examples/sds-demo/src/lib/data/history_sample.ts
Normal file
2
examples/sds-demo/src/lib/data/history_sample.ts
Normal file
File diff suppressed because one or more lines are too long
51
examples/sds-demo/src/lib/data/sample_history.ts
Normal file
51
examples/sds-demo/src/lib/data/sample_history.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
|
||||
// Sample history with different event types for the legend
|
||||
export const historyJson = JSON.stringify([
|
||||
{
|
||||
type: MessageChannelEvent.MessageSent,
|
||||
payload: {
|
||||
messageId: "db7ce7bff8734cc868da5bd8d880b58765ed9e0481f0c2f6b0ec86258322a3fa",
|
||||
channelId: "channel-id",
|
||||
lamportTimestamp: 6,
|
||||
causalHistory: [],
|
||||
bloomFilter: { 0: 0, 1: 0, 2: 0, 3: 0 },
|
||||
content: { 0: 131, 1: 244 }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.MessageDelivered,
|
||||
payload: {
|
||||
messageId: "bc8701dd8eacca44f01a177a2d7e2ac879dd189b1f8ea2b57b10bbdb82042bc0",
|
||||
sentOrReceived: "received"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.MessageReceived,
|
||||
payload: {
|
||||
messageId: "bc8701dd8eacca44f01a177a2d7e2ac879dd189b1f8ea2b57b10bbdb82042bc0",
|
||||
channelId: "channel-id",
|
||||
causalHistory: [],
|
||||
lamportTimestamp: 4,
|
||||
bloomFilter: { 0: 0, 1: 0, 2: 0, 3: 0 },
|
||||
content: { 0: 226, 1: 83 }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.MessageAcknowledged,
|
||||
payload: "217e647921f9a6fc8ecfc480e207db828e18c5868d229cf5c5bf59be89dc70ff"
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.PartialAcknowledgement,
|
||||
payload: {
|
||||
messageId: "d81b2617e63162843413eea8c62dada058ac7e6c8f8463fd5ef1171939cd9415",
|
||||
acknowledgementBitmask: new Uint8Array([1, 0, 1])
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MessageChannelEvent.MissedMessages,
|
||||
payload: {
|
||||
messageIds: ["58cf44867e529152f4095aa6a951500a6a45571a062dc0bddb06aef48c97f85b"]
|
||||
}
|
||||
}
|
||||
]);
|
||||
14
examples/sds-demo/src/lib/identicon.svelte.ts
Normal file
14
examples/sds-demo/src/lib/identicon.svelte.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let Identicon: any = $state(null);
|
||||
|
||||
export async function loadIdenticon() {
|
||||
const identiconModule = await import("identicon.js");
|
||||
Identicon = identiconModule.default;
|
||||
}
|
||||
|
||||
export async function getIdenticon() {
|
||||
if (!Identicon) {
|
||||
await loadIdenticon();
|
||||
}
|
||||
return Identicon;
|
||||
}
|
||||
1
examples/sds-demo/src/lib/index.ts
Normal file
1
examples/sds-demo/src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
87
examples/sds-demo/src/lib/sds.svelte.ts
Normal file
87
examples/sds-demo/src/lib/sds.svelte.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { encoder, wakuNode } from '$lib/waku/waku.svelte';
|
||||
import { type Message, MessageChannelEvent, encodeMessage, decodeMessage } from '@waku/sds';
|
||||
import { getChannel } from '$lib/sds/channel.svelte';
|
||||
import { messageHash } from '@waku/message-hash';
|
||||
import type { MessageChannel } from '@waku/sds';
|
||||
const channel = getChannel();
|
||||
|
||||
export function subscribe() {
|
||||
wakuNode.subscribeToFilter((message) => {
|
||||
const sdsMessage = decodeMessage(message.payload) as unknown as Message;
|
||||
channel.receiveMessage(sdsMessage);
|
||||
});
|
||||
}
|
||||
|
||||
export async function processQueue() {
|
||||
await channel.processTasks();
|
||||
}
|
||||
|
||||
export function startSending() {
|
||||
const randomHash = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomHash);
|
||||
send(randomHash);
|
||||
}
|
||||
|
||||
export function sweepOut() {
|
||||
return channel.sweepOutgoingBuffer();
|
||||
}
|
||||
|
||||
export async function sweepIn(_channel?: MessageChannel) {
|
||||
if (!_channel) {
|
||||
_channel = channel;
|
||||
}
|
||||
const missedMessages = _channel.sweepIncomingBuffer();
|
||||
const messageHashes = missedMessages
|
||||
.filter((message) => message.retrievalHint !== undefined)
|
||||
.map((message) => message.retrievalHint!);
|
||||
if (messageHashes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const query = wakuNode.queryStore(messageHashes);
|
||||
if (!query) {
|
||||
console.error('no query');
|
||||
return;
|
||||
}
|
||||
console.log('query', query);
|
||||
|
||||
// Process all batches of promises from the AsyncGenerator
|
||||
for await (const promises of query) {
|
||||
// Resolve all promises in the batch
|
||||
const messages = await Promise.all(promises);
|
||||
console.log('messages', messages);
|
||||
|
||||
// Process each message
|
||||
for (const msg of messages) {
|
||||
if (msg?.payload) {
|
||||
const sdsMessage = decodeMessage(msg.payload) as unknown as Message;
|
||||
_channel.receiveMessage(sdsMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function send(payload: Uint8Array): Promise<void> {
|
||||
await channel.sendMessage(payload, async (message: Message) => {
|
||||
const encodedMessage = encodeMessage(message);
|
||||
const timestamp = new Date();
|
||||
const protoMessage = await encoder.toProtoObj({
|
||||
payload: encodedMessage,
|
||||
timestamp
|
||||
});
|
||||
const hash = messageHash(encoder.pubsubTopic, protoMessage);
|
||||
const result = await wakuNode.sendWithLightPush(encodedMessage, timestamp);
|
||||
if (result.failures.length > 0) {
|
||||
console.error('error sending message', result.failures);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
retrievalHint: hash
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const history: {
|
||||
messageId: string;
|
||||
lamportTimestamp?: number;
|
||||
event: MessageChannelEvent;
|
||||
}[] = $state([]);
|
||||
19
examples/sds-demo/src/lib/sds/channel.svelte.ts
Normal file
19
examples/sds-demo/src/lib/sds/channel.svelte.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { MessageChannel } from '@waku/sds';
|
||||
|
||||
const channelId = 'channel-id';
|
||||
|
||||
const channel = $state(new MessageChannel(channelId));
|
||||
|
||||
export function getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
const channels = $state<Record<string, MessageChannel>>({});
|
||||
|
||||
export function getOrCreateChannel(channelId: string) {
|
||||
if (channels[channelId]) {
|
||||
return channels[channelId];
|
||||
}
|
||||
channels[channelId] = new MessageChannel(channelId);
|
||||
return channels[channelId];
|
||||
}
|
||||
23
examples/sds-demo/src/lib/sds/message.ts
Normal file
23
examples/sds-demo/src/lib/sds/message.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { MessageChannelEvent } from "@waku/sds";
|
||||
import type { MessageChannelEventObject } from "./stream";
|
||||
|
||||
export function getMessageId(event: MessageChannelEventObject) {
|
||||
if(event.type === MessageChannelEvent.MessageSent) {
|
||||
return event.payload.messageId;
|
||||
} else if(event.type === MessageChannelEvent.MessageDelivered) {
|
||||
return event.payload.messageId;
|
||||
} else if(event.type === MessageChannelEvent.MessageReceived) {
|
||||
return event.payload.messageId;
|
||||
} else if(event.type === MessageChannelEvent.MessageAcknowledged) {
|
||||
return event.payload;
|
||||
} else if(event.type === MessageChannelEvent.PartialAcknowledgement) {
|
||||
return event.payload.messageId;
|
||||
} else if(event.type === MessageChannelEvent.MissedMessages) {
|
||||
return event.payload[0].messageId;
|
||||
} else if(event.type === MessageChannelEvent.SyncSent) {
|
||||
return event.payload.messageId;
|
||||
} else if(event.type === MessageChannelEvent.SyncReceived) {
|
||||
return event.payload.messageId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
56
examples/sds-demo/src/lib/sds/stream.svelte.ts
Normal file
56
examples/sds-demo/src/lib/sds/stream.svelte.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { getChannel, getOrCreateChannel } from './channel.svelte';
|
||||
import { toEventStream, filterByEventType } from './stream';
|
||||
import { Effect, Stream, pipe } from 'effect';
|
||||
import type { MessageChannelEventObject } from './stream';
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
|
||||
const eventStream = $state(toEventStream(getChannel()));
|
||||
|
||||
const missingMessageEventStream = $derived(
|
||||
pipe(eventStream, filterByEventType(MessageChannelEvent.MissedMessages))
|
||||
);
|
||||
|
||||
export function subscribeToMissingMessageStream(
|
||||
onEvent: (
|
||||
event: Extract<MessageChannelEventObject, { type: MessageChannelEvent.MissedMessages }>
|
||||
) => void
|
||||
): () => void {
|
||||
return subscribeToEventStream(missingMessageEventStream, onEvent);
|
||||
}
|
||||
|
||||
export function subscribeToAllEventsStream(
|
||||
onEvent: (event: MessageChannelEventObject) => void
|
||||
): () => void {
|
||||
return subscribeToEventStream(eventStream, onEvent);
|
||||
}
|
||||
|
||||
export function subscribeToChannelEvents(
|
||||
channelId: string,
|
||||
onEvent: (event: MessageChannelEventObject) => void
|
||||
): () => void {
|
||||
return subscribeToEventStream(toEventStream(getOrCreateChannel(channelId)), onEvent);
|
||||
}
|
||||
|
||||
function subscribeToEventStream<A extends MessageChannelEventObject>(
|
||||
stream: Stream.Stream<A>,
|
||||
onEvent: (event: A) => void
|
||||
): () => void {
|
||||
const fiber = Effect.runFork(
|
||||
pipe(
|
||||
stream,
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
onEvent(event);
|
||||
})
|
||||
),
|
||||
Stream.runDrain
|
||||
)
|
||||
);
|
||||
return () => {
|
||||
Effect.runFork(
|
||||
Effect.sync(() => {
|
||||
(fiber as unknown as { interrupt: () => void }).interrupt();
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
65
examples/sds-demo/src/lib/sds/stream.ts
Normal file
65
examples/sds-demo/src/lib/sds/stream.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Stream, pipe } from 'effect';
|
||||
import { MessageChannel, MessageChannelEvent, type MessageChannelEvents } from '@waku/sds';
|
||||
|
||||
/**
|
||||
* Extract payload types from MessageChannelEvents
|
||||
*/
|
||||
type EventPayloadMap = {
|
||||
[K in keyof MessageChannelEvents]: MessageChannelEvents[K] extends CustomEvent<infer T>
|
||||
? T
|
||||
: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Union type for all possible event objects
|
||||
*/
|
||||
export type MessageChannelEventObject = {
|
||||
[E in MessageChannelEvent]: { type: E; payload: EventPayloadMap[E] };
|
||||
}[MessageChannelEvent];
|
||||
|
||||
/**
|
||||
* Helper function to create a stream for a specific event type
|
||||
*/
|
||||
const fromMessageChannelEvent = <E extends MessageChannelEvent>(
|
||||
channel: MessageChannel,
|
||||
eventType: E
|
||||
): Stream.Stream<MessageChannelEventObject> => {
|
||||
return Stream.map(
|
||||
Stream.fromEventListener(channel, eventType, { passive: true }),
|
||||
(event: Event) => {
|
||||
const customEvent = event as CustomEvent<EventPayloadMap[E]>;
|
||||
return {
|
||||
type: eventType,
|
||||
payload: customEvent.detail
|
||||
} as MessageChannelEventObject;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an Effect Stream from a MessageChannel's events
|
||||
*/
|
||||
export const toEventStream = (
|
||||
channel: MessageChannel
|
||||
): Stream.Stream<MessageChannelEventObject> => {
|
||||
return Stream.mergeAll(
|
||||
Object.values(MessageChannelEvent).map((eventType) =>
|
||||
fromMessageChannelEvent(channel, eventType as MessageChannelEvent)
|
||||
),
|
||||
{ concurrency: 'unbounded' }
|
||||
);
|
||||
};
|
||||
|
||||
// Add some convenience filtering methods
|
||||
export const filterByEventType =
|
||||
<E extends MessageChannelEvent>(eventType: E) =>
|
||||
<R, E2>(
|
||||
stream: Stream.Stream<MessageChannelEventObject, E2, R>
|
||||
): Stream.Stream<Extract<MessageChannelEventObject, { type: E }>, E2, R> =>
|
||||
pipe(
|
||||
stream,
|
||||
Stream.filter(
|
||||
(event): event is Extract<MessageChannelEventObject, { type: E }> =>
|
||||
event.type === eventType
|
||||
)
|
||||
);
|
||||
52
examples/sds-demo/src/lib/utils/event.svelte.ts
Normal file
52
examples/sds-demo/src/lib/utils/event.svelte.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { getMessageId } from '$lib/sds/message';
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
import type { MessageChannelEventObject } from '$lib/sds/stream';
|
||||
|
||||
export const eventColors: { [key in string]: string } = {
|
||||
[MessageChannelEvent.MessageSent]: '#4482CF', // blue similar to LIFO
|
||||
[MessageChannelEvent.MessageDelivered]: '#45A676', // green similar to FIFO
|
||||
[MessageChannelEvent.MessageReceived]: '#8D6AB3', // purple similar to priority+RED
|
||||
[MessageChannelEvent.MessageAcknowledged]: '#2E7B58', // darker green
|
||||
[MessageChannelEvent.PartialAcknowledgement]: '#6A4A96', // darker purple
|
||||
[MessageChannelEvent.MissedMessages]: '#E05252', // error red
|
||||
[MessageChannelEvent.SyncSent]: '#DB8D43', // orange/brown similar to priority
|
||||
[MessageChannelEvent.SyncReceived]: '#C47A35' // darker orange/brown
|
||||
};
|
||||
|
||||
// Event type to display name using index signature
|
||||
export const eventNames: { [key in string]: string } = {
|
||||
[MessageChannelEvent.MessageSent]: 'Sent',
|
||||
[MessageChannelEvent.MessageDelivered]: 'Delivered',
|
||||
[MessageChannelEvent.MessageReceived]: 'Received',
|
||||
[MessageChannelEvent.MessageAcknowledged]: 'Acknowledged',
|
||||
[MessageChannelEvent.PartialAcknowledgement]: 'Partially Acknowledged',
|
||||
[MessageChannelEvent.MissedMessages]: 'Missed',
|
||||
[MessageChannelEvent.SyncSent]: 'Sync Sent',
|
||||
[MessageChannelEvent.SyncReceived]: 'Sync Received'
|
||||
};
|
||||
|
||||
export const currentIdFilter: { id: string | null } = $state({ id: null });
|
||||
|
||||
export const matchesIdFilter = (event: MessageChannelEventObject) => {
|
||||
if (currentIdFilter) {
|
||||
const id = getMessageId(event);
|
||||
|
||||
// Check direct ID match
|
||||
if (id === currentIdFilter.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check causal history for ID match
|
||||
if (
|
||||
(event.type === MessageChannelEvent.MessageSent ||
|
||||
event.type === MessageChannelEvent.MessageReceived) &&
|
||||
event.payload.causalHistory
|
||||
) {
|
||||
return event.payload.causalHistory.some(
|
||||
(dependency: { messageId: string }) => dependency.messageId === currentIdFilter.id
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
38
examples/sds-demo/src/lib/utils/frequency.ts
Normal file
38
examples/sds-demo/src/lib/utils/frequency.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Schedule } from "effect";
|
||||
|
||||
import { Ref } from "effect";
|
||||
|
||||
import { Effect } from "effect";
|
||||
|
||||
|
||||
export function frequency() {
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const listenCondition = yield* Ref.make(false);
|
||||
// const queue = yield* initializeQueue;
|
||||
yield* Effect.all(
|
||||
[
|
||||
// setup filter
|
||||
// subscribe(listenCondition),
|
||||
// // send messages
|
||||
Effect.repeat(Effect.sync(() => {}), Schedule.spaced('2000 millis')),
|
||||
// // Effect.repeat(takeAndSend, Schedule.spaced('2000 millis')),
|
||||
// // periodic sync
|
||||
// Effect.repeat(sendSync, Schedule.spaced('10000 millis')),
|
||||
// // periodically process queue
|
||||
// Effect.repeat(processQueue, Schedule.spaced('200 millis')),
|
||||
// // periodically sweep buffers
|
||||
// Effect.repeat(sweep, Schedule.spaced('2000 millis')),
|
||||
// // periodically switch off filter to miss messages
|
||||
Effect.repeat(
|
||||
Ref.update(listenCondition, (listening) => !listening),
|
||||
Schedule.spaced('2000 millis')
|
||||
)
|
||||
],
|
||||
{
|
||||
concurrency: 'unbounded'
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
7
examples/sds-demo/src/lib/utils/hash.ts
Normal file
7
examples/sds-demo/src/lib/utils/hash.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
|
||||
export function hash(data: string) {
|
||||
return bytesToHex(sha256(data));
|
||||
}
|
||||
|
||||
11
examples/sds-demo/src/lib/utils/match.svelte.ts
Normal file
11
examples/sds-demo/src/lib/utils/match.svelte.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { MatchParams } from "$lib/waku/waku.svelte";
|
||||
|
||||
let match = $state<MatchParams | undefined>(undefined);
|
||||
|
||||
export function setMatch(params: MatchParams) {
|
||||
match = params;
|
||||
}
|
||||
|
||||
export function getMatch() {
|
||||
return match;
|
||||
}
|
||||
115
examples/sds-demo/src/lib/utils/stateGraph.svelte.ts
Normal file
115
examples/sds-demo/src/lib/utils/stateGraph.svelte.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { getMessageId } from '$lib/sds/message';
|
||||
import { type MessageChannelEventObject } from '$lib/sds/stream';
|
||||
import { MessageChannelEvent } from '@waku/sds';
|
||||
|
||||
const lamportTimestamp = $state(0);
|
||||
let maxLamportTimestamp = $state(0);
|
||||
|
||||
export const initializeGrid = (_maxLamportTimestamp: number) => {
|
||||
maxLamportTimestamp = _maxLamportTimestamp;
|
||||
const rows = maxLamportTimestamp;
|
||||
const columns = maxLamportTimestamp;
|
||||
return createGrid(rows, columns);
|
||||
};
|
||||
|
||||
export const addItems = (items: Array<MessageChannelEventObject>, _lamportTimestamp?: number) => {
|
||||
if (!_lamportTimestamp) {
|
||||
_lamportTimestamp = lamportTimestamp;
|
||||
}
|
||||
grid[_lamportTimestamp] = items;
|
||||
};
|
||||
|
||||
export const createGrid = (
|
||||
rows: number,
|
||||
columns: number
|
||||
): Array<Array<MessageChannelEventObject | null>> => {
|
||||
return Array(rows)
|
||||
.fill(null)
|
||||
.map(() => Array(columns).fill(null));
|
||||
};
|
||||
|
||||
const x_window = 100;
|
||||
export const actual_grid = $state(
|
||||
createGrid(x_window, 10).map((row, index) => ({ lamportTimestamp: index + 1, columns: row }))
|
||||
);
|
||||
export const update_virtual_grid = (event: MessageChannelEventObject) => {
|
||||
const lamportTimestamp = getLamportTimestamp(event);
|
||||
if (!lamportTimestamp) {
|
||||
return;
|
||||
}
|
||||
actual_grid[lamportTimestamp - (1 % x_window)].columns.push(event);
|
||||
};
|
||||
|
||||
export const grid = $state(createGrid(50, 10));
|
||||
|
||||
const messageIdToLamportTimestamp = $state(new Map<string, number>());
|
||||
const getLamportTimestamp = (event: MessageChannelEventObject) => {
|
||||
const messageId = getMessageId(event);
|
||||
let lamportTimestamp = null;
|
||||
if (
|
||||
event.type === MessageChannelEvent.MessageSent ||
|
||||
event.type === MessageChannelEvent.MessageReceived ||
|
||||
event.type === MessageChannelEvent.SyncSent ||
|
||||
event.type === MessageChannelEvent.SyncReceived
|
||||
) {
|
||||
lamportTimestamp = event.payload.lamportTimestamp;
|
||||
if (!lamportTimestamp) {
|
||||
return;
|
||||
}
|
||||
if (messageId) {
|
||||
messageIdToLamportTimestamp.set(messageId, lamportTimestamp);
|
||||
}
|
||||
} else if (
|
||||
event.type === MessageChannelEvent.MessageDelivered ||
|
||||
event.type === MessageChannelEvent.MessageAcknowledged
|
||||
) {
|
||||
if (messageId) {
|
||||
lamportTimestamp = messageIdToLamportTimestamp.get(messageId) || null;
|
||||
}
|
||||
if (!lamportTimestamp) {
|
||||
lamportTimestamp = longestTimestamp;
|
||||
}
|
||||
}
|
||||
return lamportTimestamp;
|
||||
};
|
||||
const messagesPerLamportTimestamp = $state(new Map<number, Array<MessageChannelEventObject>>());
|
||||
let longestTimestamp = $state(0);
|
||||
export const recordMessage = (
|
||||
message: MessageChannelEventObject,
|
||||
_grid?: Array<Array<MessageChannelEventObject | null>>
|
||||
) => {
|
||||
if (!_grid) {
|
||||
_grid = grid;
|
||||
}
|
||||
let lamportTimestamp = null;
|
||||
if (
|
||||
message.type === MessageChannelEvent.MessageSent ||
|
||||
message.type === MessageChannelEvent.MessageReceived ||
|
||||
message.type === MessageChannelEvent.SyncSent ||
|
||||
message.type === MessageChannelEvent.SyncReceived
|
||||
) {
|
||||
lamportTimestamp = message.payload.lamportTimestamp;
|
||||
if (!lamportTimestamp) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
lamportTimestamp = longestTimestamp;
|
||||
}
|
||||
const messages = messagesPerLamportTimestamp.get(lamportTimestamp) || [];
|
||||
messages.push(message);
|
||||
messagesPerLamportTimestamp.set(lamportTimestamp, messages);
|
||||
if (lamportTimestamp > longestTimestamp) {
|
||||
longestTimestamp = lamportTimestamp;
|
||||
}
|
||||
const firstFill = _grid[lamportTimestamp].findIndex((item) => item !== null);
|
||||
if (firstFill === -1) {
|
||||
_grid[lamportTimestamp][Math.floor(_grid[lamportTimestamp].length / 2)] = message;
|
||||
} else {
|
||||
const lastFill = _grid[lamportTimestamp].findLastIndex((item) => item !== null);
|
||||
if (firstFill > _grid[lamportTimestamp].length - lastFill) {
|
||||
_grid[lamportTimestamp][firstFill - 1] = message;
|
||||
} else {
|
||||
_grid[lamportTimestamp][lastFill + 1] = message;
|
||||
}
|
||||
}
|
||||
};
|
||||
91
examples/sds-demo/src/lib/utils/tooltipUtils.ts
Normal file
91
examples/sds-demo/src/lib/utils/tooltipUtils.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { mount, unmount } from 'svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
|
||||
interface TooltipOptions {
|
||||
position?: 'left' | 'right';
|
||||
content: string;
|
||||
offset?: number;
|
||||
verticalOffset?: number;
|
||||
width?: number;
|
||||
showOnHover?: boolean;
|
||||
visible?: boolean;
|
||||
highlightTarget?: boolean;
|
||||
highlightClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tooltip positioned relative to a target element
|
||||
* @param targetElement - The element to attach the tooltip to
|
||||
* @param options - Configuration options for the tooltip
|
||||
* @returns An object with methods to control the tooltip
|
||||
*/
|
||||
export function createTooltip(targetElement: HTMLElement, options: TooltipOptions) {
|
||||
const {
|
||||
position = 'right',
|
||||
content,
|
||||
offset = 20,
|
||||
width = 200,
|
||||
showOnHover = true,
|
||||
visible = false,
|
||||
highlightTarget = false,
|
||||
highlightClass = 'tooltip-target-highlight',
|
||||
verticalOffset = 0
|
||||
} = options;
|
||||
|
||||
// Store current visibility state
|
||||
let isVisible = visible;
|
||||
|
||||
// Create a container for the tooltip
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Initialize the tooltip component using Svelte's mount API
|
||||
const tooltipInstance = mount(Tooltip, {
|
||||
target: container,
|
||||
props: {
|
||||
targetElement,
|
||||
position,
|
||||
content,
|
||||
offset,
|
||||
width,
|
||||
verticalOffset,
|
||||
showOnHover,
|
||||
visible: isVisible,
|
||||
highlightTarget,
|
||||
highlightClass
|
||||
}
|
||||
});
|
||||
|
||||
// Return methods to control the tooltip
|
||||
return {
|
||||
destroy: () => {
|
||||
// In Svelte 5, we use the unmount function instead of $destroy
|
||||
unmount(tooltipInstance);
|
||||
container.remove();
|
||||
},
|
||||
updatePosition: () => {
|
||||
tooltipInstance.$set({ targetElement });
|
||||
},
|
||||
updateContent: (newContent: string) => {
|
||||
tooltipInstance.$set({ content: newContent });
|
||||
},
|
||||
show: () => {
|
||||
isVisible = true;
|
||||
tooltipInstance.$set({ visible: true });
|
||||
},
|
||||
hide: () => {
|
||||
isVisible = false;
|
||||
tooltipInstance.$set({ visible: false });
|
||||
},
|
||||
toggle: () => {
|
||||
isVisible = !isVisible;
|
||||
tooltipInstance.$set({ visible: isVisible });
|
||||
},
|
||||
updateOptions: (newOptions: Partial<TooltipOptions>) => {
|
||||
if (newOptions.visible !== undefined) {
|
||||
isVisible = newOptions.visible;
|
||||
}
|
||||
tooltipInstance.$set(newOptions);
|
||||
}
|
||||
};
|
||||
}
|
||||
233
examples/sds-demo/src/lib/waku/lobby.svelte.ts
Normal file
233
examples/sds-demo/src/lib/waku/lobby.svelte.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { Effect, Option, pipe, Stream } from 'effect';
|
||||
import type { MatchParams } from './waku.svelte';
|
||||
|
||||
// Define the type for state transition events
|
||||
export interface StateTransitionDetail {
|
||||
peerId: string;
|
||||
prevState: PeerState;
|
||||
newState: PeerState;
|
||||
message: LobbyMessage;
|
||||
}
|
||||
|
||||
export type StateTransitionEvent = CustomEvent<StateTransitionDetail>;
|
||||
|
||||
// Callback type for state transitions
|
||||
export type StateTransitionCallback = (event: StateTransitionEvent) => void;
|
||||
|
||||
// Events emitted by the lobby
|
||||
export type LobbyEvents = {
|
||||
'state-transition': StateTransitionEvent;
|
||||
};
|
||||
|
||||
export enum LobbyMessageType {
|
||||
Ping = 'ping',
|
||||
Request = 'request',
|
||||
Accept = 'accept',
|
||||
Match = 'match',
|
||||
Ongoing = 'ongoing'
|
||||
}
|
||||
|
||||
enum LobbyEvent {
|
||||
GotPing = 'got_ping',
|
||||
GotRequest = 'got_request',
|
||||
SentRequest = 'sent_request',
|
||||
GotAccept = 'got_accept',
|
||||
SentAccept = 'sent_accept',
|
||||
GotMatch = 'got_match',
|
||||
SentMatch = 'sent_match',
|
||||
Ongoing = 'ongoing'
|
||||
}
|
||||
|
||||
export type LobbyMessage = {
|
||||
messageType: LobbyMessageType;
|
||||
timestamp: Date;
|
||||
expiry?: Date;
|
||||
from: string;
|
||||
to?: string;
|
||||
match?: MatchParams;
|
||||
};
|
||||
|
||||
export enum PeerState {
|
||||
None = 'none',
|
||||
Found = 'found',
|
||||
RequestTo = 'request_to',
|
||||
RequestFrom = 'request_from',
|
||||
AcceptTo = 'accept_to',
|
||||
AcceptFrom = 'accept_from',
|
||||
Success = 'success',
|
||||
Failure = 'failure',
|
||||
Ongoing = 'ongoing'
|
||||
}
|
||||
|
||||
// Create a class that extends EventTarget for native event handling
|
||||
class LobbyState extends EventTarget {
|
||||
// The peer state map
|
||||
peerState = $state(new Map<string, { state: PeerState; messages: LobbyMessage[] }>());
|
||||
|
||||
// Method to update the state of a peer
|
||||
updatePeerState(peerId: string, newState: PeerState, message: LobbyMessage): void {
|
||||
const prevState = this.peerState.get(peerId)?.state || PeerState.None;
|
||||
const messages = this.peerState.get(peerId)?.messages || [];
|
||||
|
||||
// Update the state
|
||||
this.peerState.set(peerId, {
|
||||
state: newState,
|
||||
messages: [...messages, message]
|
||||
});
|
||||
|
||||
// Create and dispatch the event using native event handling
|
||||
const event = new CustomEvent<StateTransitionDetail>('state-transition', {
|
||||
detail: {
|
||||
peerId,
|
||||
prevState,
|
||||
newState,
|
||||
message
|
||||
}
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
export const lobbyState = $state(new LobbyState());
|
||||
|
||||
// Type helpers to narrow message types
|
||||
type MessageOfType<T extends LobbyMessageType> = Omit<LobbyMessage, 'messageType'> & {
|
||||
messageType: T;
|
||||
};
|
||||
|
||||
// Define transition handlers for each message type and state
|
||||
// Allow the state machine to define only valid transitions
|
||||
type TransitionTable = {
|
||||
[LE in LobbyEvent]: {
|
||||
[PS in PeerState]?: PeerState;
|
||||
};
|
||||
};
|
||||
|
||||
// State transition table - defines the next state based on message type and current state
|
||||
const stateMachine: TransitionTable = {
|
||||
[LobbyEvent.GotPing]: {
|
||||
[PeerState.None]: PeerState.Found,
|
||||
[PeerState.Found]: PeerState.Found
|
||||
},
|
||||
[LobbyEvent.SentRequest]: {
|
||||
[PeerState.Found]: PeerState.RequestTo
|
||||
},
|
||||
[LobbyEvent.GotRequest]: {
|
||||
[PeerState.None]: PeerState.RequestFrom,
|
||||
[PeerState.Found]: PeerState.RequestFrom
|
||||
},
|
||||
[LobbyEvent.SentAccept]: {
|
||||
[PeerState.RequestFrom]: PeerState.AcceptTo
|
||||
},
|
||||
[LobbyEvent.GotAccept]: {
|
||||
[PeerState.RequestTo]: PeerState.AcceptFrom
|
||||
},
|
||||
[LobbyEvent.SentMatch]: {
|
||||
[PeerState.AcceptFrom]: PeerState.Success
|
||||
},
|
||||
[LobbyEvent.GotMatch]: {
|
||||
[PeerState.AcceptTo]: PeerState.Success
|
||||
},
|
||||
[LobbyEvent.Ongoing]: {
|
||||
[PeerState.None]: PeerState.Ongoing
|
||||
}
|
||||
};
|
||||
|
||||
// Example of a function that uses the type-narrowed message
|
||||
function processMessage<MT extends LobbyMessageType>(
|
||||
message: MessageOfType<MT>,
|
||||
currentState: PeerState,
|
||||
sent: boolean
|
||||
): Option.Option<PeerState> {
|
||||
// Here we know the exact message type at compile time
|
||||
// We can do specific processing based on message type
|
||||
let event: LobbyEvent | null = null;
|
||||
if (message.messageType === LobbyMessageType.Ping) {
|
||||
console.log(`Received ping from ${message.from}`);
|
||||
if (sent) {
|
||||
throw `Don't track sent pings`;
|
||||
}
|
||||
event = LobbyEvent.GotPing;
|
||||
} else if (message.messageType === LobbyMessageType.Request) {
|
||||
console.log(`Received request from ${message.from} to ${message.to || 'everyone'}`);
|
||||
event = sent ? LobbyEvent.SentRequest : LobbyEvent.GotRequest;
|
||||
} else if (message.messageType === LobbyMessageType.Accept) {
|
||||
console.log(`Received accept from ${message.from} to ${message.to || 'unknown'}`);
|
||||
event = sent ? LobbyEvent.SentAccept : LobbyEvent.GotAccept;
|
||||
} else if (message.messageType === LobbyMessageType.Match) {
|
||||
console.log(`Received match between peers`);
|
||||
event = sent ? LobbyEvent.SentMatch : LobbyEvent.GotMatch;
|
||||
} else if (message.messageType === LobbyMessageType.Ongoing) {
|
||||
console.log(`Received ongoing match`);
|
||||
event = LobbyEvent.Ongoing;
|
||||
}
|
||||
|
||||
// Get next state from transition table
|
||||
if (event === null) {
|
||||
console.warn(`Invalid message type: ${message.messageType}`);
|
||||
return Option.none();
|
||||
}
|
||||
const nextStateValue =
|
||||
message.messageType === LobbyMessageType.Ongoing
|
||||
? PeerState.Ongoing
|
||||
: stateMachine[event][currentState];
|
||||
if (nextStateValue === undefined) {
|
||||
// Handle invalid transitions - throw error or return current state
|
||||
console.warn(`Invalid transition: ${event} from ${currentState}`);
|
||||
return Option.none();
|
||||
}
|
||||
return Option.some(nextStateValue);
|
||||
}
|
||||
|
||||
export async function processUpdates(
|
||||
updates: { peerId: string; message: LobbyMessage; sent: boolean }[]
|
||||
) {
|
||||
for (const update of updates) {
|
||||
const { peerId, message, sent } = update;
|
||||
const currentState = lobbyState.peerState.get(peerId)?.state || PeerState.None;
|
||||
const result = processMessage(message, currentState, sent);
|
||||
Option.match(result, {
|
||||
onNone: () =>
|
||||
console.warn(
|
||||
`Invalid state transition: ${message.messageType} from ${currentState} for peer ${peerId}`
|
||||
),
|
||||
onSome: (newState) => {
|
||||
lobbyState.updatePeerState(peerId, newState, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create a typed stream from the events
|
||||
export const stateTransitionStream = $state(
|
||||
Stream.map(
|
||||
Stream.fromEventListener(lobbyState, 'state-transition', { passive: true }),
|
||||
(event: Event) => event as CustomEvent<StateTransitionDetail>
|
||||
)
|
||||
);
|
||||
|
||||
export function subscribeToStateTransitionStream<A>(
|
||||
stream: Stream.Stream<CustomEvent<A>>,
|
||||
onEvent: (event: A) => void
|
||||
): () => void {
|
||||
const fiber = Effect.runFork(
|
||||
pipe(
|
||||
stream,
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
onEvent(event.detail);
|
||||
})
|
||||
),
|
||||
Stream.runDrain
|
||||
)
|
||||
);
|
||||
return () => {
|
||||
Effect.runFork(
|
||||
Effect.sync(() => {
|
||||
(fiber as unknown as { interrupt: () => void }).interrupt();
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
231
examples/sds-demo/src/lib/waku/pingpong.svelte.ts
Normal file
231
examples/sds-demo/src/lib/waku/pingpong.svelte.ts
Normal file
@ -0,0 +1,231 @@
|
||||
import { decoder, encoder, lobbyEncoder, wakuNode } from '$lib/waku/waku.svelte';
|
||||
import { type Message, encodeMessage, decodeMessage } from '@waku/sds';
|
||||
import type { MatchParams } from './waku.svelte';
|
||||
import { getOrCreateChannel } from '$lib/sds/channel.svelte';
|
||||
import { Effect, Schedule, Stream, Option, Queue, Chunk, Ref } from 'effect';
|
||||
import type { SDKProtocolResult, SubscribeResult } from '@waku/sdk';
|
||||
import { messageHash } from '@waku/message-hash';
|
||||
import { hash } from '$lib/utils/hash';
|
||||
import { encodeBase64 } from 'effect/Encoding';
|
||||
import type { MessageChannel } from '@waku/sds';
|
||||
import { sweepIn, sweepOut } from '$lib/sds.svelte';
|
||||
import { LobbyMessageType, type LobbyMessage } from './lobby.svelte';
|
||||
|
||||
export const listening = $state({ listening: false });
|
||||
|
||||
export function send(channel: MessageChannel, payload: Uint8Array) {
|
||||
return channel.sendMessage(payload, async (message: Message) => {
|
||||
const encodedMessage = encodeMessage(message);
|
||||
const timestamp = new Date();
|
||||
const protoMessage = await encoder.toProtoObj({
|
||||
payload: encodedMessage,
|
||||
timestamp
|
||||
});
|
||||
const hash = messageHash(encoder.pubsubTopic, protoMessage);
|
||||
const result = await wakuNode.sendWithLightPush(encodedMessage, timestamp);
|
||||
if (result.failures.length > 0) {
|
||||
console.error('error sending message', result.failures);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
retrievalHint: hash
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const broadcastMatchToLobby = (params: MatchParams) =>
|
||||
Effect.async<SDKProtocolResult, Error>((resume) => {
|
||||
const m: LobbyMessage = {
|
||||
messageType: LobbyMessageType.Ongoing,
|
||||
timestamp: new Date(),
|
||||
from: params.myPeerId,
|
||||
to: params.otherPeerId,
|
||||
match: params
|
||||
};
|
||||
wakuNode.node?.lightPush
|
||||
.send(lobbyEncoder, {
|
||||
payload: new TextEncoder().encode(JSON.stringify(m)),
|
||||
timestamp: new Date()
|
||||
})
|
||||
.then((result) => resume(Effect.succeed(result)))
|
||||
.catch((error) => resume(Effect.fail(new Error(error as string))));
|
||||
});
|
||||
|
||||
export function start(params: MatchParams, joined: boolean = false) {
|
||||
const { matchId } = params;
|
||||
const first =
|
||||
params.myPeerId.localeCompare(params.otherPeerId + (joined ? Math.random() : '')) < 0
|
||||
? true
|
||||
: false;
|
||||
|
||||
let lastMessage = new Date();
|
||||
const sinkTimeout = 10_000;
|
||||
const channel = getOrCreateChannel(matchId);
|
||||
|
||||
// Create a stream of the messages scheduled based on the strategy
|
||||
// we can predetermine the list of messages we expect to send
|
||||
const payloadsStream = Stream.make(
|
||||
...Array.from({ length: params.messages }, (_, i) => {
|
||||
return new TextEncoder().encode(
|
||||
hash(matchId + (joined ? Math.random() : params.myPeerId) + i)
|
||||
);
|
||||
})
|
||||
);
|
||||
const skips = Array.from({ length: params.messages * 2 }, (_, i) =>
|
||||
i % 2 === 0 ? first : !first
|
||||
);
|
||||
const skipsStream = Stream.fromIterable(skips);
|
||||
|
||||
const payloadsWithSkips = Effect.gen(function* () {
|
||||
const skips = yield* Stream.runCollect(skipsStream);
|
||||
let payloads = yield* Stream.runCollect(payloadsStream);
|
||||
return Chunk.map(skips, (skip): Option.Option<Uint8Array> => {
|
||||
if (skip) {
|
||||
return Option.none();
|
||||
}
|
||||
const head = Chunk.takeRight(1)(payloads);
|
||||
const result = Chunk.get(head, 0);
|
||||
return Option.match(result, {
|
||||
onNone: () => Option.none(),
|
||||
onSome: (value) => {
|
||||
payloads = Chunk.dropRight(1)(payloads);
|
||||
return Option.some(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
const sendEffect = (payload: Option.Option<Uint8Array>) =>
|
||||
Effect.async<void, Error>((resume) => {
|
||||
if (Option.isNone(payload)) {
|
||||
return resume(Effect.succeed(undefined));
|
||||
}
|
||||
send(channel, Option.getOrThrow(payload))
|
||||
.then((result) => resume(Effect.succeed(result)))
|
||||
.catch((error) => resume(Effect.fail(new Error(error as string))));
|
||||
});
|
||||
|
||||
// Creating a bounded queue with a capacity of 100
|
||||
|
||||
// Initialize the queue with the payloads based on strategy
|
||||
const initializeQueue = Effect.gen(function* () {
|
||||
const sendQueue = Queue.bounded<Option.Option<Uint8Array>>(100);
|
||||
const q = yield* sendQueue;
|
||||
const result = yield* q.offerAll(yield* payloadsWithSkips);
|
||||
console.log('queue initialized', result);
|
||||
return q;
|
||||
});
|
||||
|
||||
const takeAndSend = (sendQueue: Queue.Queue<Option.Option<Uint8Array>>) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* sendQueue.take;
|
||||
return yield* sendEffect(result);
|
||||
});
|
||||
|
||||
const validateMessage = (message: Message) => {
|
||||
if (message.channelId !== channel.channelId) {
|
||||
console.error('Message is not for this match');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const subscribe = (listenCondition: Ref.Ref<boolean>) =>
|
||||
Effect.async<SubscribeResult, Error>((resume) => {
|
||||
try {
|
||||
wakuNode.node?.filter
|
||||
.subscribe([decoder], async (message) => {
|
||||
const listening = await Effect.runPromise(listenCondition.get);
|
||||
if (!listening) {
|
||||
return;
|
||||
}
|
||||
const hash = encodeBase64(messageHash(encoder.pubsubTopic, message));
|
||||
const sdsMessage = decodeMessage(message.payload) as unknown as Message;
|
||||
if (validateMessage(sdsMessage) && !sent.has(hash)) {
|
||||
channel.receiveMessage(sdsMessage);
|
||||
lastMessage = new Date();
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
Effect.runPromise(Ref.set(listenCondition, true));
|
||||
resume(Effect.succeed(result));
|
||||
});
|
||||
} catch (error) {
|
||||
resume(Effect.fail(new Error(error as string)));
|
||||
}
|
||||
});
|
||||
|
||||
const sent = new Map<string, boolean>();
|
||||
|
||||
const sendSync = Effect.async<boolean, Error>((resume) => {
|
||||
if (new Date().getTime() - lastMessage.getTime() < sinkTimeout + Math.random() * 2000) {
|
||||
return resume(Effect.succeed(false));
|
||||
} else {
|
||||
channel
|
||||
.sendSyncMessage(async (message: Message) => {
|
||||
const encodedMessage = encodeMessage(message);
|
||||
const timestamp = new Date();
|
||||
const protoMessage = await encoder.toProtoObj({
|
||||
payload: encodedMessage,
|
||||
timestamp
|
||||
});
|
||||
const hash = encodeBase64(messageHash(encoder.pubsubTopic, protoMessage));
|
||||
sent.set(hash, true);
|
||||
const result = await wakuNode.sendWithLightPush(encodedMessage, timestamp);
|
||||
if (result.failures.length > 0) {
|
||||
console.error('error sending message', result.failures);
|
||||
}
|
||||
lastMessage = new Date();
|
||||
return true;
|
||||
})
|
||||
.then((result) => resume(Effect.succeed(result)))
|
||||
.catch((error) => resume(Effect.fail(new Error(error as string))));
|
||||
}
|
||||
});
|
||||
|
||||
const processQueue = Effect.sync(async () => {
|
||||
await channel.processTasks();
|
||||
});
|
||||
|
||||
const sweep = Effect.sync(async () => {
|
||||
await sweepIn(channel);
|
||||
const result = sweepOut();
|
||||
console.log('unacknowledged ', result.unacknowledged.length);
|
||||
});
|
||||
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const listenCondition = yield* Ref.make(false);
|
||||
const queue = yield* initializeQueue;
|
||||
yield* Effect.all(
|
||||
[
|
||||
// setup filter
|
||||
subscribe(listenCondition),
|
||||
// send messages
|
||||
Effect.repeat(takeAndSend(queue), Schedule.spaced('2000 millis')),
|
||||
// Effect.repeat(takeAndSend, Schedule.spaced('2000 millis')),
|
||||
// periodic sync
|
||||
Effect.repeat(sendSync, Schedule.spaced('10000 millis')),
|
||||
// periodically process queue
|
||||
Effect.repeat(processQueue, Schedule.spaced('200 millis')),
|
||||
// periodically sweep buffers
|
||||
Effect.repeat(sweep, Schedule.spaced('2000 millis')),
|
||||
// periodically switch off filter to miss messages
|
||||
Effect.repeat(
|
||||
Ref.update(listenCondition, (_listening) => {
|
||||
listening.listening = !_listening;
|
||||
return !_listening;
|
||||
}),
|
||||
Schedule.spaced(first ? '4500 millis' : `8000 millis`)
|
||||
),
|
||||
Effect.repeat(
|
||||
broadcastMatchToLobby(params),
|
||||
Schedule.spaced('2000 millis')
|
||||
)
|
||||
],
|
||||
{
|
||||
concurrency: 'unbounded'
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
390
examples/sds-demo/src/lib/waku/waku.svelte.ts
Normal file
390
examples/sds-demo/src/lib/waku/waku.svelte.ts
Normal file
@ -0,0 +1,390 @@
|
||||
import {
|
||||
createLightNode,
|
||||
DecodedMessage,
|
||||
type LightNode,
|
||||
Protocols,
|
||||
HealthStatus,
|
||||
HealthStatusChangeEvents,
|
||||
type ISubscription,
|
||||
type SDKProtocolResult,
|
||||
createDecoder,
|
||||
createEncoder,
|
||||
type SubscribeResult
|
||||
} from '@waku/sdk';
|
||||
import { Effect, Schedule, Fiber } from 'effect';
|
||||
import { writable } from 'svelte/store';
|
||||
import { PeerState, stateTransitionStream, subscribeToStateTransitionStream } from './lobby.svelte';
|
||||
import type { LobbyMessage, StateTransitionDetail } from './lobby.svelte';
|
||||
import { LobbyMessageType, processUpdates } from './lobby.svelte';
|
||||
import { hash } from '../utils/hash';
|
||||
import type { RuntimeFiber } from 'effect/Fiber';
|
||||
const contentTopic = '/sds-demo/1/messages/proto';
|
||||
const lobbyContentTopic = '/sds-demo/1/lobby/proto';
|
||||
|
||||
export const encoder = createEncoder({
|
||||
contentTopic,
|
||||
pubsubTopicShardInfo: { clusterId: 42, shard: 0 }
|
||||
});
|
||||
|
||||
export const decoder = createDecoder(contentTopic, { clusterId: 42, shard: 0 });
|
||||
|
||||
export const lobbyEncoder = createEncoder({
|
||||
contentTopic: lobbyContentTopic,
|
||||
pubsubTopicShardInfo: { clusterId: 42, shard: 0 }
|
||||
});
|
||||
|
||||
export const lobbyDecoder = createDecoder(lobbyContentTopic, { clusterId: 42, shard: 0 });
|
||||
export class WakuNode {
|
||||
public node = $state<LightNode | undefined>(undefined);
|
||||
|
||||
async setNode(node: LightNode) {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
public subscription: ISubscription | undefined;
|
||||
|
||||
public async subscribeToFilter(callback: (message: DecodedMessage) => void) {
|
||||
if (!node) {
|
||||
throw new Error('Waku node not started');
|
||||
}
|
||||
|
||||
const result = await node.filter.subscribe([decoder], (message) => {
|
||||
callback(message);
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Error subscribing to filter:', result.error);
|
||||
throw new Error('Failed to subscribe to filter');
|
||||
}
|
||||
|
||||
// At this point TypeScript knows we have a SubscriptionSuccess
|
||||
this.subscription = result.subscription;
|
||||
|
||||
if (result.results.failures.length > 0 || result.results.successes.length === 0) {
|
||||
throw new Error('Failed to subscribe to filter: No successful peer connections');
|
||||
}
|
||||
}
|
||||
|
||||
public async unsubscribe() {
|
||||
if (!node) {
|
||||
throw new Error('Waku node not started');
|
||||
}
|
||||
await this.subscription?.unsubscribe([decoder.contentTopic]);
|
||||
}
|
||||
|
||||
public async sendWithLightPush(payload: Uint8Array, timestamp: Date): Promise<SDKProtocolResult> {
|
||||
if (!node) {
|
||||
throw new Error('Waku node not started');
|
||||
}
|
||||
return await node.lightPush.send(encoder, {
|
||||
payload: payload,
|
||||
timestamp: timestamp
|
||||
});
|
||||
}
|
||||
|
||||
public queryStore(messageHashes: Uint8Array[]) {
|
||||
return node?.store.queryGenerator([decoder], {
|
||||
includeData: true,
|
||||
messageHashes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let node = $state<LightNode | undefined>(undefined);
|
||||
export const connectionState = writable({
|
||||
status: 'disconnected' as
|
||||
| 'error'
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
| 'waiting_for_peers'
|
||||
| 'setting_up_subscriptions'
|
||||
| 'connected',
|
||||
error: null as string | null
|
||||
});
|
||||
|
||||
export const wakuNode = new WakuNode();
|
||||
|
||||
export async function startWaku(): Promise<void> {
|
||||
connectionState.update((state) => ({
|
||||
...state,
|
||||
status: 'connecting',
|
||||
error: null
|
||||
}));
|
||||
|
||||
try {
|
||||
node = await createLightNode({
|
||||
defaultBootstrap: false,
|
||||
networkConfig: {
|
||||
clusterId: 42,
|
||||
shards: [0]
|
||||
},
|
||||
libp2p: {
|
||||
connectionGater: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
denyDialMultiaddr: async (_) => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
filterMultiaddrs: false
|
||||
}
|
||||
});
|
||||
|
||||
await node.start();
|
||||
await wakuNode.setNode(node);
|
||||
|
||||
// Connect to peers
|
||||
const peers = [
|
||||
// '/ip4/127.0.0.1/tcp/8000/ws/p2p/16Uiu2HAm3TLea2NVs4dAqYM2gAgoV9CMKGeD1BkP3RAvmk7HBAbU',
|
||||
'/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ',
|
||||
'/dns4/waku.fryorcraken.xyz/tcp/8000/wss/p2p/16Uiu2HAmMRvhDHrtiHft1FTUYnn6cVA8AWVrTyLUayJJ3MWpUZDB',
|
||||
'/dns4/ivansete.xyz/tcp/8000/wss/p2p/16Uiu2HAmDAHuJ8w9zgxVnhtFe8otWNJdCewPAerJJPbXJcn8tu4r'
|
||||
];
|
||||
for (const peer of peers) {
|
||||
try {
|
||||
await node.dial(peer);
|
||||
} catch (error) {
|
||||
console.error(`Error dialing peer ${peer}:`, error);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).waku = node;
|
||||
connectionState.update((state) => ({
|
||||
...state,
|
||||
status: 'waiting_for_peers'
|
||||
}));
|
||||
|
||||
// Wait for peer connections
|
||||
try {
|
||||
await node.waitForPeers([Protocols.LightPush, Protocols.Filter, Protocols.Store]);
|
||||
connectionState.update((state) => ({
|
||||
...state,
|
||||
status: 'setting_up_subscriptions'
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error waiting for peers:', error);
|
||||
}
|
||||
|
||||
connectionState.update((state) => ({
|
||||
...state,
|
||||
status: 'connected'
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error starting Waku node:', error);
|
||||
connectionState.update((state) => ({
|
||||
...state,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function health(callback: (health: HealthStatus) => void): void {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.health.addEventListener(
|
||||
HealthStatusChangeEvents.StatusChange,
|
||||
(health: CustomEvent<HealthStatus>) => {
|
||||
callback(health.detail);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function unregisterHealthListener(callback: (health: HealthStatus) => void): void {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.health.removeEventListener(
|
||||
HealthStatusChangeEvents.StatusChange,
|
||||
(health: CustomEvent<HealthStatus>) => {
|
||||
callback(health.detail);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export enum Strategy {
|
||||
PingPong = 'ping-pong',
|
||||
Linear = 'linear'
|
||||
}
|
||||
|
||||
export type MatchParams = {
|
||||
matchId: string;
|
||||
myPeerId: string;
|
||||
otherPeerId: string;
|
||||
startTime: Date;
|
||||
messages: number;
|
||||
strategy: Strategy;
|
||||
};
|
||||
|
||||
export async function joinLobby(): Promise<MatchParams> {
|
||||
if (!node) {
|
||||
throw new Error('Waku node not started');
|
||||
}
|
||||
// Setup id and peer state
|
||||
const id = node?.libp2p.peerId.toString();
|
||||
if (!id) {
|
||||
throw new Error('Peer ID not found');
|
||||
}
|
||||
|
||||
let lastMessage: number = 0;
|
||||
const subscribeToLobby = Effect.async<SubscribeResult, Error>((resume) => {
|
||||
try {
|
||||
node?.filter
|
||||
.subscribe([lobbyDecoder], (message) => {
|
||||
const messageData: LobbyMessage = JSON.parse(new TextDecoder().decode(message.payload));
|
||||
if (messageData.from === id) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
messageData.messageType === LobbyMessageType.Ongoing ||
|
||||
messageData.messageType === LobbyMessageType.Ping ||
|
||||
messageData.to === id
|
||||
) {
|
||||
processUpdates([{ peerId: messageData.from, message: messageData, sent: false }]);
|
||||
}
|
||||
console.log('Received lobby message:', messageData);
|
||||
lastMessage = Date.now();
|
||||
})
|
||||
.then((result) => resume(Effect.succeed(result)));
|
||||
} catch (error) {
|
||||
resume(Effect.fail(new Error(error as string)));
|
||||
}
|
||||
});
|
||||
|
||||
const sendMessage = (message: LobbyMessage) =>
|
||||
Effect.async<SDKProtocolResult, Error>((resume) => {
|
||||
node?.lightPush
|
||||
.send(lobbyEncoder, {
|
||||
payload: new TextEncoder().encode(JSON.stringify(message)),
|
||||
timestamp: new Date()
|
||||
})
|
||||
.then((result) => resume(Effect.succeed(result)))
|
||||
.catch((error) => resume(Effect.fail(new Error(error as string))));
|
||||
});
|
||||
|
||||
const policy = Schedule.spaced('1000 millis');
|
||||
|
||||
const repeated = Effect.retry(subscribeToLobby, policy);
|
||||
|
||||
let subscription: ISubscription;
|
||||
Effect.runPromise(repeated).then((sub) => {
|
||||
if (sub.subscription) {
|
||||
subscription = sub.subscription;
|
||||
}
|
||||
});
|
||||
|
||||
const getMatchParams = Effect.async<MatchParams, Error>((resume) => {
|
||||
let fiber: () => void = () => {};
|
||||
let pingFiber: RuntimeFiber<number, Error> | null = null;
|
||||
const pingSchedule = Schedule.spaced('1000 millis');
|
||||
const ping = Effect.async<number, Error>((resume) => {
|
||||
if (lastMessage + 2000 < Date.now()) {
|
||||
Effect.runFork(
|
||||
sendMessage({
|
||||
messageType: LobbyMessageType.Ping,
|
||||
from: id,
|
||||
timestamp: new Date()
|
||||
})
|
||||
);
|
||||
}
|
||||
resume(Effect.succeed(0));
|
||||
});
|
||||
|
||||
const handleStateTransition = async (event: StateTransitionDetail) => {
|
||||
if (event.newState === PeerState.Found) {
|
||||
// Found a peer, send a request to match
|
||||
// or ignore
|
||||
console.log(`Found peer ${event.peerId}`);
|
||||
const message = {
|
||||
messageType: LobbyMessageType.Request,
|
||||
from: id,
|
||||
to: event.peerId,
|
||||
timestamp: new Date()
|
||||
};
|
||||
console.log('Sending request to match:', message);
|
||||
await processUpdates([{ peerId: event.peerId, message, sent: true }]);
|
||||
await Effect.runPromise(sendMessage(message));
|
||||
} else if (event.newState === PeerState.RequestFrom) {
|
||||
// Received a request to match, send an accept
|
||||
// or ignore
|
||||
const message = {
|
||||
messageType: LobbyMessageType.Accept,
|
||||
from: id,
|
||||
to: event.peerId,
|
||||
timestamp: new Date()
|
||||
};
|
||||
console.log('Sending accept to match:', message);
|
||||
await Effect.runPromise(sendMessage(message));
|
||||
await processUpdates([{ peerId: event.peerId, message, sent: true }]);
|
||||
} else if (event.newState === PeerState.AcceptFrom) {
|
||||
console.log(`Accepted match from ${event.peerId}`);
|
||||
// We received an accept to match, start match
|
||||
// or ignore
|
||||
const message = {
|
||||
messageType: LobbyMessageType.Match,
|
||||
from: id,
|
||||
to: event.peerId,
|
||||
timestamp: new Date()
|
||||
};
|
||||
console.log('Sending match:', message);
|
||||
await Effect.runPromise(sendMessage(message));
|
||||
await processUpdates([{ peerId: event.peerId, message, sent: true }]);
|
||||
} else if (event.newState === PeerState.Success) {
|
||||
console.log(`Match started with ${event.peerId}`);
|
||||
const matchId = hash(
|
||||
id.localeCompare(event.peerId) < 0 ? `${id}-${event.peerId}` : `${event.peerId}-${id}`
|
||||
);
|
||||
console.log(event.message);
|
||||
console.log(event.message.timestamp);
|
||||
const params: MatchParams = {
|
||||
matchId,
|
||||
myPeerId: id,
|
||||
otherPeerId: event.peerId,
|
||||
startTime: new Date(new Date(event.message.timestamp).getTime() + 10_000),
|
||||
messages: 20,
|
||||
strategy: Strategy.PingPong
|
||||
};
|
||||
// if we sent a match, then start the simulation
|
||||
// stop responding to lobby messages, as we should now be in a match
|
||||
if (fiber) {
|
||||
fiber();
|
||||
}
|
||||
if (pingFiber) {
|
||||
Effect.runFork(Fiber.interrupt(pingFiber));
|
||||
}
|
||||
if (subscription) {
|
||||
subscription.unsubscribe([lobbyDecoder.contentTopic]);
|
||||
}
|
||||
resume(Effect.succeed(params));
|
||||
} else if (event.newState === PeerState.Ongoing) {
|
||||
console.log(`Match ongoing with ${event.peerId}`);
|
||||
|
||||
// if we sent a match, then start the simulation
|
||||
// stop responding to lobby messages, as we should now be in a match
|
||||
if (fiber) {
|
||||
fiber();
|
||||
}
|
||||
if (pingFiber) {
|
||||
Effect.runFork(Fiber.interrupt(pingFiber));
|
||||
}
|
||||
if (subscription) {
|
||||
subscription.unsubscribe([lobbyDecoder.contentTopic]);
|
||||
}
|
||||
if (event.message.match) {
|
||||
const params: MatchParams = event.message.match;
|
||||
resume(Effect.succeed(params));
|
||||
} else {
|
||||
resume(Effect.fail(new Error('No match found')));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fiber = subscribeToStateTransitionStream(stateTransitionStream, handleStateTransition);
|
||||
setTimeout(() => {
|
||||
pingFiber = Effect.runFork(Effect.repeat(ping, pingSchedule));
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
return Effect.runPromise(getMatchParams);
|
||||
}
|
||||
27
examples/sds-demo/src/routes/+layout.svelte
Normal file
27
examples/sds-demo/src/routes/+layout.svelte
Normal file
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
// import "../tailwind.css";
|
||||
import "../app.css";
|
||||
import Header from "$lib/components/Header.svelte";
|
||||
import ConnectionIndicator from "$lib/components/ConnectionIndicator.svelte";
|
||||
import ActionModule from "$lib/components/ActionModule.svelte";
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
import { onMount } from 'svelte';
|
||||
import { loadIdenticon } from '$lib/identicon.svelte';
|
||||
|
||||
onMount(async () => {
|
||||
await loadIdenticon();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="layout-container" class="fixed inset-0 overflow-hidden flex flex-col">
|
||||
<Header>
|
||||
<!-- <ActionModule /> -->
|
||||
<ConnectionIndicator />
|
||||
</Header>
|
||||
<div class="flex-1 overflow-auto my-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
|
||||
2
examples/sds-demo/src/routes/+layout.ts
Normal file
2
examples/sds-demo/src/routes/+layout.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
60
examples/sds-demo/src/routes/+page.svelte
Normal file
60
examples/sds-demo/src/routes/+page.svelte
Normal file
@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import ConnectionButton from "$lib/components/ConnectionButton.svelte";
|
||||
import { connectionState } from "$lib/waku/waku.svelte";
|
||||
import PageLayout from "$lib/components/PageLayout.svelte";
|
||||
import CallToAction from "$lib/components/CallToAction.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
// Redirect to history page when connected
|
||||
$effect(() => {
|
||||
if ($connectionState.status === "connected") {
|
||||
goto('/state-graph');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageLayout title="Scalable Data Sync" maxWidth="md">
|
||||
<div class="flex justify-center">
|
||||
<div class="w-64 h-64 overflow-hidden flex items-center justify-center perspective-500">
|
||||
<img
|
||||
src="/waku-mark-primary-black.svg"
|
||||
alt="Waku Logo"
|
||||
class="w-full h-full transform scale-125 {$connectionState.status === 'connecting' || $connectionState.status === 'waiting_for_peers' || $connectionState.status === 'setting_up_subscriptions' || $connectionState.status === 'connected' ? 'animate-spin-y' : ''}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CallToAction
|
||||
message="Connect to the Waku network to get started"
|
||||
useSlot={true}
|
||||
marginTop="sm:mt-0 mt-10"
|
||||
messageMarginBottom="mb-4"
|
||||
>
|
||||
<div class="w-full flex justify-center">
|
||||
<ConnectionButton size="large" />
|
||||
</div>
|
||||
</CallToAction>
|
||||
</PageLayout>
|
||||
|
||||
<style>
|
||||
.perspective-500 {
|
||||
perspective: 500px;
|
||||
}
|
||||
|
||||
@keyframes spin-y {
|
||||
0% {
|
||||
transform: scale(1.25) rotateY(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.25) rotateY(180deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.25) rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-y {
|
||||
animation: spin-y 10s infinite linear;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
</style>
|
||||
|
||||
38
examples/sds-demo/src/routes/history/+page.svelte
Normal file
38
examples/sds-demo/src/routes/history/+page.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { connectionState } from "$lib/waku/waku.svelte";
|
||||
import History from "$lib/components/History.svelte";
|
||||
import Missing from "$lib/components/Missing.svelte";
|
||||
import PageLayout from "$lib/components/PageLayout.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
// Redirect to home page if not connected
|
||||
onMount(() => {
|
||||
if ($connectionState.status !== "connected") {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
// Also watch for disconnection
|
||||
$effect(() => {
|
||||
if ($connectionState.status !== "connected") {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 sm:gap-6 px-4">
|
||||
<div class="flex-1 w-full">
|
||||
<PageLayout title="History" maxWidth="2xl" padding="sm:p-6 px-4" margin="sm:my-6 my-4">
|
||||
<p class="text-gray-600 text-sm mb-4 leading-relaxed">Log of all events as they are emitted by the message channel.</p>
|
||||
<History />
|
||||
</PageLayout>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 w-full">
|
||||
<PageLayout title="Missing" maxWidth="2xl" padding="sm:p-6 px-4" margin="sm:my-6 my-4">
|
||||
<p class="text-gray-600 text-sm mb-4 leading-relaxed">List of messages that are currently known to be missing from the channel.</p>
|
||||
<Missing />
|
||||
</PageLayout>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
81
examples/sds-demo/src/routes/lobby/+page.svelte
Normal file
81
examples/sds-demo/src/routes/lobby/+page.svelte
Normal file
@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import ConnectionButton from '$lib/components/ConnectionButton.svelte';
|
||||
import { connectionState, joinLobby } from '$lib/waku/waku.svelte';
|
||||
import PageLayout from '$lib/components/PageLayout.svelte';
|
||||
import CallToAction from '$lib/components/CallToAction.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { setMatch } from '$lib/utils/match.svelte';
|
||||
import { getOrCreateChannel } from '$lib/sds/channel.svelte';
|
||||
// Redirect to history page when connected
|
||||
let matchFound = $state(false);
|
||||
$effect(() => {
|
||||
if ($connectionState.status === 'connected' && matchFound) {
|
||||
goto('/state-graph');
|
||||
}
|
||||
});
|
||||
|
||||
const afterConnect = (status: (state: string) => void) => {
|
||||
status('Finding match...');
|
||||
joinLobby()
|
||||
.then((params) => {
|
||||
getOrCreateChannel(params.matchId);
|
||||
setMatch(params);
|
||||
matchFound = true;
|
||||
status('Match found!');
|
||||
})
|
||||
.catch((error) => {
|
||||
status('Error finding match ' + error);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<PageLayout title="Scalable Data Sync" maxWidth="md">
|
||||
<div class="flex justify-center">
|
||||
<div class="perspective-500 flex h-64 w-64 items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src="/waku-mark-primary-black.svg"
|
||||
alt="Waku Logo"
|
||||
class="h-full w-full scale-125 transform {$connectionState.status === 'connecting' ||
|
||||
$connectionState.status === 'waiting_for_peers' ||
|
||||
$connectionState.status === 'setting_up_subscriptions' ||
|
||||
$connectionState.status === 'connected'
|
||||
? 'animate-spin-y'
|
||||
: ''}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CallToAction
|
||||
message="Connect to the Waku network to get started"
|
||||
useSlot={true}
|
||||
marginTop="sm:mt-0 mt-10"
|
||||
messageMarginBottom="mb-4"
|
||||
>
|
||||
<div class="flex w-full justify-center">
|
||||
<ConnectionButton size="large" afterConnect={afterConnect} />
|
||||
</div>
|
||||
</CallToAction>
|
||||
</PageLayout>
|
||||
|
||||
<style>
|
||||
.perspective-500 {
|
||||
perspective: 500px;
|
||||
}
|
||||
|
||||
@keyframes spin-y {
|
||||
0% {
|
||||
transform: scale(1.25) rotateY(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.25) rotateY(180deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.25) rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-y {
|
||||
animation: spin-y 10s infinite linear;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
</style>
|
||||
125
examples/sds-demo/src/routes/state-graph/+page.svelte
Normal file
125
examples/sds-demo/src/routes/state-graph/+page.svelte
Normal file
@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import History from '$lib/components/History.svelte';
|
||||
import StateGraphSummary from '$lib/components/StateGraphSummary.svelte';
|
||||
import Missing from '$lib/components/Missing.svelte';
|
||||
import { getMatch } from '$lib/utils/match.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { MatchParams } from '$lib/waku/waku.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { start } from '$lib/waku/pingpong.svelte';
|
||||
let match = $state<MatchParams | undefined>(undefined);
|
||||
onMount(() => {
|
||||
match = getMatch();
|
||||
if (!match) {
|
||||
goto('/lobby');
|
||||
} else {
|
||||
start(match);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if match}
|
||||
<div class="main-container">
|
||||
<!-- History Sidebar -->
|
||||
<div class="state-graph-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Event History</h2>
|
||||
</div>
|
||||
<History channelId={match?.matchId ?? null} />
|
||||
</div>
|
||||
|
||||
<div class="state-container">
|
||||
<!-- Summary State Graph -->
|
||||
<div class="state-graph-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Events by Lamport Timestamp</h2>
|
||||
</div>
|
||||
<StateGraphSummary channelId={match?.matchId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-graph-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Missed Messages</h2>
|
||||
</div>
|
||||
<Missing channelId={match?.matchId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
gap: 20px;
|
||||
overflow: hidden;
|
||||
background-color: #f5f2e8; /* Light beige background similar to example */
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
flex: 0 0 400px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.state-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.state-graph-panel {
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
border: 1px solid #e0ddd4;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 20px;
|
||||
color: #333333;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
padding: 0 0 10px 0;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.panel-header h2::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: #e0ddd4;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.main-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
flex: 0 0 300px;
|
||||
}
|
||||
|
||||
.state-container {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
BIN
examples/sds-demo/static/favicon.ico
Normal file
BIN
examples/sds-demo/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
3
examples/sds-demo/static/waku-mark-primary-black.svg
Normal file
3
examples/sds-demo/static/waku-mark-primary-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
15
examples/sds-demo/svelte.config.js
Normal file
15
examples/sds-demo/svelte.config.js
Normal file
@ -0,0 +1,15 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
19
examples/sds-demo/tsconfig.json
Normal file
19
examples/sds-demo/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
7
examples/sds-demo/vite.config.ts
Normal file
7
examples/sds-demo/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user