diff --git a/package.json b/package.json index b0dc3763..465551d5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", "lint": "prettier --check src && eslint src", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", "format:all": "prettier --write src", @@ -38,7 +39,8 @@ "tailwindcss": "^3.4.19", "typescript": "^5.9.3", "typescript-eslint": "^8.53.1", - "vite": "^5.4.21" + "vite": "^5.4.21", + "vitest": "^2.1.9" }, "type": "module", "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f6bebf7..3ca2b101 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: vite: specifier: ^5.4.21 version: 5.4.21(@types/node@25.0.10)(terser@5.46.0) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.0.10)(terser@5.46.0) packages: @@ -2036,6 +2039,35 @@ packages: '@vite-pwa/assets-generator': optional: true + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@welshman/app@0.8.12': resolution: {integrity: sha512-kRp+AVzn4i3FvZmdlyMknFUAb/5SnUz9A/cFKkDqWHsd+N3PbNcL2ZOlV9v5NI77GtsDF2ez6PEQfsZxWvkS/g==} peerDependencies: @@ -2221,6 +2253,10 @@ packages: resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} engines: {node: '>=12.0.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -2361,6 +2397,10 @@ packages: cbor-x@1.6.0: resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -2369,6 +2409,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chevrotain@7.1.1: resolution: {integrity: sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==} @@ -2646,6 +2690,10 @@ packages: resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==} engines: {node: '>=8.6'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2786,6 +2834,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2895,6 +2946,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2903,6 +2957,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3553,6 +3611,9 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3920,6 +3981,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -4458,6 +4526,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4525,6 +4596,12 @@ packages: split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4677,10 +4754,28 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} @@ -4881,6 +4976,11 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-plugin-pwa@0.21.2: resolution: {integrity: sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==} engines: {node: '>=16.0.0'} @@ -4932,6 +5032,31 @@ packages: vite: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -4975,6 +5100,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -7167,6 +7297,46 @@ snapshots: optionalDependencies: '@vite-pwa/assets-generator': 0.2.6 + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.0.10)(terser@5.46.0) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@welshman/app@0.8.12(3074ef6691f94dc03952d8dbc98013a7)': dependencies: '@pomade/core': 0.2.2(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3)) @@ -7363,6 +7533,8 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} async-function@1.0.0: {} @@ -7508,6 +7680,14 @@ snapshots: optionalDependencies: cbor-extract: 2.2.0 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -7519,6 +7699,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + chevrotain@7.1.1: dependencies: regexp-to-ast: 0.5.0 @@ -7827,6 +8009,8 @@ snapshots: decode-bmp: 0.2.1 to-data-view: 1.1.0 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -8014,6 +8198,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -8178,10 +8364,16 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} events@3.3.0: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -8790,6 +8982,8 @@ snapshots: loglevel@1.9.2: {} + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@11.2.4: {} @@ -9165,6 +9359,10 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} + pend@1.2.0: {} picocolors@1.1.1: {} @@ -9727,6 +9925,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} simple-plist@1.3.1: @@ -9792,6 +9992,10 @@ snapshots: dependencies: through: 2.3.8 + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -10026,11 +10230,21 @@ snapshots: through@2.3.8: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + tippy.js@6.3.7: dependencies: '@popperjs/core': 2.11.8 @@ -10223,6 +10437,24 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + vite-node@2.1.9(@types/node@25.0.10)(terser@5.46.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@25.0.10)(terser@5.46.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@0.2.6)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0))(workbox-build@7.3.0)(workbox-window@7.3.0): dependencies: debug: 4.4.3 @@ -10250,6 +10482,41 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@25.0.10)(terser@5.46.0) + vitest@2.1.9(@types/node@25.0.10)(terser@5.46.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@25.0.10)(terser@5.46.0) + vite-node: 2.1.9(@types/node@25.0.10)(terser@5.46.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.10 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + w3c-keyname@2.2.8: {} webidl-conversions@3.0.1: {} @@ -10318,6 +10585,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} diff --git a/src/app/components/Content.svelte b/src/app/components/Content.svelte index 963177a6..f89ac5c1 100644 --- a/src/app/components/Content.svelte +++ b/src/app/components/Content.svelte @@ -24,6 +24,7 @@ import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" + import ContentText from "@app/components/ContentText.svelte" import ContentToken from "@app/components/ContentToken.svelte" import ContentEmoji from "@app/components/ContentEmoji.svelte" import ContentCode from "@app/components/ContentCode.svelte" @@ -155,6 +156,8 @@ {#each shortContent as parsed, i} {#if isNewline(parsed) && !isBlock(i - 1)} + {:else if isText(parsed)} + {:else if isTopic(parsed)} {:else if isEmoji(parsed)} diff --git a/src/app/components/ContentMinimal.svelte b/src/app/components/ContentMinimal.svelte index d90667fb..43c29d3f 100644 --- a/src/app/components/ContentMinimal.svelte +++ b/src/app/components/ContentMinimal.svelte @@ -22,6 +22,7 @@ import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" + import ContentText from "@app/components/ContentText.svelte" import ContentToken from "@app/components/ContentToken.svelte" import ContentEmoji from "@app/components/ContentEmoji.svelte" import ContentCode from "@app/components/ContentCode.svelte" @@ -105,6 +106,8 @@ {#each shortContent as parsed, i} {#if isNewline(parsed)} + {:else if isText(parsed)} + {:else if isTopic(parsed)} {:else if isEmoji(parsed)} diff --git a/src/app/components/ContentText.svelte b/src/app/components/ContentText.svelte new file mode 100644 index 00000000..f3347347 --- /dev/null +++ b/src/app/components/ContentText.svelte @@ -0,0 +1,24 @@ + + +{#each parts as part, i (i)} + {#if part.type === "room"} + {part.value} + {:else if part.type === "relay"} + {part.value} + {:else} + {part.value} + {/if} +{/each} diff --git a/src/app/editor/RoomReferenceExtension.ts b/src/app/editor/RoomReferenceExtension.ts new file mode 100644 index 00000000..5a134764 --- /dev/null +++ b/src/app/editor/RoomReferenceExtension.ts @@ -0,0 +1,42 @@ +import {mergeAttributes, Node} from "@tiptap/core" +import {RoomReferenceNodeView} from "@app/editor/RoomReferenceNodeView" + +export const RoomReferenceExtension = Node.create({ + name: "roomref", + + atom: true, + + inline: true, + + group: "inline", + + selectable: true, + + priority: 1000, + + addAttributes() { + return { + url: {default: undefined}, + h: {default: undefined}, + } + }, + + parseHTML() { + return [{tag: `span[data-type="${this.name}"]`}] + }, + + renderHTML({HTMLAttributes}) { + return ["span", mergeAttributes(HTMLAttributes, {"data-type": this.name}), "~"] + }, + + renderText({node}) { + const url = typeof node.attrs.url === "string" ? node.attrs.url : "" + const h = typeof node.attrs.h === "string" ? node.attrs.h : "" + + return `${url}'${h}` + }, + + addNodeView() { + return RoomReferenceNodeView + }, +}) diff --git a/src/app/editor/RoomReferenceNodeView.ts b/src/app/editor/RoomReferenceNodeView.ts new file mode 100644 index 00000000..c6503d4e --- /dev/null +++ b/src/app/editor/RoomReferenceNodeView.ts @@ -0,0 +1,28 @@ +import type {NodeViewRendererProps} from "@tiptap/core" +import {deriveRoom} from "@app/core/state" + +export const RoomReferenceNodeView = ({node}: NodeViewRendererProps) => { + const dom = document.createElement("span") + const url = typeof node.attrs.url === "string" ? node.attrs.url : "" + const h = typeof node.attrs.h === "string" ? node.attrs.h : "" + const room = deriveRoom(url, h) + + dom.classList.add("tiptap-object") + + const unsubRoom = room.subscribe($room => { + dom.textContent = `~${$room.name || h}` + }) + + return { + dom, + destroy: () => { + unsubRoom() + }, + selectNode() { + dom.classList.add("tiptap-active") + }, + deselectNode() { + dom.classList.remove("tiptap-active") + }, + } +} diff --git a/src/app/editor/RoomSuggestion.svelte b/src/app/editor/RoomSuggestion.svelte new file mode 100644 index 00000000..87cc80ca --- /dev/null +++ b/src/app/editor/RoomSuggestion.svelte @@ -0,0 +1,17 @@ + + +
+
~{$room.name || h}
+
{displayRelayUrl(url)}'{h}
+
diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index 8ea78606..46432657 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -14,12 +14,20 @@ import { getWotGraph, } from "@welshman/app" import type {FileAttributes} from "@welshman/editor" -import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor" +import { + Editor, + MentionSuggestion, + TippySuggestion, + WelshmanExtension, + editorProps, +} from "@welshman/editor" import {escapeHtml} from "@lib/html" import {makeMentionNodeView} from "@app/editor/MentionNodeView" import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte" +import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension" +import RoomSuggestion from "@app/editor/RoomSuggestion.svelte" import {uploadFile} from "@app/core/commands" -import {deriveSpaceMembers} from "@app/core/state" +import {deriveSpaceMembers, splitRoomId, userSpaceUrls, roomsByUrl} from "@app/core/state" import {pushToast} from "@app/util/toast" export const makeEditor = async ({ @@ -82,12 +90,40 @@ export const makeEditor = async ({ }, ) + const roomReferenceSearch = derived( + [throttled(800, userSpaceUrls), throttled(800, roomsByUrl)], + ([$userSpaceUrls, $roomsByUrl]) => { + const options: Array<{id: string; name: string; h: string; url: string}> = [] + + for (const roomUrl of $userSpaceUrls) { + for (const room of $roomsByUrl.get(roomUrl) || []) { + options.push({ + id: room.id, + name: room.name || "", + h: room.h, + url: roomUrl, + }) + } + } + + return createSearch(options, { + getValue: item => item.id, + fuseOptions: { + keys: ["name", "h", "url"], + threshold: 0.3, + shouldSort: false, + }, + }) + }, + ) + return new Editor({ content: escapeHtml(content), autofocus, editorProps, element: document.createElement("div"), extensions: [ + RoomReferenceExtension, WelshmanExtension.configure({ submit, extensions: { @@ -129,6 +165,29 @@ export const makeEditor = async ({ mount(ProfileSuggestion, {target, props: {value, url}}) + return target + }, + }), + TippySuggestion({ + char: "~", + name: "roomref", + editor: (this as any).editor, + search: (term: string) => get(roomReferenceSearch).searchValues(term), + updateSignal: roomReferenceSearch, + select: (id: string, props) => { + const [roomUrl, h] = splitRoomId(id) + + if (!roomUrl || !h) { + return + } + + return props.command({url: roomUrl, h}) + }, + createSuggestion: (value: string) => { + const target = document.createElement("div") + + mount(RoomSuggestion, {target, props: {value}}) + return target }, }), diff --git a/src/lib/content-text.ts b/src/lib/content-text.ts new file mode 100644 index 00000000..27e2ab7d --- /dev/null +++ b/src/lib/content-text.ts @@ -0,0 +1,95 @@ +import {isRelayUrl, normalizeRelayUrl} from "@welshman/util" + +export type ContentTextPart = + | {type: "text"; value: string} + | {type: "room"; value: string; url: string; h: string} + | {type: "relay"; value: string; url: string} + +const CONTENT_URL_PATTERN = /wss?:\/\/\S+/g +const TRAILING_PUNCTUATION_PATTERN = /[),.!?;:\]}]+$/ + +const appendTextPart = (parts: ContentTextPart[], text: string) => { + if (!text) { + return + } + + const last = parts.at(-1) + + if (last?.type === "text") { + last.value += text + + return + } + + parts.push({type: "text", value: text}) +} + +const splitTokenSuffix = (token: string) => { + const suffixMatch = token.match(TRAILING_PUNCTUATION_PATTERN) + const suffix = suffixMatch?.[0] || "" + + if (!suffix) { + return {trimmed: token, suffix} + } + + return { + trimmed: token.slice(0, -suffix.length), + suffix, + } +} + +const toRoomPart = (token: string) => { + const separatorIndex = token.indexOf("'") + + if (separatorIndex === -1) { + return undefined + } + + const url = token.slice(0, separatorIndex) + const h = token.slice(separatorIndex + 1) + + if (!h || !isRelayUrl(url)) { + return undefined + } + + return {type: "room", value: token, url: normalizeRelayUrl(url), h} as const +} + +const toRelayPart = (token: string) => { + if (!isRelayUrl(token)) { + return undefined + } + + return {type: "relay", value: token, url: normalizeRelayUrl(token)} as const +} + +export const parseContentTextParts = (text: string) => { + const parts: ContentTextPart[] = [] + let lastIndex = 0 + + for (const match of text.matchAll(CONTENT_URL_PATTERN)) { + const start = match.index || 0 + + appendTextPart(parts, text.slice(lastIndex, start)) + + const token = match[0] + const {trimmed, suffix} = splitTokenSuffix(token) + const roomPart = toRoomPart(trimmed) + const relayPart = toRelayPart(trimmed) + + if (roomPart) { + parts.push(roomPart) + } else if (relayPart) { + parts.push(relayPart) + } else { + appendTextPart(parts, trimmed) + } + + appendTextPart(parts, suffix) + lastIndex = start + token.length + } + + appendTextPart(parts, text.slice(lastIndex)) + + return parts +} diff --git a/tests/content-text.test.ts b/tests/content-text.test.ts new file mode 100644 index 00000000..b04e29b2 --- /dev/null +++ b/tests/content-text.test.ts @@ -0,0 +1,47 @@ +import {describe, expect, it} from "vitest" +import {parseContentTextParts} from "../src/lib/content-text" + +describe("parseContentTextParts", () => { + it("parses room references as relay_url'room_id", () => { + const parts = parseContentTextParts("Join wss://relay.example.com'general now") + + expect(parts).toHaveLength(3) + expect(parts[0]).toEqual({type: "text", value: "Join "}) + expect(parts[1]).toMatchObject({ + type: "room", + value: "wss://relay.example.com'general", + h: "general", + }) + expect(parts[2]).toEqual({type: "text", value: " now"}) + }) + + it("parses relay urls as relay parts", () => { + const parts = parseContentTextParts("Try wss://relay.example.com") + + expect(parts).toHaveLength(2) + expect(parts[0]).toEqual({type: "text", value: "Try "}) + expect(parts[1]).toMatchObject({ + type: "relay", + value: "wss://relay.example.com", + }) + }) + + it("keeps trailing punctuation outside links", () => { + const parts = parseContentTextParts("See wss://relay.example.com'chat), thanks") + + expect(parts).toHaveLength(3) + expect(parts[0]).toEqual({type: "text", value: "See "}) + expect(parts[1]).toMatchObject({ + type: "room", + value: "wss://relay.example.com'chat", + h: "chat", + }) + expect(parts[2]).toEqual({type: "text", value: "), thanks"}) + }) + + it("leaves non-relay urls as plain text", () => { + const parts = parseContentTextParts("https://example.com/path") + + expect(parts).toEqual([{type: "text", value: "https://example.com/path"}]) + }) +}) \ No newline at end of file