mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
Merge branch 'next'
This commit is contained in:
commit
6d1cfb7afa
5
.changeset/bright-shirts-explain.md
Normal file
5
.changeset/bright-shirts-explain.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add offline mode
|
5
.changeset/cool-clouds-flash.md
Normal file
5
.changeset/cool-clouds-flash.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Restore scroll position when returning to the timeline
|
5
.changeset/cuddly-planets-dress.md
Normal file
5
.changeset/cuddly-planets-dress.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Show quotes as mentions in notifications
|
5
.changeset/empty-dots-sip.md
Normal file
5
.changeset/empty-dots-sip.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add basic support for flare video kind
|
5
.changeset/fast-bats-jump.md
Normal file
5
.changeset/fast-bats-jump.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Show unavailable events in threads
|
5
.changeset/fifty-zebras-punch.md
Normal file
5
.changeset/fifty-zebras-punch.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Support kind 16 generic reposts
|
5
.changeset/fluffy-coats-obey.md
Normal file
5
.changeset/fluffy-coats-obey.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for using nostr-relay-tray as cache relay
|
5
.changeset/four-forks-repeat.md
Normal file
5
.changeset/four-forks-repeat.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
count nevent and naddr as pubkey mentions
|
5
.changeset/gentle-icons-occur.md
Normal file
5
.changeset/gentle-icons-occur.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Rebuild tools menu
|
5
.changeset/great-terms-flash.md
Normal file
5
.changeset/great-terms-flash.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Improve display of unknown events
|
5
.changeset/green-jobs-kiss.md
Normal file
5
.changeset/green-jobs-kiss.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add track view for stemstr tracks
|
5
.changeset/grumpy-paws-judge.md
Normal file
5
.changeset/grumpy-paws-judge.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for local image proxy and cors servers
|
5
.changeset/heavy-suits-yell.md
Normal file
5
.changeset/heavy-suits-yell.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Show NIP-05 verified icons in @ mentions
|
5
.changeset/kind-jobs-matter.md
Normal file
5
.changeset/kind-jobs-matter.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add POW option when writing note
|
5
.changeset/little-rockets-breathe.md
Normal file
5
.changeset/little-rockets-breathe.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for nsecBunker OAuth flow
|
5
.changeset/long-radios-fly.md
Normal file
5
.changeset/long-radios-fly.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix issue with search relays getting reset
|
5
.changeset/many-poets-exist.md
Normal file
5
.changeset/many-poets-exist.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Remove ackee
|
5
.changeset/mighty-hairs-boil.md
Normal file
5
.changeset/mighty-hairs-boil.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add threads notifications view
|
5
.changeset/nasty-pumpkins-dance.md
Normal file
5
.changeset/nasty-pumpkins-dance.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for bunker://npub@relay NIP-46 login
|
5
.changeset/neat-owls-visit.md
Normal file
5
.changeset/neat-owls-visit.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add search when selecting list in feed
|
5
.changeset/new-planes-warn.md
Normal file
5
.changeset/new-planes-warn.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Improve channel message layout
|
5
.changeset/poor-donuts-admire.md
Normal file
5
.changeset/poor-donuts-admire.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add NIP-66 relay stats service
|
5
.changeset/popular-tools-breathe.md
Normal file
5
.changeset/popular-tools-breathe.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for OAuth signup flow
|
5
.changeset/small-comics-try.md
Normal file
5
.changeset/small-comics-try.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add NIP definitions when hovering over "NIP-xx"
|
5
.changeset/spotty-cars-bake.md
Normal file
5
.changeset/spotty-cars-bake.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add messages to launchpad
|
5
.changeset/stale-rabbits-appear.md
Normal file
5
.changeset/stale-rabbits-appear.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix bug with stuck timelines
|
5
.changeset/strange-oranges-unite.md
Normal file
5
.changeset/strange-oranges-unite.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add CACHE_RELAY option to docker container
|
5
.changeset/tall-shrimps-obey.md
Normal file
5
.changeset/tall-shrimps-obey.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Overhaul core relay code
|
5
.changeset/thick-bobcats-sing.md
Normal file
5
.changeset/thick-bobcats-sing.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Upgrade nostr-tools to v2
|
5
.changeset/thin-teachers-end.md
Normal file
5
.changeset/thin-teachers-end.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add simple bookmarks view
|
5
.changeset/thirty-ants-cross.md
Normal file
5
.changeset/thirty-ants-cross.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Show Videos an articles on bookmark list
|
5
.changeset/weak-shirts-help.md
Normal file
5
.changeset/weak-shirts-help.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add Simple Satellite CDN view
|
5
.changeset/yellow-bugs-rest.md
Normal file
5
.changeset/yellow-bugs-rest.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for .mp3 and .wav urls
|
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.github
|
||||
.vscode
|
||||
.changeset
|
||||
scripts
|
||||
screenshots
|
||||
relay
|
16
.vscode/settings.json
vendored
16
.vscode/settings.json
vendored
@ -1,3 +1,17 @@
|
||||
{
|
||||
"cSpell.words": ["Bech", "Chakra", "damus", "lnurl", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"]
|
||||
"cSpell.words": [
|
||||
"Bech",
|
||||
"Chakra",
|
||||
"damus",
|
||||
"lnurl",
|
||||
"Msat",
|
||||
"nostr",
|
||||
"noStrudel",
|
||||
"Npub",
|
||||
"pubkeys",
|
||||
"Sats",
|
||||
"webln"
|
||||
],
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
10
README.md
10
README.md
@ -28,6 +28,16 @@ I would recommend you use a browser extension like [Alby](https://getalby.com/)
|
||||
docker run --rm -p 8080:80 ghcr.io/hzrd149/nostrudel:master
|
||||
```
|
||||
|
||||
## Docker compose and other services
|
||||
|
||||
noStrudels docker image has a few options for connecting to other services running locally
|
||||
|
||||
- `CACHE_RELAY`: if set the client will use the relay to cache all of its events instead of storing them in the browser cache
|
||||
- `IMAGE_PROXY`: can be set to a local [imageproxy](https://github.com/willnorris/imageproxy) instance so the app can resize profile images
|
||||
- `CORS_PROXY`: can be set to a local [cors-anywhere](https://github.com/Rob--W/cors-anywhere) instance so the app can proxy http request
|
||||
|
||||
You can find a full example of all these services in the [docker-compose.yaml](./docker-compose.yaml)
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
|
29
docker-compose.yaml
Normal file
29
docker-compose.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
version: "3.7"
|
||||
|
||||
volumes:
|
||||
data: {}
|
||||
|
||||
services:
|
||||
cors:
|
||||
image: ghcr.io/hzrd149/docker-cors-anywhere:0.4.5
|
||||
environment:
|
||||
CORSANYWHERE_REQUIRE_HEADERS: "host"
|
||||
imageproxy:
|
||||
image: ghcr.io/willnorris/imageproxy:v0.11.2
|
||||
relay:
|
||||
image: scsibug/nostr-rs-relay:0.8.13
|
||||
volumes:
|
||||
- data:/usr/src/app/db
|
||||
app:
|
||||
build: .
|
||||
image: ghcr.io/hzrd149/nostrudel:latest
|
||||
depends_on:
|
||||
- relay
|
||||
- cors
|
||||
- imageproxy
|
||||
environment:
|
||||
CACHE_RELAY: relay:8080
|
||||
IMAGE_PROXY: imageproxy:8080
|
||||
CORS_PROXY: cors:8080
|
||||
ports:
|
||||
- 8080:80
|
110
docker-entrypoint.sh
Executable file
110
docker-entrypoint.sh
Executable file
@ -0,0 +1,110 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
PROXY_PASS_BLOCK=""
|
||||
|
||||
if [ -n "$CACHE_RELAY" ]; then
|
||||
echo "Cache relay set to $CACHE_RELAY"
|
||||
sed -i 's/CACHE_RELAY_ENABLED = false/CACHE_RELAY_ENABLED = true/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /local-relay {
|
||||
proxy_pass http://$CACHE_RELAY/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
"
|
||||
else
|
||||
echo "No cache relay set"
|
||||
fi
|
||||
|
||||
if [ -n "$CORS_PROXY" ]; then
|
||||
echo "CORS proxy set to $CORS_PROXY"
|
||||
sed -i 's/CORS_PROXY_PATH = ""/CORS_PROXY_PATH = "\/corsproxy"/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /corsproxy/ {
|
||||
proxy_pass http://$CORS_PROXY;
|
||||
rewrite ^/corsproxy/(.*) /\$1 break;
|
||||
}
|
||||
"
|
||||
else
|
||||
echo "No CORS proxy set"
|
||||
fi
|
||||
|
||||
if [ -n "$IMAGE_PROXY" ]; then
|
||||
echo "Image proxy set to $IMAGE_PROXY"
|
||||
sed -i 's/IMAGE_PROXY_PATH = ""/IMAGE_PROXY_PATH = "\/imageproxy"/g' /usr/share/nginx/html/index.html
|
||||
PROXY_PASS_BLOCK="$PROXY_PASS_BLOCK
|
||||
location /imageproxy/ {
|
||||
proxy_pass http://$IMAGE_PROXY;
|
||||
rewrite ^/imageproxy/(.*) /\$1 break;
|
||||
}
|
||||
"
|
||||
else
|
||||
echo "No Image proxy set"
|
||||
fi
|
||||
|
||||
CONF_FILE="/etc/nginx/conf.d/default.conf"
|
||||
NGINX_CONF="
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name localhost;
|
||||
merge_slashes off;
|
||||
|
||||
$PROXY_PASS_BLOCK
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
# Gzip settings
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
application/atom+xml
|
||||
application/geo+json
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/ld+json
|
||||
application/manifest+json
|
||||
application/rdf+xml
|
||||
application/rss+xml
|
||||
application/xhtml+xml
|
||||
application/xml
|
||||
font/eot
|
||||
font/otf
|
||||
font/ttf
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
"
|
||||
echo "$NGINX_CONF" > $CONF_FILE
|
||||
|
||||
_term() {
|
||||
echo "Caught SIGTERM signal!"
|
||||
kill -SIGTERM "$nginx_process" 2>/dev/null
|
||||
}
|
||||
|
||||
nginx -g 'daemon off;' &
|
||||
nginx_process=$!
|
||||
|
||||
trap _term SIGTERM
|
||||
|
||||
wait $nginx_process
|
@ -11,3 +11,11 @@ RUN yarn install && yarn build
|
||||
FROM nginx:stable-alpine-slim
|
||||
EXPOSE 80
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
ENV CACHE_RELAY=""
|
||||
ENV IMAGE_PROXY=""
|
||||
ENV CORS_PROXY=""
|
||||
ADD ./docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod a+x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT "/usr/local/bin/docker-entrypoint.sh"
|
||||
|
@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" dir="auto">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
@ -20,7 +20,11 @@
|
||||
content="https://repository-images.githubusercontent.com/581644549/d5eec580-ba3d-41e8-87db-58c313bf3f45"
|
||||
/>
|
||||
|
||||
%VITE_ANALYTICS_SCRIPT%
|
||||
<script>
|
||||
window.CACHE_RELAY_ENABLED = false;
|
||||
window.IMAGE_PROXY_PATH = "";
|
||||
window.CORS_PROXY_PATH = "";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
76
package.json
76
package.json
@ -3,38 +3,49 @@
|
||||
"version": "0.37.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hzrd149/nostrudel"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite serve",
|
||||
"dev": "VITE_APP_VERSION=production vite serve",
|
||||
"build": "tsc --project tsconfig.json && vite build",
|
||||
"format": "prettier --ignore-path .prettierignore -w .",
|
||||
"analyze": "npx vite-bundle-visualizer -o ./stats.html",
|
||||
"build-icons": "node ./scripts/build-icons.mjs"
|
||||
"build-icons": "node ./scripts/build-icons.mjs",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "^0.8.2-rc.7",
|
||||
"@chakra-ui/anatomy": "^2.2.1",
|
||||
"@cashu/cashu-ts": "^0.9.0",
|
||||
"@chakra-ui/anatomy": "^2.2.2",
|
||||
"@chakra-ui/breakpoint-utils": "^2.0.8",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/media-query": "^3.3.0",
|
||||
"@chakra-ui/react": "^2.8.1",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/shared-utils": "^2.0.4",
|
||||
"@chakra-ui/styled-system": "^2.9.1",
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@getalby/bitcoin-connect-react": "^2.4.2",
|
||||
"@getalby/bitcoin-connect": "^3.2.1",
|
||||
"@getalby/bitcoin-connect-react": "^3.2.1",
|
||||
"@noble/curves": "^1.3.0",
|
||||
"@noble/hashes": "^1.3.2",
|
||||
"@noble/secp256k1": "^1.7.0",
|
||||
"@react-three/drei": "^9.92.5",
|
||||
"@react-three/fiber": "^8.15.12",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
"bech32": "^2.0.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"chart.js": "^4.4.1",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"chroma-js": "^2.4.2",
|
||||
"dayjs": "^1.11.9",
|
||||
"debug": "^4.3.4",
|
||||
"emojilib": "2",
|
||||
"framer-motion": "^10.16.0",
|
||||
"hls.js": "^1.4.10",
|
||||
"idb": "^7.1.1",
|
||||
"hls.js": "^1.4.14",
|
||||
"idb": "^8.0.0",
|
||||
"identicon.js": "^2.3.3",
|
||||
"iso-language-codes": "^2.0.0",
|
||||
"json-stringify-deterministic": "^1.0.12",
|
||||
@ -43,10 +54,12 @@
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"match-sorter": "^6.3.1",
|
||||
"nanoid": "^5.0.2",
|
||||
"nanoid": "^5.0.4",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"nostr-idb": "^2.1.1",
|
||||
"nostr-tools": "^2.1.3",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-force-graph-2d": "^1.25.1",
|
||||
@ -55,34 +68,35 @@
|
||||
"react-mosaic-component": "^6.1.0",
|
||||
"react-photo-album": "^2.3.0",
|
||||
"react-qr-barcode-scanner": "^1.0.6",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.20",
|
||||
"three": "^0.157.0",
|
||||
"three": "^0.160.0",
|
||||
"three-spritetext": "^1.8.1",
|
||||
"webln": "^0.3.2",
|
||||
"yet-another-react-lightbox": "^3.12.1"
|
||||
"yet-another-react-lightbox": "^3.15.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.26.2",
|
||||
"@types/chroma-js": "^2.4.1",
|
||||
"@types/debug": "^4.1.8",
|
||||
"@changesets/cli": "^2.27.1",
|
||||
"@types/chroma-js": "^2.4.3",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/dom-serial": "^1.0.6",
|
||||
"@types/identicon.js": "^2.3.1",
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"@types/leaflet.locatecontrol": "^0.74.1",
|
||||
"@types/lodash.throttle": "^4.1.7",
|
||||
"@types/ngeohash": "^0.6.4",
|
||||
"@types/react": "^18.2.22",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/three": "^0.157.2",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"@types/identicon.js": "^2.3.4",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/leaflet.locatecontrol": "^0.74.4",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/ngeohash": "^0.6.8",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/three": "^0.160.0",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"camelcase": "^8.0.0",
|
||||
"prettier": "^3.0.2",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.2",
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10",
|
||||
"vite-plugin-pwa": "^0.17.4",
|
||||
"workbox-build": "^7.0.0",
|
||||
"workbox-window": "^7.0.0"
|
||||
@ -90,5 +104,9 @@
|
||||
"resolutions": {
|
||||
"@types/react": "^18.2.22",
|
||||
"@types/react-dom": "^18.2.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "lightning",
|
||||
"url": "lightning:nostrudel@geyser.fund"
|
||||
}
|
||||
}
|
||||
|
21
patches/@react-three+fiber+8.15.12.patch
Normal file
21
patches/@react-three+fiber+8.15.12.patch
Normal file
@ -0,0 +1,21 @@
|
||||
diff --git a/node_modules/@react-three/fiber/dist/declarations/src/three-types.d.ts b/node_modules/@react-three/fiber/dist/declarations/src/three-types.d.ts
|
||||
index 7b2a48a..12120f2 100644
|
||||
--- a/node_modules/@react-three/fiber/dist/declarations/src/three-types.d.ts
|
||||
+++ b/node_modules/@react-three/fiber/dist/declarations/src/three-types.d.ts
|
||||
@@ -383,10 +383,10 @@ export interface ThreeElements {
|
||||
fogExp2: FogExp2Props;
|
||||
shape: ShapeProps;
|
||||
}
|
||||
-declare global {
|
||||
- namespace JSX {
|
||||
- interface IntrinsicElements extends ThreeElements {
|
||||
- }
|
||||
- }
|
||||
-}
|
||||
+// declare global {
|
||||
+// namespace JSX {
|
||||
+// interface IntrinsicElements extends ThreeElements {
|
||||
+// }
|
||||
+// }
|
||||
+// }
|
||||
export {};
|
165
src/app.tsx
165
src/app.tsx
@ -5,16 +5,16 @@ import { css, Global } from "@emotion/react";
|
||||
|
||||
import { ErrorBoundary } from "./components/error-boundary";
|
||||
import Layout from "./components/layout";
|
||||
import { PageProviders } from "./providers";
|
||||
import DrawerSubViewProvider from "./providers/drawer-sub-view-provider";
|
||||
import useSetColorMode from "./hooks/use-set-color-mode";
|
||||
|
||||
import HomeView from "./views/home/index";
|
||||
import DVMFeedHomeView from "./views/dvm-feed/index";
|
||||
import SettingsView from "./views/settings";
|
||||
import NostrLinkView from "./views/link";
|
||||
import ProfileView from "./views/profile";
|
||||
import HashTagView from "./views/hashtag";
|
||||
import ThreadView from "./views/note";
|
||||
import ThreadView from "./views/thread";
|
||||
import NotificationsView from "./views/notifications";
|
||||
import DirectMessagesView from "./views/dms";
|
||||
import DirectMessageChatView from "./views/dms/chat";
|
||||
@ -41,8 +41,8 @@ import MutedByView from "./views/user/muted-by";
|
||||
import UserArticlesTab from "./views/user/articles";
|
||||
const UserTorrentsTab = lazy(() => import("./views/user/torrents"));
|
||||
|
||||
import ListsView from "./views/lists";
|
||||
import ListDetailsView from "./views/lists/list-details";
|
||||
import ListsHomeView from "./views/lists";
|
||||
import ListView from "./views/lists/list";
|
||||
import BrowseListView from "./views/lists/browse";
|
||||
|
||||
import EmojiPacksBrowseView from "./views/emoji-packs/browse";
|
||||
@ -68,20 +68,36 @@ import CommunityTrendingView from "./views/community/views/trending";
|
||||
|
||||
import RelaysView from "./views/relays";
|
||||
import RelayView from "./views/relays/relay";
|
||||
import RelayReviewsView from "./views/relays/reviews";
|
||||
import PopularRelaysView from "./views/relays/popular";
|
||||
import BrowseRelaySetsView from "./views/relays/browse-sets";
|
||||
import UserDMsTab from "./views/user/dms";
|
||||
import DMFeedView from "./views/tools/dm-feed";
|
||||
import ContentDiscoveryView from "./views/tools/content-discovery";
|
||||
import ContentDiscoveryDVMView from "./views/tools/content-discovery/dvm";
|
||||
import DMTimelineView from "./views/tools/dm-timeline";
|
||||
import LoginNostrConnectView from "./views/signin/nostr-connect";
|
||||
import ThreadsNotificationsView from "./views/notifications/threads";
|
||||
import DVMFeedView from "./views/dvm-feed/feed";
|
||||
import TransformNoteView from "./views/tools/transform-note";
|
||||
import SatelliteCDNView from "./views/tools/satellite-cdn";
|
||||
import OtherStuffView from "./views/other-stuff";
|
||||
import { RouteProviders } from "./providers/route";
|
||||
import LaunchpadView from "./views/launchpad";
|
||||
import VideosView from "./views/videos";
|
||||
import VideoDetailsView from "./views/videos/video";
|
||||
import BookmarksView from "./views/bookmarks";
|
||||
import CacheRelayView from "./views/relays/cache";
|
||||
import RelaySetView from "./views/relays/relay-set";
|
||||
import AppRelays from "./views/relays/app";
|
||||
import MailboxesView from "./views/relays/mailboxes";
|
||||
import LoginNostrAddressView from "./views/signin/address";
|
||||
import LoginNostrAddressCreate from "./views/signin/address/create";
|
||||
const TracksView = lazy(() => import("./views/tracks"));
|
||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||
const UserVideosTab = lazy(() => import("./views/user/videos"));
|
||||
|
||||
const ToolsHomeView = lazy(() => import("./views/tools"));
|
||||
const NetworkView = lazy(() => import("./views/tools/network"));
|
||||
const StreamModerationView = lazy(() => import("./views/tools/stream-moderation"));
|
||||
const WotTestView = lazy(() => import("./views/tools/wot-test"));
|
||||
const StreamModerationView = lazy(() => import("./views/streams/dashboard"));
|
||||
const NetworkMuteGraphView = lazy(() => import("./views/tools/network-mute-graph"));
|
||||
const NetworkDMGraphView = lazy(() => import("./views/tools/network-dm-graph"));
|
||||
const UnknownTimelineView = lazy(() => import("./views/tools/unknown-event-feed"));
|
||||
|
||||
const UserStreamsTab = lazy(() => import("./views/user/streams"));
|
||||
const StreamsView = lazy(() => import("./views/streams"));
|
||||
@ -126,14 +142,24 @@ const RootPage = () => {
|
||||
useSetColorMode();
|
||||
|
||||
return (
|
||||
<PageProviders>
|
||||
<RouteProviders>
|
||||
<Layout>
|
||||
<ScrollRestoration />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</PageProviders>
|
||||
</RouteProviders>
|
||||
);
|
||||
};
|
||||
const NoLayoutPage = () => {
|
||||
return (
|
||||
<RouteProviders>
|
||||
<ScrollRestoration />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</RouteProviders>
|
||||
);
|
||||
};
|
||||
|
||||
@ -146,50 +172,58 @@ const router = createHashRouter([
|
||||
{ path: "npub", element: <LoginNpubView /> },
|
||||
{ path: "nip05", element: <LoginNip05View /> },
|
||||
{ path: "nsec", element: <LoginNsecView /> },
|
||||
{
|
||||
path: "address",
|
||||
children: [
|
||||
{ path: "", element: <LoginNostrAddressView /> },
|
||||
{ path: "create", element: <LoginNostrAddressCreate /> },
|
||||
],
|
||||
},
|
||||
{ path: "nostr-connect", element: <LoginNostrConnectView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "signup",
|
||||
element: <NoLayoutPage />,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: (
|
||||
<PageProviders>
|
||||
<SignupView />
|
||||
</PageProviders>
|
||||
),
|
||||
element: <SignupView />,
|
||||
},
|
||||
{
|
||||
path: ":step",
|
||||
element: (
|
||||
<PageProviders>
|
||||
<SignupView />
|
||||
</PageProviders>
|
||||
),
|
||||
element: <SignupView />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "streams/:naddr",
|
||||
path: "streams/moderation",
|
||||
element: (
|
||||
<PageProviders>
|
||||
<StreamView />
|
||||
</PageProviders>
|
||||
<RouteProviders>
|
||||
<StreamModerationView />
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "tools/stream-moderation",
|
||||
path: "streams/:naddr",
|
||||
element: (
|
||||
<PageProviders>
|
||||
<StreamModerationView />
|
||||
</PageProviders>
|
||||
<RouteProviders>
|
||||
<StreamView />
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "map",
|
||||
element: <MapView />,
|
||||
},
|
||||
{
|
||||
path: "launchpad",
|
||||
element: (
|
||||
<RouteProviders>
|
||||
<LaunchpadView />
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <RootPage />,
|
||||
@ -204,6 +238,7 @@ const router = createHashRouter([
|
||||
{ path: "articles", element: <UserArticlesTab /> },
|
||||
{ path: "streams", element: <UserStreamsTab /> },
|
||||
{ path: "tracks", element: <UserTracksTab /> },
|
||||
{ path: "videos", element: <UserVideosTab /> },
|
||||
{ path: "zaps", element: <UserZapsTab /> },
|
||||
{ path: "likes", element: <UserReactionsTab /> },
|
||||
{ path: "lists", element: <UserListsTab /> },
|
||||
@ -222,17 +257,48 @@ const router = createHashRouter([
|
||||
path: "/n/:id",
|
||||
element: <ThreadView />,
|
||||
},
|
||||
{ path: "other-stuff", element: <OtherStuffView /> },
|
||||
{ path: "settings", element: <SettingsView /> },
|
||||
{
|
||||
path: "relays",
|
||||
element: <RelaysView />,
|
||||
children: [
|
||||
{ path: "", element: <RelaysView /> },
|
||||
{ path: "popular", element: <PopularRelaysView /> },
|
||||
{ path: "reviews", element: <RelayReviewsView /> },
|
||||
{ path: "", element: <AppRelays /> },
|
||||
{ path: "app", element: <AppRelays /> },
|
||||
{ path: "cache", element: <CacheRelayView /> },
|
||||
{ path: "mailboxes", element: <MailboxesView /> },
|
||||
{ path: "sets", element: <BrowseRelaySetsView /> },
|
||||
{ path: ":id", element: <RelaySetView /> },
|
||||
],
|
||||
},
|
||||
{ path: "r/:relay", element: <RelayView /> },
|
||||
{ path: "notifications", element: <NotificationsView /> },
|
||||
{
|
||||
path: "notifications",
|
||||
children: [
|
||||
{ path: "threads", element: <ThreadsNotificationsView /> },
|
||||
{ path: "", element: <NotificationsView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "videos",
|
||||
children: [
|
||||
{
|
||||
path: ":naddr",
|
||||
element: <VideoDetailsView />,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: <VideosView />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "dvm",
|
||||
children: [
|
||||
{ path: ":addr", element: <DVMFeedView /> },
|
||||
{ path: "", element: <DVMFeedHomeView /> },
|
||||
],
|
||||
},
|
||||
{ path: "search", element: <SearchView /> },
|
||||
{
|
||||
path: "dm",
|
||||
@ -244,25 +310,28 @@ const router = createHashRouter([
|
||||
path: "tools",
|
||||
children: [
|
||||
{ path: "", element: <ToolsHomeView /> },
|
||||
{
|
||||
path: "content-discovery",
|
||||
children: [
|
||||
{ path: "", element: <ContentDiscoveryView /> },
|
||||
{ path: ":pubkey", element: <ContentDiscoveryDVMView /> },
|
||||
],
|
||||
},
|
||||
{ path: "network", element: <NetworkView /> },
|
||||
{ path: "wot-test", element: <WotTestView /> },
|
||||
{ path: "network-mute-graph", element: <NetworkMuteGraphView /> },
|
||||
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
|
||||
{ path: "dm-feed", element: <DMFeedView /> },
|
||||
{ path: "dm-timeline", element: <DMTimelineView /> },
|
||||
{ path: "transform/:id", element: <TransformNoteView /> },
|
||||
{ path: "satellite-cdn", element: <SatelliteCDNView /> },
|
||||
{ path: "unknown", element: <UnknownTimelineView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "lists",
|
||||
children: [
|
||||
{ path: "", element: <ListsView /> },
|
||||
{ path: "", element: <ListsHomeView /> },
|
||||
{ path: "browse", element: <BrowseListView /> },
|
||||
{ path: ":addr", element: <ListDetailsView /> },
|
||||
{ path: ":addr", element: <ListView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "bookmarks",
|
||||
children: [
|
||||
{ path: ":pubkey", element: <BookmarksView /> },
|
||||
{ path: "", element: <BookmarksView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -331,6 +400,10 @@ const router = createHashRouter([
|
||||
path: "streams",
|
||||
element: <StreamsView />,
|
||||
},
|
||||
{
|
||||
path: "tracks",
|
||||
element: <TracksView />,
|
||||
},
|
||||
{ path: "l/:link", element: <NostrLinkView /> },
|
||||
{ path: "t/:hashtag", element: <HashTagView /> },
|
||||
{
|
||||
|
@ -1,14 +1,10 @@
|
||||
import { getEventUID, isReplaceable } from "../helpers/nostr/events";
|
||||
import { getEventUID, isReplaceable, sortByDate } from "../helpers/nostr/events";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import { NostrEvent, isDTag } from "../types/nostr-event";
|
||||
import Subject from "./subject";
|
||||
|
||||
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
|
||||
|
||||
function sortByDate(a: NostrEvent, b: NostrEvent) {
|
||||
return b.created_at - a.created_at;
|
||||
}
|
||||
|
||||
export default class EventStore {
|
||||
name?: string;
|
||||
events = new Map<string, NostrEvent>();
|
||||
|
@ -1,15 +1,11 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import stringify from "json-stringify-deterministic";
|
||||
|
||||
import { Subject } from "./subject";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { NostrOutgoingRequest, NostrRequestFilter, RelayQueryMap } from "../types/nostr-query";
|
||||
import Relay, { IncomingEvent } from "./relay";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
|
||||
function isFilterEqual(a: NostrRequestFilter, b: NostrRequestFilter) {
|
||||
return stringify(a) === stringify(b);
|
||||
}
|
||||
import { isFilterEqual, isQueryMapEqual } from "../helpers/nostr/filter";
|
||||
|
||||
export default class NostrMultiSubscription {
|
||||
static INIT = "initial";
|
||||
@ -62,7 +58,7 @@ export default class NostrMultiSubscription {
|
||||
}
|
||||
|
||||
setQueryMap(queryMap: RelayQueryMap) {
|
||||
if (isFilterEqual(this.queryMap, queryMap)) return;
|
||||
if (isQueryMapEqual(this.queryMap, queryMap)) return;
|
||||
|
||||
// add and remove relays
|
||||
for (const url of Object.keys(queryMap)) {
|
||||
@ -129,6 +125,9 @@ export default class NostrMultiSubscription {
|
||||
|
||||
return this;
|
||||
}
|
||||
waitForConnection(): Promise<void> {
|
||||
return Promise.all(this.relays.map((r) => r.waitForConnection())).then((v) => void 0);
|
||||
}
|
||||
close() {
|
||||
if (this.state !== NostrMultiSubscription.OPEN) return this;
|
||||
|
||||
|
@ -1,16 +1,11 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import { isReplaceable } from "../helpers/nostr/events";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import createDefer from "./deferred";
|
||||
import Relay, { IncomingCommandResult } from "./relay";
|
||||
import Subject, { PersistentSubject } from "./subject";
|
||||
|
||||
import { addToLog } from "../services/publish-log";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import eventExistsService from "../services/event-exists";
|
||||
|
||||
export default class NostrPublishAction {
|
||||
id = nanoid();
|
||||
label: string;
|
||||
@ -23,9 +18,9 @@ export default class NostrPublishAction {
|
||||
|
||||
private remaining = new Set<Relay>();
|
||||
|
||||
constructor(label: string, relays: string[], event: NostrEvent, timeout: number = 5000) {
|
||||
constructor(label: string, relays: Iterable<string>, event: NostrEvent, timeout: number = 5000) {
|
||||
this.label = label;
|
||||
this.relays = relays;
|
||||
this.relays = Array.from(relays);
|
||||
this.event = event;
|
||||
|
||||
for (const url of relays) {
|
||||
@ -38,16 +33,6 @@ export default class NostrPublishAction {
|
||||
}
|
||||
|
||||
setTimeout(this.handleTimeout.bind(this), timeout);
|
||||
|
||||
addToLog(this);
|
||||
|
||||
// if this is replaceable, mirror it over to the replaceable event service
|
||||
if (isReplaceable(event.kind)) {
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
|
||||
// pass the event along to the eventExistsService
|
||||
eventExistsService.handleEvent(event);
|
||||
}
|
||||
|
||||
private handleResult(result: IncomingCommandResult) {
|
||||
|
@ -21,9 +21,9 @@ export default class NostrRequest {
|
||||
onComplete = createDefer<void>();
|
||||
seenEvents = new Set<string>();
|
||||
|
||||
constructor(relayUrls: string[], timeout?: number) {
|
||||
constructor(relayUrls: Iterable<string>, timeout?: number) {
|
||||
this.id = nanoid();
|
||||
this.relays = new Set(relayUrls.map((url) => relayPoolService.requestRelay(url)));
|
||||
this.relays = new Set(Array.from(relayUrls).map((url) => relayPoolService.requestRelay(url)));
|
||||
|
||||
for (const relay of this.relays) {
|
||||
relay.onEOSE.subscribe(this.handleEOSE, this);
|
||||
|
@ -19,7 +19,7 @@ export default class NostrSubscription {
|
||||
onEvent = new Subject<NostrEvent>();
|
||||
onEOSE = new Subject<IncomingEOSE>();
|
||||
|
||||
constructor(relayUrl: string, query?: NostrRequestFilter, name?: string) {
|
||||
constructor(relayUrl: string | URL, query?: NostrRequestFilter, name?: string) {
|
||||
this.id = nanoid();
|
||||
this.query = query;
|
||||
this.name = name;
|
||||
|
42
src/classes/relay-set.ts
Normal file
42
src/classes/relay-set.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { getRelaysFromMailbox } from "../helpers/nostr/mailbox";
|
||||
import { safeRelayUrl } from "../helpers/relay";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { RelayMode } from "./relay";
|
||||
|
||||
export default class RelaySet extends Set<string> {
|
||||
get urls() {
|
||||
return Array.from(this);
|
||||
}
|
||||
getRelays() {
|
||||
return this.urls.map((url) => relayPoolService.requestRelay(url, false));
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new RelaySet(this);
|
||||
}
|
||||
merge(src: Iterable<string>): this {
|
||||
for (const url of src) this.add(url);
|
||||
return this;
|
||||
}
|
||||
|
||||
static from(...sources: (Iterable<string> | undefined)[]) {
|
||||
const set = new RelaySet();
|
||||
for (const src of sources) {
|
||||
if (!src) continue;
|
||||
for (const url of src) {
|
||||
const safe = safeRelayUrl(url);
|
||||
if (safe) set.add(safe);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
static fromNIP65Event(event: NostrEvent, mode: RelayMode = RelayMode.ALL) {
|
||||
return new RelaySet(
|
||||
getRelaysFromMailbox(event)
|
||||
.filter((r) => r.mode & mode)
|
||||
.map((r) => r.url),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { offlineMode } from "../services/offline-mode";
|
||||
import relayScoreboardService from "../services/relay-scoreboard";
|
||||
import { RawIncomingNostrEvent, NostrEvent, CountResponse } from "../types/nostr-event";
|
||||
import { NostrOutgoingMessage } from "../types/nostr-query";
|
||||
import { Subject } from "./subject";
|
||||
import createDefer, { Deferred } from "./deferred";
|
||||
import { PersistentSubject, Subject } from "./subject";
|
||||
|
||||
export type IncomingEvent = {
|
||||
type: "EVENT";
|
||||
@ -24,7 +26,6 @@ export type IncomingEOSE = {
|
||||
subId: string;
|
||||
relay: Relay;
|
||||
};
|
||||
// NIP-20
|
||||
export type IncomingCommandResult = {
|
||||
type: "OK";
|
||||
eventId: string;
|
||||
@ -39,12 +40,12 @@ export enum RelayMode {
|
||||
WRITE = 2,
|
||||
ALL = 1 | 2,
|
||||
}
|
||||
export type RelayConfig = { url: string; mode: RelayMode };
|
||||
|
||||
const CONNECTION_TIMEOUT = 1000 * 30;
|
||||
|
||||
export default class Relay {
|
||||
url: string;
|
||||
status = new PersistentSubject<number>(WebSocket.CLOSED);
|
||||
onOpen = new Subject<Relay>(undefined, false);
|
||||
onClose = new Subject<Relay>(undefined, false);
|
||||
onEvent = new Subject<IncomingEvent>(undefined, false);
|
||||
@ -53,7 +54,8 @@ export default class Relay {
|
||||
onEOSE = new Subject<IncomingEOSE>(undefined, false);
|
||||
onCommandResult = new Subject<IncomingCommandResult>(undefined, false);
|
||||
ws?: WebSocket;
|
||||
mode: RelayMode = RelayMode.ALL;
|
||||
|
||||
private connectionPromises: Deferred<void>[] = [];
|
||||
|
||||
private connectionTimer?: () => void;
|
||||
private ejectTimer?: () => void;
|
||||
@ -61,12 +63,13 @@ export default class Relay {
|
||||
private subscriptionResTimer = new Map<string, () => void>();
|
||||
private queue: NostrOutgoingMessage[] = [];
|
||||
|
||||
constructor(url: string, mode: RelayMode = RelayMode.ALL) {
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
open() {
|
||||
if (offlineMode.value) return;
|
||||
|
||||
if (this.okay) return;
|
||||
this.intentionalClose = false;
|
||||
this.ws = new WebSocket(this.url);
|
||||
@ -77,6 +80,9 @@ export default class Relay {
|
||||
if (this.connectionTimer) {
|
||||
this.connectionTimer();
|
||||
this.connectionTimer = undefined;
|
||||
|
||||
for (const p of this.connectionPromises) p.reject();
|
||||
this.connectionPromises = [];
|
||||
}
|
||||
// relayScoreboardService.relayTimeouts.get(this.url).addIncident();
|
||||
}, CONNECTION_TIMEOUT);
|
||||
@ -92,6 +98,7 @@ export default class Relay {
|
||||
this.ws.onopen = () => {
|
||||
window.clearTimeout(connectionTimeout);
|
||||
this.onOpen.next(this);
|
||||
this.status.next(this.ws!.readyState);
|
||||
|
||||
this.ejectTimer = relayScoreboardService.relayEjectTime.get(this.url).createTimer();
|
||||
if (this.connectionTimer) {
|
||||
@ -100,9 +107,13 @@ export default class Relay {
|
||||
}
|
||||
|
||||
this.sendQueued();
|
||||
|
||||
for (const p of this.connectionPromises) p.resolve();
|
||||
this.connectionPromises = [];
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.onClose.next(this);
|
||||
this.status.next(this.ws!.readyState);
|
||||
|
||||
if (!this.intentionalClose && this.ejectTimer) {
|
||||
this.ejectTimer();
|
||||
@ -112,16 +123,14 @@ export default class Relay {
|
||||
this.ws.onmessage = this.handleMessage.bind(this);
|
||||
}
|
||||
send(json: NostrOutgoingMessage) {
|
||||
if (this.mode & RelayMode.WRITE) {
|
||||
if (this.connected) {
|
||||
this.ws?.send(JSON.stringify(json));
|
||||
if (this.connected) {
|
||||
this.ws?.send(JSON.stringify(json));
|
||||
|
||||
// record start time
|
||||
if (json[0] === "REQ" || json[0] === "COUNT") {
|
||||
this.startSubResTimer(json[1]);
|
||||
}
|
||||
} else this.queue.push(json);
|
||||
}
|
||||
// record start time
|
||||
if (json[0] === "REQ" || json[0] === "COUNT") {
|
||||
this.startSubResTimer(json[1]);
|
||||
}
|
||||
} else this.queue.push(json);
|
||||
}
|
||||
close() {
|
||||
this.ws?.close();
|
||||
@ -129,6 +138,13 @@ export default class Relay {
|
||||
this.subscriptionResTimer.clear();
|
||||
}
|
||||
|
||||
waitForConnection(): Promise<void> {
|
||||
if (this.connected) return Promise.resolve();
|
||||
const p = createDefer<void>();
|
||||
this.connectionPromises.push(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
private startSubResTimer(sub: string) {
|
||||
this.subscriptionResTimer.set(sub, relayScoreboardService.relayResponseTimes.get(this.url).createTimer());
|
||||
}
|
||||
@ -169,11 +185,8 @@ export default class Relay {
|
||||
}
|
||||
|
||||
handleMessage(event: MessageEvent<string>) {
|
||||
// skip empty events
|
||||
if (!event.data) return;
|
||||
|
||||
if (!(this.mode & RelayMode.READ)) return;
|
||||
|
||||
try {
|
||||
const data: RawIncomingNostrEvent = JSON.parse(event.data);
|
||||
const type = data[0];
|
||||
@ -199,7 +212,7 @@ export default class Relay {
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Relay: Failed to parse event from ${this.url}`);
|
||||
console.log(event.data);
|
||||
console.log(event.data, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Debugger } from "debug";
|
||||
import { Filter, matchFilters } from "nostr-tools";
|
||||
import _throttle from "lodash.throttle";
|
||||
|
||||
import { NostrEvent, isATag, isETag } from "../types/nostr-event";
|
||||
import { NostrRequestFilter, RelayQueryMap } from "../types/nostr-query";
|
||||
@ -11,9 +13,17 @@ import EventStore from "./event-store";
|
||||
import { isReplaceable } from "../helpers/nostr/events";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import deleteEventService from "../services/delete-events";
|
||||
import { addQueryToFilter, isFilterEqual, mapQueryMap } from "../helpers/nostr/filter";
|
||||
import {
|
||||
addQueryToFilter,
|
||||
isFilterEqual,
|
||||
isQueryMapEqual,
|
||||
mapQueryMap,
|
||||
stringifyFilter,
|
||||
} from "../helpers/nostr/filter";
|
||||
import { localRelay } from "../services/local-relay";
|
||||
import { relayRequest } from "../helpers/relay";
|
||||
|
||||
const BLOCK_SIZE = 30;
|
||||
const BLOCK_SIZE = 100;
|
||||
|
||||
export type EventFilter = (event: NostrEvent, store: EventStore) => boolean;
|
||||
|
||||
@ -28,7 +38,7 @@ export class RelayBlockLoader {
|
||||
/** set to true when the next block produces 0 events */
|
||||
complete = false;
|
||||
|
||||
onBlockFinish = new Subject<void>();
|
||||
onBlockFinish = new Subject<number>();
|
||||
|
||||
constructor(relay: string, filter: NostrRequestFilter, log?: Debugger) {
|
||||
this.relay = relay;
|
||||
@ -57,18 +67,18 @@ export class RelayBlockLoader {
|
||||
});
|
||||
request.onComplete.then(() => {
|
||||
this.loading = false;
|
||||
this.log(`Got ${gotEvents} events`);
|
||||
if (gotEvents === 0) {
|
||||
this.complete = true;
|
||||
this.log("Complete");
|
||||
}
|
||||
this.onBlockFinish.next();
|
||||
} else this.log(`Got ${gotEvents} events`);
|
||||
this.onBlockFinish.next(gotEvents);
|
||||
});
|
||||
|
||||
request.start(filter);
|
||||
}
|
||||
|
||||
private handleEvent(event: NostrEvent) {
|
||||
if (!matchFilters(Array.isArray(this.filter) ? this.filter : [this.filter], event)) return;
|
||||
return this.events.addEvent(event);
|
||||
}
|
||||
|
||||
@ -119,25 +129,26 @@ export default class TimelineLoader {
|
||||
this.subscription.onEvent.subscribe(this.handleEvent, this);
|
||||
|
||||
// update the timeline when there are new events
|
||||
this.events.onEvent.subscribe(this.updateTimeline, this);
|
||||
this.events.onDelete.subscribe(this.updateTimeline, this);
|
||||
this.events.onClear.subscribe(this.updateTimeline, this);
|
||||
this.events.onEvent.subscribe(this.throttleUpdateTimeline, this);
|
||||
this.events.onDelete.subscribe(this.throttleUpdateTimeline, this);
|
||||
this.events.onClear.subscribe(this.throttleUpdateTimeline, this);
|
||||
|
||||
deleteEventService.stream.subscribe(this.handleDeleteEvent, this);
|
||||
}
|
||||
|
||||
private throttleUpdateTimeline = _throttle(this.updateTimeline, 10);
|
||||
private updateTimeline() {
|
||||
if (this.eventFilter) {
|
||||
const filter = this.eventFilter;
|
||||
this.timeline.next(this.events.getSortedEvents().filter((e) => filter(e, this.events)));
|
||||
} else this.timeline.next(this.events.getSortedEvents());
|
||||
}
|
||||
private handleEvent(event: NostrEvent) {
|
||||
private handleEvent(event: NostrEvent, cache = true) {
|
||||
// if this is a replaceable event, mirror it over to the replaceable event service
|
||||
if (isReplaceable(event.kind)) {
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
if (isReplaceable(event.kind)) replaceableEventLoaderService.handleEvent(event);
|
||||
|
||||
this.events.addEvent(event);
|
||||
if (cache) localRelay.publish(event);
|
||||
}
|
||||
private handleDeleteEvent(deleteEvent: NostrEvent) {
|
||||
const cord = deleteEvent.tags.find(isATag)?.[1];
|
||||
@ -159,8 +170,22 @@ export default class TimelineLoader {
|
||||
loader.onBlockFinish.unsubscribe(this.updateComplete, this);
|
||||
}
|
||||
|
||||
private loadQueriesFromCache(queryMap: RelayQueryMap) {
|
||||
const queries: Record<string, Filter[]> = {};
|
||||
for (const [url, filters] of Object.entries(queryMap)) {
|
||||
const key = stringifyFilter(filters);
|
||||
if (!queries[key]) queries[key] = Array.isArray(filters) ? filters : [filters];
|
||||
}
|
||||
|
||||
for (const filters of Object.values(queries)) {
|
||||
relayRequest(localRelay, filters).then((events) => {
|
||||
for (const e of events) this.handleEvent(e, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setQueryMap(queryMap: RelayQueryMap) {
|
||||
if (isFilterEqual(this.queryMap, queryMap)) return;
|
||||
if (isQueryMapEqual(this.queryMap, queryMap)) return;
|
||||
|
||||
this.log("set query map", queryMap);
|
||||
|
||||
@ -191,6 +216,9 @@ export default class TimelineLoader {
|
||||
|
||||
this.queryMap = queryMap;
|
||||
|
||||
// load all filters from cache relay
|
||||
this.loadQueriesFromCache(queryMap);
|
||||
|
||||
// update the subscription query map and add limit
|
||||
this.subscription.setQueryMap(
|
||||
mapQueryMap(this.queryMap, (filter) => addQueryToFilter(filter, { limit: BLOCK_SIZE / 2 })),
|
||||
|
12
src/components/back-button.tsx
Normal file
12
src/components/back-button.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { IconButton, IconButtonProps } from "@chakra-ui/react";
|
||||
import { ChevronLeftIcon } from "./icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function BackButton({ ...props }: Omit<IconButtonProps, "onClick" | "children" | "aria-label">) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<IconButton icon={<ChevronLeftIcon />} aria-label="Back" {...props} onClick={() => navigate(-1)}>
|
||||
Back
|
||||
</IconButton>
|
||||
);
|
||||
}
|
23
src/components/blured-image.tsx
Normal file
23
src/components/blured-image.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Box, Image, ImageProps, useDisclosure } from "@chakra-ui/react";
|
||||
|
||||
export default function BlurredImage(props: ImageProps) {
|
||||
const { isOpen, onOpen } = useDisclosure();
|
||||
return (
|
||||
<Box overflow="hidden">
|
||||
<Image
|
||||
onClick={
|
||||
!isOpen
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cursor="pointer"
|
||||
filter={isOpen ? "" : "blur(1.5rem)"}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
29
src/components/blurhash-image.tsx
Normal file
29
src/components/blurhash-image.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { decode } from "blurhash";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type BlurhashImageProps = {
|
||||
blurhash: string;
|
||||
width: number;
|
||||
height: number;
|
||||
} & Omit<BoxProps, "width" | "height" | "children">;
|
||||
|
||||
export default function BlurhashImage({ blurhash, width, height, ...props }: BlurhashImageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.canvas.width = width;
|
||||
ctx.canvas.height = height;
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const pixels = decode(blurhash, width, height);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}, [blurhash, width, height]);
|
||||
|
||||
return <Box as="canvas" ref={canvasRef} {...props} />;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { MenuItem } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { useDeleteEventContext } from "../../providers/delete-event-provider";
|
||||
import { useDeleteEventContext } from "../../providers/route/delete-event-provider";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { TrashIcon } from "../icons";
|
||||
|
||||
|
@ -3,12 +3,12 @@ import { MenuItem } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { MuteIcon, UnmuteIcon } from "../icons";
|
||||
import { useMuteModalContext } from "../../providers/mute-modal-provider";
|
||||
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
|
||||
import { useMuteModalContext } from "../../providers/route/mute-modal-provider";
|
||||
import useUserMuteActions from "../../hooks/use-user-mute-actions";
|
||||
|
||||
export default function MuteUserMenuItem({ event }: { event: NostrEvent }) {
|
||||
const account = useCurrentAccount();
|
||||
const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey);
|
||||
const { isMuted, mute, unmute } = useUserMuteActions(event.pubkey);
|
||||
const { openModal } = useMuteModalContext();
|
||||
|
||||
if (account?.pubkey === event.pubkey) return null;
|
||||
|
@ -1,20 +1,17 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { MenuItem, useToast } from "@chakra-ui/react";
|
||||
import { MenuItem } from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { useSigningContext } from "../../providers/signing-provider";
|
||||
import useUserPinList from "../../hooks/use-user-pin-list";
|
||||
import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event";
|
||||
import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import { PinIcon } from "../icons";
|
||||
import { usePublishEvent } from "../../providers/global/publish-provider";
|
||||
|
||||
export default function PinNoteMenuItem({ event }: { event: NostrEvent }) {
|
||||
const toast = useToast();
|
||||
const publish = usePublishEvent();
|
||||
const account = useCurrentAccount();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const { list } = useUserPinList(account?.pubkey);
|
||||
|
||||
const isPinned = list?.tags.some((t) => isETag(t) && t[1] === event.id) ?? false;
|
||||
@ -22,24 +19,19 @@ export default function PinNoteMenuItem({ event }: { event: NostrEvent }) {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const togglePin = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let draft: DraftNostrEvent = {
|
||||
kind: PIN_LIST_KIND,
|
||||
created_at: dayjs().unix(),
|
||||
content: list?.content ?? "",
|
||||
tags: list?.tags ? Array.from(list.tags) : [],
|
||||
};
|
||||
setLoading(true);
|
||||
let draft: DraftNostrEvent = {
|
||||
kind: PIN_LIST_KIND,
|
||||
created_at: dayjs().unix(),
|
||||
content: list?.content ?? "",
|
||||
tags: list?.tags ? Array.from(list.tags) : [],
|
||||
};
|
||||
|
||||
if (isPinned) draft = listRemoveEvent(draft, event.id);
|
||||
else draft = listAddEvent(draft, event.id);
|
||||
if (isPinned) draft = listRemoveEvent(draft, event.id);
|
||||
else draft = listAddEvent(draft, event.id);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
new NostrPublishAction(label, clientRelaysService.getWriteUrls(), signed);
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ status: "error", description: e.message });
|
||||
}
|
||||
await publish(label, draft);
|
||||
setLoading(false);
|
||||
}, [list, isPinned]);
|
||||
|
||||
if (event.pubkey !== account?.pubkey) return null;
|
||||
|
14
src/components/debug-modal/debug-event-button.tsx
Normal file
14
src/components/debug-modal/debug-event-button.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { IconButton, IconButtonProps } from "@chakra-ui/react";
|
||||
import { CodeIcon } from "../icons";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { useContext } from "react";
|
||||
import { DebugModalContext } from "../../providers/route/debug-modal-provider";
|
||||
|
||||
export default function DebugEventButton({
|
||||
event,
|
||||
...props
|
||||
}: { event: NostrEvent } & Omit<IconButtonProps, "icon" | "aria-label">) {
|
||||
const { open } = useContext(DebugModalContext);
|
||||
|
||||
return <IconButton icon={<CodeIcon />} aria-label="Raw Event" onClick={() => open(event)} {...props} />;
|
||||
}
|
19
src/components/debug-modal/debug-event-menu-item.tsx
Normal file
19
src/components/debug-modal/debug-event-menu-item.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { useContext } from "react";
|
||||
import { MenuItem, MenuItemProps } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import { CodeIcon } from "../icons";
|
||||
import { DebugModalContext } from "../../providers/route/debug-modal-provider";
|
||||
|
||||
export default function DebugEventMenuItem({
|
||||
event,
|
||||
...props
|
||||
}: { event: NostrEvent } & Omit<MenuItemProps, "icon" | "aria-label">) {
|
||||
const { open } = useContext(DebugModalContext);
|
||||
|
||||
return (
|
||||
<MenuItem onClick={() => open(event)} icon={<CodeIcon />} {...props}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
135
src/components/debug-modal/event-debug-modal.tsx
Normal file
135
src/components/debug-modal/event-debug-modal.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { PropsWithChildren, ReactNode, useCallback, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Flex,
|
||||
Button,
|
||||
Heading,
|
||||
Text,
|
||||
AccordionItem,
|
||||
Accordion,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
AccordionButton,
|
||||
Box,
|
||||
ModalHeader,
|
||||
Code,
|
||||
AccordionPanelProps,
|
||||
Card,
|
||||
} from "@chakra-ui/react";
|
||||
import { ModalProps } from "@chakra-ui/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { getContentTagRefs, getEventUID, getThreadReferences } from "../../helpers/nostr/events";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import RawValue from "./raw-value";
|
||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
import { usePublishEvent } from "../../providers/global/publish-provider";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { getEventRelays } from "../../services/event-relays";
|
||||
import { RelayFavicon } from "../relay-favicon";
|
||||
import { CopyIconButton } from "../copy-icon-button";
|
||||
import DebugEventTags from "./event-tags";
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
actions,
|
||||
...props
|
||||
}: PropsWithChildren<{ label: string; actions?: ReactNode }> & Omit<AccordionPanelProps, "children">) {
|
||||
return (
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
{label}
|
||||
</Box>
|
||||
{actions && <div onClick={(e) => e.stopPropagation()}>{actions}</div>}
|
||||
<AccordionIcon ml="2" />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel display="flex" flexDirection="column" gap="2" alignItems="flex-start" {...props}>
|
||||
{children}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonCode({ data }: { data: any }) {
|
||||
return (
|
||||
<Code whiteSpace="pre" overflowX="auto" width="100%" p="4">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventDebugModal({ event, ...props }: { event: NostrEvent } & Omit<ModalProps, "children">) {
|
||||
const publish = usePublishEvent();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const broadcast = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await publish("Broadcast", event);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const eventRelays = useSubject(getEventRelays(getEventUID(event)));
|
||||
|
||||
return (
|
||||
<Modal size="6xl" {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader p="4">{event.id}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody p="0">
|
||||
<Accordion allowToggle>
|
||||
<Section label="IDs">
|
||||
<RawValue heading="Event Id" value={event.id} />
|
||||
<RawValue heading="NIP-19 Encoded Id" value={nip19.noteEncode(event.id)} />
|
||||
<RawValue heading="NIP-19 Pointer" value={getSharableEventAddress(event)} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
label="Content"
|
||||
p="0"
|
||||
actions={<CopyIconButton aria-label="copy json" text={event.content} size="sm" />}
|
||||
>
|
||||
<Code whiteSpace="pre" overflowX="auto" width="100%" p="4">
|
||||
{event.content}
|
||||
</Code>
|
||||
</Section>
|
||||
<Section
|
||||
label="JSON"
|
||||
p="0"
|
||||
actions={<CopyIconButton aria-label="copy json" text={JSON.stringify(event)} size="sm" />}
|
||||
>
|
||||
<JsonCode data={event} />
|
||||
</Section>
|
||||
<Section label="Threading" p="0">
|
||||
<JsonCode data={getThreadReferences(event)} />
|
||||
</Section>
|
||||
<Section label="Tags">
|
||||
<DebugEventTags event={event} />
|
||||
<Heading size="sm">Tags referenced in content</Heading>
|
||||
<JsonCode data={getContentTagRefs(event.content, event.tags)} />
|
||||
</Section>
|
||||
<Section label="Relays">
|
||||
<Heading size="sm">Seen on:</Heading>
|
||||
{eventRelays.map((url) => (
|
||||
<Flex gap="2" key={url} alignItems="center">
|
||||
<RelayFavicon size="sm" relay={url} />
|
||||
<Text fontWeight="bold">{url}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
<Button onClick={broadcast} mr="auto" colorScheme="primary" isLoading={loading}>
|
||||
Broadcast
|
||||
</Button>
|
||||
</Section>
|
||||
</Accordion>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
96
src/components/debug-modal/event-tags.tsx
Normal file
96
src/components/debug-modal/event-tags.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { MouseEventHandler, useCallback } from "react";
|
||||
import { Box, Button, Flex, Link, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import { NostrEvent, nip19 } from "nostr-tools";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { Tag, isATag, isETag, isPTag } from "../../types/nostr-event";
|
||||
import { aTagToAddressPointer, eTagToEventPointer } from "../../helpers/nostr/events";
|
||||
import { EmbedEventPointer } from "../embed-event";
|
||||
import UserAvatarLink from "../user-avatar-link";
|
||||
import UserLink from "../user-link";
|
||||
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
|
||||
|
||||
function EventTag({ tag }: { tag: Tag }) {
|
||||
const expand = useDisclosure();
|
||||
const content = `[${tag[0]}] ${tag.slice(1).join(", ")}`;
|
||||
const props = {
|
||||
fontWeight: "bold",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "1.2em",
|
||||
isTruncated: true,
|
||||
color: "GrayText",
|
||||
};
|
||||
|
||||
const toggle = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
expand.onToggle();
|
||||
},
|
||||
[expand.onToggle],
|
||||
);
|
||||
|
||||
if (isETag(tag)) {
|
||||
const pointer = eTagToEventPointer(tag);
|
||||
return (
|
||||
<>
|
||||
<Link as={RouterLink} to={`/l/${nip19.neventEncode(pointer)}`} onClick={toggle} {...props}>
|
||||
{content}
|
||||
</Link>
|
||||
{expand.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
|
||||
</>
|
||||
);
|
||||
} else if (isATag(tag)) {
|
||||
const pointer = aTagToAddressPointer(tag);
|
||||
return (
|
||||
<>
|
||||
<Link as={RouterLink} to={`/l/${nip19.naddrEncode(pointer)}`} onClick={toggle} {...props}>
|
||||
{content}
|
||||
</Link>
|
||||
{expand.isOpen && <EmbedEventPointer pointer={{ type: "naddr", data: pointer }} />}
|
||||
</>
|
||||
);
|
||||
} else if (isPTag(tag)) {
|
||||
const pubkey = tag[1];
|
||||
return (
|
||||
<>
|
||||
<Link as={RouterLink} to={`/l/${nip19.npubEncode(pubkey)}`} onClick={toggle} {...props}>
|
||||
{content}
|
||||
</Link>
|
||||
{expand.isOpen && (
|
||||
<Flex gap="4" p="2">
|
||||
<UserAvatarLink pubkey={pubkey} />
|
||||
<Box>
|
||||
<UserLink pubkey={pubkey} fontWeight="bold" />
|
||||
<br />
|
||||
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<Text title={content} {...props}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DebugEventTags({ event }: { event: NostrEvent }) {
|
||||
const expand = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="link" color="GrayText" fontFamily="monospace" onClick={expand.onToggle}>
|
||||
[{expand.isOpen ? "-" : "+"}] Tags ({event.tags.length})
|
||||
</Button>
|
||||
{expand.isOpen && (
|
||||
<Flex direction="column" gap="1" px="2" my="2">
|
||||
{event.tags.map((tag, i) => (
|
||||
<EventTag key={i} tag={tag} />
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Flex, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from "@chakra-ui/react";
|
||||
import { ModalProps } from "@chakra-ui/react";
|
||||
import { Kind, nip19 } from "nostr-tools";
|
||||
import { kinds, nip19 } from "nostr-tools";
|
||||
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import RawValue from "./raw-value";
|
||||
@ -13,7 +13,7 @@ export default function UserDebugModal({ pubkey, ...props }: { pubkey: string }
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const nprofile = useSharableProfileId(pubkey);
|
||||
const relays = replaceableEventLoaderService.getEvent(Kind.RelayList, pubkey).value;
|
||||
const relays = replaceableEventLoaderService.getEvent(kinds.RelayList, pubkey).value;
|
||||
const tipMetadata = useUserLNURLMetadata(pubkey);
|
||||
|
||||
return (
|
@ -1,51 +0,0 @@
|
||||
import { Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, Flex } from "@chakra-ui/react";
|
||||
import { ModalProps } from "@chakra-ui/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { NostrEvent, isATag } from "../../types/nostr-event";
|
||||
import RawJson from "./raw-json";
|
||||
import RawValue from "./raw-value";
|
||||
import RawPre from "./raw-pre";
|
||||
import userMetadataService from "../../services/user-metadata";
|
||||
import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
|
||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
|
||||
export default function CommunityPostDebugModal({
|
||||
event,
|
||||
approvals,
|
||||
...props
|
||||
}: { event: NostrEvent; approvals: NostrEvent[] } & Omit<ModalProps, "children">) {
|
||||
const communityCoordinate = event.tags
|
||||
.filter(isATag)
|
||||
.find((t) => t[1].startsWith(COMMUNITY_DEFINITION_KIND + ":"))?.[1];
|
||||
|
||||
return (
|
||||
<Modal {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalCloseButton />
|
||||
<ModalBody p="4">
|
||||
<Flex gap="2" direction="column">
|
||||
<RawValue heading="Event Id" value={event.id} />
|
||||
<RawValue heading="NIP-19 Encoded Id" value={nip19.noteEncode(event.id)} />
|
||||
<RawValue heading="NIP-19 Pointer" value={getSharableEventAddress(event)} />
|
||||
<RawValue heading="Community Coordinate" value={communityCoordinate} />
|
||||
<RawPre heading="Content" value={event.content} />
|
||||
<RawJson heading="JSON" json={event} />
|
||||
{approvals.map((approval) => (
|
||||
<RawJson
|
||||
key={approval.id}
|
||||
heading={`Approval by ${getUserDisplayName(
|
||||
userMetadataService.getSubject(approval.pubkey).value,
|
||||
approval.pubkey,
|
||||
)}`}
|
||||
json={approval}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import { Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, Flex } from "@chakra-ui/react";
|
||||
import { ModalProps } from "@chakra-ui/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { getReferences } from "../../helpers/nostr/events";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import RawJson from "./raw-json";
|
||||
import RawValue from "./raw-value";
|
||||
import RawPre from "./raw-pre";
|
||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
|
||||
export default function NoteDebugModal({ event, ...props }: { event: NostrEvent } & Omit<ModalProps, "children">) {
|
||||
return (
|
||||
<Modal size="6xl" {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalCloseButton />
|
||||
<ModalBody p="4">
|
||||
<Flex gap="2" direction="column">
|
||||
<RawValue heading="Event Id" value={event.id} />
|
||||
<RawValue heading="NIP-19 Encoded Id" value={nip19.noteEncode(event.id)} />
|
||||
<RawValue heading="NIP-19 Pointer" value={getSharableEventAddress(event)} />
|
||||
<RawPre heading="Content" value={event.content} />
|
||||
<RawJson heading="JSON" json={event} />
|
||||
<RawJson heading="References" json={getReferences(event)} />
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -47,9 +47,9 @@ export default function EmbeddedArticle({ article, ...props }: Omit<CardProps, "
|
||||
</LinkOverlay>
|
||||
<Text mb="2">{summary}</Text>
|
||||
{article.tags
|
||||
.filter((t) => t[0] === "t")
|
||||
.map(([_, hashtag]) => (
|
||||
<Tag mr="2" mb="2">
|
||||
.filter((t) => t[0] === "t" && t[1])
|
||||
.map(([_, hashtag]: string[], i) => (
|
||||
<Tag key={hashtag + i} mr="2" mb="2">
|
||||
#{hashtag}
|
||||
</Tag>
|
||||
))}
|
||||
|
@ -8,14 +8,14 @@ import { NostrEvent } from "../../../types/nostr-event";
|
||||
import useChannelMetadata from "../../../hooks/use-channel-metadata";
|
||||
import HoverLinkOverlay from "../../hover-link-overlay";
|
||||
import singleEventService from "../../../services/single-event";
|
||||
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import { useReadRelays } from "../../../hooks/use-client-relays";
|
||||
|
||||
export default function EmbeddedChannel({
|
||||
channel,
|
||||
additionalRelays,
|
||||
...props
|
||||
}: Omit<CardProps, "children"> & { channel: NostrEvent; additionalRelays?: string[] }) {
|
||||
const readRelays = useReadRelayUrls(additionalRelays);
|
||||
const readRelays = useReadRelays(additionalRelays);
|
||||
const { metadata } = useChannelMetadata(channel.id, readRelays);
|
||||
|
||||
if (!channel || !metadata) return null;
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { Card, CardBody, CardHeader, CardProps, LinkBox, Text } from "@chakra-ui/react";
|
||||
import { Card, CardBody, CardHeader, CardProps, IconButton, LinkBox, Text, useDisclosure } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import { TrustProvider } from "../../../providers/local/trust";
|
||||
import UserAvatarLink from "../../user-avatar-link";
|
||||
import UserLink from "../../user-link";
|
||||
import Timestamp from "../../timestamp";
|
||||
import DecryptPlaceholder from "../../../views/dms/components/decrypt-placeholder";
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
|
||||
import { MessageContent } from "../../../views/dms/components/message-bubble";
|
||||
import DirectMessageContent from "../../../views/dms/components/direct-message-content";
|
||||
import DebugEventButton from "../../debug-modal/debug-event-button";
|
||||
|
||||
export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children"> & { dm: NostrEvent }) {
|
||||
const account = useCurrentAccount();
|
||||
@ -27,11 +28,12 @@ export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children">
|
||||
<UserAvatarLink pubkey={receiver} size="xs" />
|
||||
<UserLink pubkey={receiver} fontWeight="bold" isTruncated fontSize="lg" />
|
||||
<Timestamp timestamp={dm.created_at} />
|
||||
<DebugEventButton event={dm} size="sm" variant="outline" ml="auto" />
|
||||
</CardHeader>
|
||||
{(sender === account?.pubkey || receiver === account?.pubkey) && (
|
||||
<CardBody px="2" pt="0" pb="2">
|
||||
<DecryptPlaceholder message={dm}>
|
||||
{(plaintext) => <MessageContent event={dm} text={plaintext} />}
|
||||
{(plaintext) => <DirectMessageContent event={dm} text={plaintext} />}
|
||||
</DecryptPlaceholder>
|
||||
</CardBody>
|
||||
)}
|
||||
|
@ -0,0 +1,63 @@
|
||||
import { Card, CardBody, CardProps, Flex, Heading, Image, Link, Text } from "@chakra-ui/react";
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserLink from "../../user-link";
|
||||
import UserAvatar from "../../user-avatar";
|
||||
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
|
||||
import { getVideoDuration, getVideoImages, getVideoSummary, getVideoTitle } from "../../../helpers/nostr/flare";
|
||||
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||
|
||||
export default function EmbeddedFlareVideo({ video, ...props }: Omit<CardProps, "children"> & { video: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const title = getVideoTitle(video);
|
||||
const { thumb } = getVideoImages(video);
|
||||
const duration = getVideoDuration(video);
|
||||
const summary = getVideoSummary(video);
|
||||
|
||||
const isVertical = useBreakpointValue({ base: true, md: false });
|
||||
const naddr = getSharableEventAddress(video);
|
||||
|
||||
return (
|
||||
<Card {...props} position="relative">
|
||||
<CardBody p="2" gap="2">
|
||||
{isVertical ? (
|
||||
<Image
|
||||
src={thumb}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
onClick={() => navigate(`/videos/${naddr}`)}
|
||||
maxH="2in"
|
||||
mx="auto"
|
||||
mb="2"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={thumb}
|
||||
borderRadius="md"
|
||||
maxH="2in"
|
||||
maxW="30%"
|
||||
mr="2"
|
||||
float="left"
|
||||
cursor="pointer"
|
||||
onClick={() => navigate(`/videos/${naddr}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Heading size="md">
|
||||
<Link as={RouterLink} to={`/videos/${naddr}`}>
|
||||
{title}
|
||||
</Link>
|
||||
</Heading>
|
||||
<Flex gap="2" alignItems="center" my="2">
|
||||
<UserAvatar pubkey={video.pubkey} size="xs" />
|
||||
<Heading size="sm">
|
||||
<UserLink pubkey={video.pubkey} />
|
||||
</Heading>
|
||||
</Flex>
|
||||
<Text noOfLines={2}>{summary}</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -9,7 +9,7 @@ import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
|
||||
import useSubject from "../../../hooks/use-subject";
|
||||
import appSettings from "../../../services/settings/app-settings";
|
||||
import EventVerificationIcon from "../../event-verification-icon";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import { TrustProvider } from "../../../providers/local/trust";
|
||||
import { NoteLink } from "../../note-link";
|
||||
import Timestamp from "../../timestamp";
|
||||
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Card, CardProps, Flex, LinkBox, Spacer, Text } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import { TrustProvider } from "../../../providers/local/trust";
|
||||
import UserAvatarLink from "../../user-avatar-link";
|
||||
import UserLink from "../../user-link";
|
||||
import Timestamp from "../../timestamp";
|
||||
|
13
src/components/embed-event/event-types/embedded-repost.tsx
Normal file
13
src/components/embed-event/event-types/embedded-repost.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { CardProps } from "@chakra-ui/react";
|
||||
import { nip18 } from "nostr-tools";
|
||||
|
||||
import EmbeddedUnknown from "./embedded-unknown";
|
||||
import { EmbedEventPointer } from "..";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
|
||||
export default function EmbeddedRepost({ repost, ...props }: Omit<CardProps, "children"> & { repost: NostrEvent }) {
|
||||
const pointer = nip18.getRepostedEventPointer(repost);
|
||||
|
||||
if (!pointer) return <EmbeddedUnknown event={repost} {...props} />;
|
||||
return <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} {...props} />;
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
@ -8,9 +7,6 @@ import {
|
||||
CardHeader,
|
||||
CardProps,
|
||||
Flex,
|
||||
IconButton,
|
||||
Image,
|
||||
Link,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from "@chakra-ui/react";
|
||||
@ -19,30 +15,17 @@ import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserAvatarLink from "../../user-avatar-link";
|
||||
import UserLink from "../../user-link";
|
||||
import { CompactNoteContent } from "../../compact-note-content";
|
||||
import { getDownloadURL, getHashtags, getStreamURL } from "../../../helpers/nostr/stemstr";
|
||||
import { DownloadIcon, ReplyIcon } from "../../icons";
|
||||
import { getHashtags } from "../../../helpers/nostr/stemstr";
|
||||
import { ReplyIcon } from "../../icons";
|
||||
import NoteZapButton from "../../note/note-zap-button";
|
||||
import { QuoteRepostButton } from "../../note/components/quote-repost-button";
|
||||
import QuoteRepostButton from "../../note/components/quote-repost-button";
|
||||
import Timestamp from "../../timestamp";
|
||||
import { ReactNode } from "react";
|
||||
import { LiveAudioPlayer } from "../../live-audio-player";
|
||||
import TrackStemstrButton from "../../../views/tracks/components/track-stemstr-button";
|
||||
import TrackDownloadButton from "../../../views/tracks/components/track-download-button";
|
||||
import TrackPlayer from "../../../views/tracks/components/track-player";
|
||||
|
||||
// example nevent1qqst32cnyhhs7jt578u7vp3y047dduuwjquztpvwqc43f3nvg8dh28gpzamhxue69uhhyetvv9ujuum5v4khxarj9eshquq4rxdxa
|
||||
export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps, "children"> & { track: NostrEvent }) {
|
||||
const streamUrl = getStreamURL(track);
|
||||
const downloadUrl = getDownloadURL(track);
|
||||
|
||||
let player: ReactNode | null = null;
|
||||
if (streamUrl) {
|
||||
player = <LiveAudioPlayer stream={streamUrl.url} w="full" />;
|
||||
} else if (downloadUrl) {
|
||||
player = (
|
||||
<Box as="audio" controls w="full">
|
||||
<source src={downloadUrl.url} type={downloadUrl.format} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const hashtags = getHashtags(track);
|
||||
|
||||
return (
|
||||
@ -53,7 +36,7 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps
|
||||
<Timestamp ml="auto" timestamp={track.created_at} />
|
||||
</CardHeader>
|
||||
<CardBody p="2" display="flex" gap="2" flexDirection="column">
|
||||
{player}
|
||||
<TrackPlayer track={track} />
|
||||
<CompactNoteContent event={track} />
|
||||
{hashtags.length > 0 && (
|
||||
<Flex wrap="wrap" gap="2">
|
||||
@ -74,26 +57,8 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps
|
||||
<NoteZapButton event={track} />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
{downloadUrl && (
|
||||
<IconButton
|
||||
as={Link}
|
||||
icon={<DownloadIcon />}
|
||||
aria-label="Download"
|
||||
title="Download"
|
||||
href={downloadUrl.url}
|
||||
download
|
||||
isExternal
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
as={Link}
|
||||
leftIcon={<Image src="https://stemstr.app/favicon.svg" />}
|
||||
href={`https://stemstr.app/thread/${track.id}`}
|
||||
colorScheme="purple"
|
||||
isExternal
|
||||
>
|
||||
View on Stemstr
|
||||
</Button>
|
||||
<TrackDownloadButton track={track} />
|
||||
<TrackStemstrButton track={track} />
|
||||
</ButtonGroup>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
@ -8,7 +8,7 @@ import UserLink from "../../user-link";
|
||||
import UserAvatar from "../../user-avatar";
|
||||
import useEventNaddr from "../../../hooks/use-event-naddr";
|
||||
import Timestamp from "../../timestamp";
|
||||
import { useBreakpointValue } from "../../../providers/breakpoint-provider";
|
||||
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
|
||||
|
||||
export default function EmbeddedStream({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
|
||||
const stream = parseStreamEvent(event);
|
||||
|
@ -7,16 +7,16 @@ import UserLink from "../../user-link";
|
||||
import useSubject from "../../../hooks/use-subject";
|
||||
import appSettings from "../../../services/settings/app-settings";
|
||||
import EventVerificationIcon from "../../event-verification-icon";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import { TrustProvider } from "../../../providers/local/trust";
|
||||
import Timestamp from "../../timestamp";
|
||||
import { getNeventForEventId } from "../../../helpers/nip19";
|
||||
import { CompactNoteContent } from "../../compact-note-content";
|
||||
import HoverLinkOverlay from "../../hover-link-overlay";
|
||||
import { getReferences } from "../../../helpers/nostr/events";
|
||||
import { getThreadReferences } from "../../../helpers/nostr/events";
|
||||
import useSingleEvent from "../../../hooks/use-single-event";
|
||||
import { getTorrentTitle } from "../../../helpers/nostr/torrents";
|
||||
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
|
||||
import { MouseEventHandler, useCallback } from "react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
export default function EmbeddedTorrentComment({
|
||||
comment,
|
||||
@ -24,9 +24,9 @@ export default function EmbeddedTorrentComment({
|
||||
}: Omit<CardProps, "children"> & { comment: NostrEvent }) {
|
||||
const navigate = useNavigateInDrawer();
|
||||
const { showSignatureVerification } = useSubject(appSettings);
|
||||
const refs = getReferences(comment);
|
||||
const torrent = useSingleEvent(refs.rootId, refs.rootRelay ? [refs.rootRelay] : []);
|
||||
const linkToTorrent = refs.rootId && `/torrents/${getNeventForEventId(refs.rootId)}`;
|
||||
const refs = getThreadReferences(comment);
|
||||
const torrent = useSingleEvent(refs.root?.e?.id, refs.root?.e?.relays);
|
||||
const linkToTorrent = refs.root?.e && `/torrents/${nip19.neventEncode(refs.root.e)}`;
|
||||
|
||||
const handleClick = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
|
@ -1,43 +1,41 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Button, Card, CardBody, CardHeader, CardProps, Flex, Link, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import { MouseEventHandler, useCallback, useMemo } from "react";
|
||||
import { Box, Button, ButtonGroup, Card, CardBody, CardHeader, CardProps, Link, Text } from "@chakra-ui/react";
|
||||
|
||||
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserAvatarLink from "../../user-avatar-link";
|
||||
import UserLink from "../../user-link";
|
||||
import { truncatedId } from "../../../helpers/nostr/events";
|
||||
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
|
||||
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
|
||||
import {
|
||||
embedEmoji,
|
||||
embedNostrHashtags,
|
||||
embedNostrLinks,
|
||||
embedNostrMentions,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
renderVideoUrl,
|
||||
} from "../../embed-types";
|
||||
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
||||
import Timestamp from "../../timestamp";
|
||||
import { CodeIcon } from "../../icons";
|
||||
import NoteDebugModal from "../../debug-modals/note-debug-modal";
|
||||
import { ExternalLinkIcon } from "../../icons";
|
||||
import { renderAudioUrl } from "../../embed-types/audio";
|
||||
import DebugEventButton from "../../debug-modal/debug-event-button";
|
||||
import DebugEventTags from "../../debug-modal/event-tags";
|
||||
|
||||
export default function EmbeddedUnknown({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
|
||||
const debugModal = useDisclosure();
|
||||
const address = getSharableEventAddress(event);
|
||||
|
||||
const alt = event.tags.find((t) => t[0] === "alt")?.[1];
|
||||
const content = useMemo(() => {
|
||||
let jsx: EmbedableContent = [alt || event.content];
|
||||
let jsx: EmbedableContent = [event.content];
|
||||
jsx = embedNostrLinks(jsx);
|
||||
jsx = embedNostrMentions(jsx, event);
|
||||
jsx = embedNostrHashtags(jsx, event);
|
||||
jsx = embedEmoji(jsx, event);
|
||||
|
||||
jsx = embedUrls(jsx, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
|
||||
jsx = embedUrls(jsx, [renderImageUrl, renderVideoUrl, renderAudioUrl, renderGenericUrl]);
|
||||
|
||||
return jsx;
|
||||
}, [event.content, alt]);
|
||||
}, [event.content]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -46,24 +44,33 @@ export default function EmbeddedUnknown({ event, ...props }: Omit<CardProps, "ch
|
||||
<UserAvatarLink pubkey={event.pubkey} size="xs" />
|
||||
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="md" />
|
||||
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
|
||||
<Link ml="auto" href={address ? buildAppSelectUrl(address) : ""} isExternal>
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
</Link>
|
||||
<Text>kind: {event.kind}</Text>
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
<ButtonGroup ml="auto">
|
||||
<Button
|
||||
as={Link}
|
||||
size="sm"
|
||||
leftIcon={<ExternalLinkIcon />}
|
||||
isExternal
|
||||
href={address ? buildAppSelectUrl(address) : ""}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<DebugEventButton event={event} size="sm" variant="outline" />
|
||||
</ButtonGroup>
|
||||
</CardHeader>
|
||||
<CardBody p="2">
|
||||
<Flex gap="2">
|
||||
<Text>Kind: {event.kind}</Text>
|
||||
<Link href={address ? buildAppSelectUrl(address) : ""} isExternal color="blue.500">
|
||||
{address && truncatedId(address)}
|
||||
</Link>
|
||||
<Button leftIcon={<CodeIcon />} ml="auto" size="sm" variant="outline" onClick={debugModal.onOpen}>
|
||||
View Raw
|
||||
</Button>
|
||||
</Flex>
|
||||
<Box whiteSpace="pre-wrap">{content}</Box>
|
||||
{alt && (
|
||||
<Text isTruncated fontStyle="italic">
|
||||
{alt}
|
||||
</Text>
|
||||
)}
|
||||
<Box whiteSpace="pre-wrap" noOfLines={3}>
|
||||
{content}
|
||||
</Box>
|
||||
{event.tags.length > 0 && <DebugEventTags event={event} />}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{debugModal.isOpen && <NoteDebugModal isOpen={debugModal.isOpen} onClose={debugModal.onClose} event={event} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { lazy } from "react";
|
||||
import { Suspense, lazy } from "react";
|
||||
import type { DecodeResult } from "nostr-tools/lib/types/nip19";
|
||||
import { CardProps } from "@chakra-ui/react";
|
||||
import { Kind, nip19 } from "nostr-tools";
|
||||
import { CardProps, Spinner } from "@chakra-ui/react";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
import EmbeddedNote from "./event-types/embedded-note";
|
||||
import useSingleEvent from "../../hooks/use-single-event";
|
||||
import { NoteLink } from "../note-link";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { STREAM_CHAT_MESSAGE_KIND, STREAM_KIND } from "../../helpers/nostr/stream";
|
||||
import { GOAL_KIND } from "../../helpers/nostr/goal";
|
||||
@ -38,6 +37,10 @@ import { TORRENT_COMMENT_KIND, TORRENT_KIND } from "../../helpers/nostr/torrents
|
||||
import EmbeddedTorrent from "./event-types/embedded-torrent";
|
||||
import EmbeddedTorrentComment from "./event-types/embedded-torrent-comment";
|
||||
import EmbeddedChannel from "./event-types/embedded-channel";
|
||||
import { FLARE_VIDEO_KIND } from "../../helpers/nostr/flare";
|
||||
import EmbeddedFlareVideo from "./event-types/embedded-flare-video";
|
||||
import LoadingNostrLink from "../loading-nostr-link";
|
||||
import EmbeddedRepost from "./event-types/embedded-repost";
|
||||
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
|
||||
|
||||
export type EmbedProps = {
|
||||
@ -49,61 +52,70 @@ export function EmbedEvent({
|
||||
goalProps,
|
||||
...cardProps
|
||||
}: Omit<CardProps, "children"> & { event: NostrEvent } & EmbedProps) {
|
||||
switch (event.kind) {
|
||||
case Kind.Text:
|
||||
return <EmbeddedNote event={event} {...cardProps} />;
|
||||
case Kind.Reaction:
|
||||
return <EmbeddedReaction event={event} {...cardProps} />;
|
||||
case Kind.EncryptedDirectMessage:
|
||||
return <EmbeddedDM dm={event} {...cardProps} />;
|
||||
case STREAM_KIND:
|
||||
return <EmbeddedStream event={event} {...cardProps} />;
|
||||
case GOAL_KIND:
|
||||
return <EmbeddedGoal goal={event} {...cardProps} {...goalProps} />;
|
||||
case EMOJI_PACK_KIND:
|
||||
return <EmbeddedEmojiPack pack={event} {...cardProps} />;
|
||||
case PEOPLE_LIST_KIND:
|
||||
case NOTE_LIST_KIND:
|
||||
case BOOKMARK_LIST_KIND:
|
||||
case COMMUNITIES_LIST_KIND:
|
||||
case CHANNELS_LIST_KIND:
|
||||
return <EmbeddedList list={event} {...cardProps} />;
|
||||
case Kind.Article:
|
||||
return <EmbeddedArticle article={event} {...cardProps} />;
|
||||
case Kind.BadgeDefinition:
|
||||
return <EmbeddedBadge badge={event} {...cardProps} />;
|
||||
case STREAM_CHAT_MESSAGE_KIND:
|
||||
return <EmbeddedStreamMessage message={event} {...cardProps} />;
|
||||
case COMMUNITY_DEFINITION_KIND:
|
||||
return <EmbeddedCommunity community={event} {...cardProps} />;
|
||||
case STEMSTR_TRACK_KIND:
|
||||
return <EmbeddedStemstrTrack track={event} {...cardProps} />;
|
||||
case TORRENT_KIND:
|
||||
return <EmbeddedTorrent torrent={event} {...cardProps} />;
|
||||
case TORRENT_COMMENT_KIND:
|
||||
return <EmbeddedTorrentComment comment={event} {...cardProps} />;
|
||||
case Kind.ChannelCreation:
|
||||
return <EmbeddedChannel channel={event} {...cardProps} />;
|
||||
}
|
||||
const renderContent = () => {
|
||||
switch (event.kind) {
|
||||
case kinds.ShortTextNote:
|
||||
return <EmbeddedNote event={event} {...cardProps} />;
|
||||
case kinds.Reaction:
|
||||
return <EmbeddedReaction event={event} {...cardProps} />;
|
||||
case kinds.EncryptedDirectMessage:
|
||||
return <EmbeddedDM dm={event} {...cardProps} />;
|
||||
case STREAM_KIND:
|
||||
return <EmbeddedStream event={event} {...cardProps} />;
|
||||
case GOAL_KIND:
|
||||
return <EmbeddedGoal goal={event} {...cardProps} {...goalProps} />;
|
||||
case EMOJI_PACK_KIND:
|
||||
return <EmbeddedEmojiPack pack={event} {...cardProps} />;
|
||||
case PEOPLE_LIST_KIND:
|
||||
case NOTE_LIST_KIND:
|
||||
case BOOKMARK_LIST_KIND:
|
||||
case COMMUNITIES_LIST_KIND:
|
||||
case CHANNELS_LIST_KIND:
|
||||
return <EmbeddedList list={event} {...cardProps} />;
|
||||
case kinds.LongFormArticle:
|
||||
return <EmbeddedArticle article={event} {...cardProps} />;
|
||||
case kinds.BadgeDefinition:
|
||||
return <EmbeddedBadge badge={event} {...cardProps} />;
|
||||
case STREAM_CHAT_MESSAGE_KIND:
|
||||
return <EmbeddedStreamMessage message={event} {...cardProps} />;
|
||||
case COMMUNITY_DEFINITION_KIND:
|
||||
return <EmbeddedCommunity community={event} {...cardProps} />;
|
||||
case STEMSTR_TRACK_KIND:
|
||||
return <EmbeddedStemstrTrack track={event} {...cardProps} />;
|
||||
case TORRENT_KIND:
|
||||
return <EmbeddedTorrent torrent={event} {...cardProps} />;
|
||||
case TORRENT_COMMENT_KIND:
|
||||
return <EmbeddedTorrentComment comment={event} {...cardProps} />;
|
||||
case FLARE_VIDEO_KIND:
|
||||
return <EmbeddedFlareVideo video={event} {...cardProps} />;
|
||||
case kinds.ChannelCreation:
|
||||
return <EmbeddedChannel channel={event} {...cardProps} />;
|
||||
case kinds.Repost:
|
||||
case kinds.GenericRepost:
|
||||
return <EmbeddedRepost repost={event} {...cardProps} />;
|
||||
}
|
||||
|
||||
return <EmbeddedUnknown event={event} {...cardProps} />;
|
||||
return <EmbeddedUnknown event={event} {...cardProps} />;
|
||||
};
|
||||
|
||||
return <Suspense fallback={<Spinner />}>{renderContent()}</Suspense>;
|
||||
}
|
||||
|
||||
export function EmbedEventPointer({ pointer, ...props }: { pointer: DecodeResult } & EmbedProps) {
|
||||
switch (pointer.type) {
|
||||
case "note": {
|
||||
const event = useSingleEvent(pointer.data);
|
||||
if (event === undefined) return <NoteLink noteId={pointer.data} />;
|
||||
if (!event) return <LoadingNostrLink link={pointer} />;
|
||||
return <EmbedEvent event={event} {...props} />;
|
||||
}
|
||||
case "nevent": {
|
||||
const event = useSingleEvent(pointer.data.id, pointer.data.relays);
|
||||
if (event === undefined) return <NoteLink noteId={pointer.data.id} />;
|
||||
if (!event) return <LoadingNostrLink link={pointer} />;
|
||||
return <EmbedEvent event={event} {...props} />;
|
||||
}
|
||||
case "naddr": {
|
||||
const event = useReplaceableEvent(pointer.data);
|
||||
if (!event) return <span>{nip19.naddrEncode(pointer.data)}</span>;
|
||||
const event = useReplaceableEvent(pointer.data, pointer.data.relays);
|
||||
if (!event) return <LoadingNostrLink link={pointer} />;
|
||||
return <EmbedEvent event={event} {...props} />;
|
||||
}
|
||||
case "nrelay":
|
||||
|
20
src/components/embed-types/audio.tsx
Normal file
20
src/components/embed-types/audio.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { isAudioURL } from "../../helpers/url";
|
||||
|
||||
const StyledAudio = styled.audio`
|
||||
max-width: 30rem;
|
||||
max-height: 20rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export function renderAudioUrl(match: URL) {
|
||||
if (!isAudioURL(match)) return null;
|
||||
|
||||
return (
|
||||
<StyledAudio controls>
|
||||
<source src={match.toString()} />
|
||||
</StyledAudio>
|
||||
);
|
||||
}
|
@ -6,7 +6,12 @@ import OpenGraphLink from "../open-graph-link";
|
||||
export function renderGenericUrl(match: URL) {
|
||||
return (
|
||||
<Link href={match.toString()} isExternal color="blue.500">
|
||||
{match.toString()}
|
||||
{match.protocol +
|
||||
"//" +
|
||||
match.host +
|
||||
match.pathname +
|
||||
(match.search && match.search.length < 120 ? match.search : "") +
|
||||
(match.hash.length < 96 ? match.hash : "")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { MouseEventHandler, MutableRefObject, forwardRef, useCallback, useMemo, useRef } from "react";
|
||||
import { Image, ImageProps, Link, LinkProps } from "@chakra-ui/react";
|
||||
|
||||
import appSettings from "../../services/settings/app-settings";
|
||||
import { useTrusted } from "../../providers/trust";
|
||||
import { useTrusted } from "../../providers/local/trust";
|
||||
import { EmbedableContent, defaultGetLocation } from "../../helpers/embeds";
|
||||
import { getMatchLink } from "../../helpers/regexp";
|
||||
import { useRegisterSlide } from "../lightbox-provider";
|
||||
@ -10,8 +9,9 @@ import { isImageURL } from "../../helpers/url";
|
||||
import PhotoGallery, { PhotoWithoutSize } from "../photo-gallery";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
import { useBreakpointValue } from "../../providers/breakpoint-provider";
|
||||
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
|
||||
import useElementBlur from "../../hooks/use-element-blur";
|
||||
import { buildImageProxyURL } from "../../helpers/image";
|
||||
|
||||
export type TrustImageProps = ImageProps;
|
||||
|
||||
@ -41,7 +41,7 @@ export type EmbeddedImageProps = Omit<LinkProps, "children" | "href" | "onClick"
|
||||
};
|
||||
|
||||
function useImageThumbnail(src?: string) {
|
||||
return appSettings.value.imageProxy ? new URL(`/256,fit/${src}`, appSettings.value.imageProxy).toString() : src;
|
||||
return (src && buildImageProxyURL(src, "256,fit")) ?? src;
|
||||
}
|
||||
|
||||
export const EmbeddedImage = forwardRef<HTMLImageElement, EmbeddedImageProps>(
|
||||
|
@ -8,3 +8,5 @@ export * from "./emoji";
|
||||
export * from "./image";
|
||||
export * from "./cashu";
|
||||
export * from "./video";
|
||||
export * from "./simplex";
|
||||
export * from "./reddit";
|
||||
|
66
src/components/embed-types/model.tsx
Normal file
66
src/components/embed-types/model.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
Heading,
|
||||
IconButton,
|
||||
Link,
|
||||
Spinner,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { ErrorBoundary } from "../error-boundary";
|
||||
import { DownloadIcon, ThingsIcon } from "../icons";
|
||||
|
||||
const STLViewer = lazy(() => import("../stl-viewer"));
|
||||
|
||||
function EmbeddedStlFile({ src }: { src: string }) {
|
||||
const preview = useDisclosure();
|
||||
|
||||
return (
|
||||
<Card variant="outline">
|
||||
<CardHeader p="2" display="flex" alignItems="center" gap="2">
|
||||
<ThingsIcon boxSize={6} />
|
||||
<Heading size="sm">STL File</Heading>
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
<IconButton icon={<DownloadIcon />} aria-label="Download File" title="Download File" />
|
||||
{!preview.isOpen && (
|
||||
<Button colorScheme="primary" onClick={preview.onOpen}>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</CardHeader>
|
||||
{preview.isOpen && (
|
||||
<CardBody px="2" pt="0" pb="2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Text>
|
||||
<Spinner /> Loading viewer...
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<STLViewer aspectRatio={16 / 10} url={src} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</CardBody>
|
||||
)}
|
||||
<CardFooter p="2" pt="0" pb="2">
|
||||
<Link isExternal href={src} color="blue.500">
|
||||
{src}
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderModelUrl(match: URL) {
|
||||
if (!match.pathname.endsWith(".stl")) return null;
|
||||
|
||||
return <EmbeddedStlFile src={match.toString()} />;
|
||||
}
|
@ -2,6 +2,7 @@ import { CSSProperties } from "react";
|
||||
import { Box, useColorMode } from "@chakra-ui/react";
|
||||
import { EmbedEventPointer } from "../embed-event";
|
||||
import appSettings from "../../services/settings/app-settings";
|
||||
import { STEMSTR_RELAY } from "../../helpers/nostr/stemstr";
|
||||
|
||||
const setZIndex: CSSProperties = { zIndex: 1, position: "relative" };
|
||||
|
||||
@ -121,7 +122,7 @@ export function renderStemstrUrl(match: URL) {
|
||||
const [_, base, id] = match.pathname.split("/");
|
||||
if (base !== "thread" || id.length !== 64) return null;
|
||||
|
||||
return <EmbedEventPointer pointer={{ type: "nevent", data: { id, relays: ["wss://relay.stemstr.app"] } }} />;
|
||||
return <EmbedEventPointer pointer={{ type: "nevent", data: { id, relays: [STEMSTR_RELAY] } }} />;
|
||||
}
|
||||
|
||||
export function renderSoundCloudUrl(match: URL) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Link } from "@chakra-ui/react";
|
||||
import { Link, Tooltip } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
|
||||
@ -7,6 +7,7 @@ import UserLink from "../user-link";
|
||||
import { getMatchHashtag, getMatchNostrLink, stripInvisibleChar } from "../../helpers/regexp";
|
||||
import { safeDecode } from "../../helpers/nip19";
|
||||
import { EmbedEventPointer } from "../embed-event";
|
||||
import { NIP_NAMES } from "../../views/relays/components/supported-nips";
|
||||
|
||||
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
|
||||
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
|
||||
@ -93,3 +94,26 @@ export function embedNostrHashtags(content: EmbedableContent, event: NostrEvent
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function embedNipDefinitions(content: EmbedableContent) {
|
||||
return embedJSX(content, {
|
||||
name: "nip-definition",
|
||||
regexp: /nip-?(\d\d)/gi,
|
||||
render: (match) => {
|
||||
if (NIP_NAMES[match[1]]) {
|
||||
return (
|
||||
<Tooltip label={NIP_NAMES[match[1]]} aria-label="NIP Definition">
|
||||
<Link
|
||||
isExternal
|
||||
href={`https://github.com/nostr-protocol/nips/blob/master/${match[1]}.md`}
|
||||
textDecoration="underline"
|
||||
>
|
||||
{match[0]}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
21
src/components/embed-types/simplex.tsx
Normal file
21
src/components/embed-types/simplex.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Button, Image, Link } from "@chakra-ui/react";
|
||||
import { ExternalLinkIcon } from "../icons";
|
||||
|
||||
export function renderSimpleXLink(match: URL) {
|
||||
if (match.hostname !== "simplex.chat") return null;
|
||||
if (!match.pathname.startsWith("/contact")) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
as={Link}
|
||||
isExternal
|
||||
rightIcon={<ExternalLinkIcon />}
|
||||
leftIcon={<Image src="https://simplex.chat/img/favicon.ico" w="6" h="6" />}
|
||||
href={match.toString()}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
>
|
||||
SimpleX Invite
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -2,7 +2,7 @@ import styled from "@emotion/styled";
|
||||
import { isVideoURL } from "../../helpers/url";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
import useElementBlur from "../../hooks/use-element-blur";
|
||||
import { useTrusted } from "../../providers/trust";
|
||||
import { useTrusted } from "../../providers/local/trust";
|
||||
|
||||
const StyledVideo = styled.video`
|
||||
max-width: 30rem;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { memo } from "react";
|
||||
import { ErrorBoundary as ErrorBoundaryHelper, FallbackProps } from "react-error-boundary";
|
||||
import { Alert, AlertIcon, AlertTitle, AlertDescription } from "@chakra-ui/react";
|
||||
|
||||
@ -12,8 +12,8 @@ export function ErrorFallback({ error, resetErrorBoundary }: Partial<FallbackPro
|
||||
);
|
||||
}
|
||||
|
||||
export const ErrorBoundary = ({ children, ...props }: { children: React.ReactNode }) => (
|
||||
export const ErrorBoundary = memo(({ children, ...props }: { children: React.ReactNode }) => (
|
||||
<ErrorBoundaryHelper FallbackComponent={ErrorFallback} {...props}>
|
||||
{children}
|
||||
</ErrorBoundaryHelper>
|
||||
);
|
||||
));
|
||||
|
@ -1,17 +1,20 @@
|
||||
import { Button, Flex, SimpleGrid, SimpleGridProps, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import { Kind } from "nostr-tools";
|
||||
import { Flex, Text } from "@chakra-ui/react";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import UserAvatarLink from "../user-avatar-link";
|
||||
import UserLink from "../user-link";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import Timestamp from "../timestamp";
|
||||
|
||||
export default function RepostDetails({ event }: { event: NostrEvent }) {
|
||||
const readRelays = useReadRelayUrls();
|
||||
const timeline = useTimelineLoader(`${event.id}-reposts`, readRelays, { kinds: [Kind.Repost], "#e": [event.id] });
|
||||
const readRelays = useReadRelays();
|
||||
const timeline = useTimelineLoader(`${event.id}-reposts`, readRelays, {
|
||||
kinds: [kinds.Repost, kinds.GenericRepost],
|
||||
"#e": [event.id],
|
||||
});
|
||||
|
||||
const reposts = useSubject(timeline.timeline);
|
||||
|
||||
|
@ -1,37 +1,23 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
|
||||
import { ReactionGroup, draftEventReaction } from "../../helpers/nostr/reactions";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import { useSigningContext } from "../../providers/signing-provider";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import eventReactionsService from "../../services/event-reactions";
|
||||
import { usePublishEvent } from "../../providers/global/publish-provider";
|
||||
|
||||
export function useAddReaction(event: NostrEvent, grouped: ReactionGroup[]) {
|
||||
const account = useCurrentAccount();
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const publish = usePublishEvent();
|
||||
|
||||
return useCallback(
|
||||
async (emoji = "+", url?: string) => {
|
||||
try {
|
||||
const group = grouped.find((g) => g.emoji === emoji);
|
||||
if (account && group && group.pubkeys.includes(account?.pubkey)) return;
|
||||
const group = grouped.find((g) => g.emoji === emoji);
|
||||
if (account && group && group.pubkeys.includes(account?.pubkey)) return;
|
||||
|
||||
const draft = draftEventReaction(event, emoji, url);
|
||||
const draft = draftEventReaction(event, emoji, url);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
if (signed) {
|
||||
const writeRelays = clientRelaysService.getWriteUrls();
|
||||
new NostrPublishAction("Reaction", writeRelays, signed);
|
||||
eventReactionsService.handleEvent(signed);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
await publish("Reaction", draft);
|
||||
},
|
||||
[grouped, account, toast, requestSignature],
|
||||
[grouped, account, publish],
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useEventReactions from "../../hooks/use-event-reactions";
|
||||
@ -13,6 +13,7 @@ export default function EventReactionButtons({ event, max }: { event: NostrEvent
|
||||
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
|
||||
|
||||
const addReaction = useAddReaction(event, grouped);
|
||||
const [loading, setLoading] = useState<string>();
|
||||
|
||||
if (grouped.length === 0) return null;
|
||||
|
||||
@ -27,7 +28,11 @@ export default function EventReactionButtons({ event, max }: { event: NostrEvent
|
||||
emoji={group.emoji}
|
||||
url={group.url}
|
||||
count={group.pubkeys.length}
|
||||
onClick={() => addReaction(group.emoji, group.url)}
|
||||
isLoading={loading === group.emoji}
|
||||
onClick={() => {
|
||||
setLoading(group.emoji);
|
||||
addReaction(group.emoji, group.url).finally(() => setLoading(undefined));
|
||||
}}
|
||||
colorScheme={account && group.pubkeys.includes(account?.pubkey) ? "primary" : undefined}
|
||||
/>
|
||||
))}
|
||||
|
31
src/components/event-reactions/simple-dislike-button.tsx
Normal file
31
src/components/event-reactions/simple-dislike-button.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useEventReactions from "../../hooks/use-event-reactions";
|
||||
import { groupReactions } from "../../helpers/nostr/reactions";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import ReactionGroupButton from "./reaction-group-button";
|
||||
import { useAddReaction } from "./common-hooks";
|
||||
import { ButtonProps } from "@chakra-ui/react";
|
||||
|
||||
export default function SimpleDislikeButton({
|
||||
event,
|
||||
...props
|
||||
}: Omit<ButtonProps, "children"> & { event: NostrEvent }) {
|
||||
const account = useCurrentAccount();
|
||||
const reactions = useEventReactions(event.id) ?? [];
|
||||
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
|
||||
|
||||
const addReaction = useAddReaction(event, grouped);
|
||||
const group = grouped.find((g) => g.emoji === "-");
|
||||
|
||||
return (
|
||||
<ReactionGroupButton
|
||||
emoji="-"
|
||||
count={group?.pubkeys.length ?? 0}
|
||||
onClick={() => addReaction("-")}
|
||||
colorScheme={account && group?.pubkeys.includes(account?.pubkey) ? "primary" : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { memo } from "react";
|
||||
import { verifySignature } from "nostr-tools";
|
||||
import { verifyEvent } from "nostr-tools";
|
||||
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { CheckIcon, VerificationFailed } from "./icons";
|
||||
@ -9,7 +9,7 @@ function EventVerificationIcon({ event }: { event: NostrEvent }) {
|
||||
const { showSignatureVerification } = useAppSettings();
|
||||
if (!showSignatureVerification) return null;
|
||||
|
||||
if (!verifySignature(event)) {
|
||||
if (!verifyEvent(event)) {
|
||||
return <VerificationFailed color="red.500" />;
|
||||
}
|
||||
return <CheckIcon color="green.500" />;
|
||||
|
@ -9,18 +9,16 @@ import {
|
||||
ModalProps,
|
||||
} from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
import { Kind } from "nostr-tools";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
import { DraftNostrEvent, NostrEvent, isDTag } from "../../types/nostr-event";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import { getEventRelays } from "../../services/event-relays";
|
||||
import { getZapSplits } from "../../helpers/nostr/zaps";
|
||||
import { unique } from "../../helpers/array";
|
||||
import { RelayMode } from "../../classes/relay";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
import { getEventCoordinate, isReplaceable } from "../../helpers/nostr/events";
|
||||
import { EmbedProps } from "../embed-event";
|
||||
import userRelaysService from "../../services/user-relays";
|
||||
import userMailboxesService from "../../services/user-mailboxes";
|
||||
import InputStep from "./input-step";
|
||||
import lnurlMetadataService from "../../services/lnurl-metadata";
|
||||
import userMetadataService from "../../services/user-metadata";
|
||||
@ -38,7 +36,7 @@ async function getPayRequestForPubkey(
|
||||
event: NostrEvent | undefined,
|
||||
amount: number,
|
||||
comment?: string,
|
||||
additionalRelays?: string[],
|
||||
additionalRelays?: Iterable<string>,
|
||||
): Promise<PayRequest> {
|
||||
const metadata = userMetadataService.getSubject(pubkey).value;
|
||||
const address = metadata?.lud16 || metadata?.lud06;
|
||||
@ -63,20 +61,15 @@ async function getPayRequestForPubkey(
|
||||
}
|
||||
|
||||
const userInbox = relayScoreboardService
|
||||
.getRankedRelays(
|
||||
userRelaysService
|
||||
.getRelays(pubkey)
|
||||
.value?.relays.filter((r) => r.mode & RelayMode.READ)
|
||||
.map((r) => r.url) ?? [],
|
||||
)
|
||||
.getRankedRelays(userMailboxesService.getMailboxes(pubkey).value?.inbox)
|
||||
.slice(0, 4);
|
||||
const eventRelays = event ? relayHintService.getEventRelayHints(event, 4) : [];
|
||||
const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.getWriteUrls()).slice(0, 4);
|
||||
const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.outbox).slice(0, 4);
|
||||
const additional = relayScoreboardService.getRankedRelays(additionalRelays);
|
||||
|
||||
// create zap request
|
||||
const zapRequest: DraftNostrEvent = {
|
||||
kind: Kind.ZapRequest,
|
||||
kind: kinds.ZapRequest,
|
||||
created_at: dayjs().unix(),
|
||||
content: comment ?? "",
|
||||
tags: [
|
||||
@ -113,7 +106,7 @@ async function getPayRequestsForEvent(
|
||||
amount: number,
|
||||
comment?: string,
|
||||
fallbackPubkey?: string,
|
||||
additionalRelays?: string[],
|
||||
additionalRelays?: Iterable<string>,
|
||||
) {
|
||||
const splits = getZapSplits(event, fallbackPubkey);
|
||||
|
||||
@ -140,7 +133,7 @@ export type ZapModalProps = Omit<ModalProps, "children"> & {
|
||||
allowComment?: boolean;
|
||||
showEmbed?: boolean;
|
||||
embedProps?: EmbedProps;
|
||||
additionalRelays?: string[];
|
||||
additionalRelays?: Iterable<string>;
|
||||
onZapped: () => void;
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useMount } from "react-use";
|
||||
import { Alert, Box, Button, ButtonGroup, Flex, IconButton, Spacer, useDisclosure, useToast } from "@chakra-ui/react";
|
||||
import { Alert, Button, ButtonGroup, Flex, IconButton, Spacer, useDisclosure, useToast } from "@chakra-ui/react";
|
||||
|
||||
import { PayRequest } from ".";
|
||||
import UserAvatar from "../user-avatar";
|
||||
@ -13,9 +13,7 @@ function UserCard({ children, pubkey }: PropsWithChildren & { pubkey: string })
|
||||
return (
|
||||
<Flex gap="2" alignItems="center" overflow="hidden">
|
||||
<UserAvatar pubkey={pubkey} size="md" />
|
||||
<Box>
|
||||
<UserLink pubkey={pubkey} fontWeight="bold" />
|
||||
</Box>
|
||||
<UserLink pubkey={pubkey} fontWeight="bold" isTruncated />
|
||||
<Spacer />
|
||||
{children}
|
||||
</Flex>
|
||||
@ -23,7 +21,7 @@ function UserCard({ children, pubkey }: PropsWithChildren & { pubkey: string })
|
||||
}
|
||||
function PayRequestCard({ pubkey, invoice, onPaid }: { pubkey: string; invoice: string; onPaid: () => void }) {
|
||||
const toast = useToast();
|
||||
const showMore = useDisclosure();
|
||||
const showMore = useDisclosure({ defaultIsOpen: !window.webln });
|
||||
|
||||
const payWithWebLn = async () => {
|
||||
try {
|
||||
@ -41,16 +39,18 @@ function PayRequestCard({ pubkey, invoice, onPaid }: { pubkey: string; invoice:
|
||||
<Flex direction="column" gap="2">
|
||||
<UserCard pubkey={pubkey}>
|
||||
<ButtonGroup size="sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="yellow"
|
||||
size="sm"
|
||||
leftIcon={<LightningIcon />}
|
||||
isDisabled={!window.webln}
|
||||
onClick={payWithWebLn}
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
{!!window.webln && (
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="yellow"
|
||||
size="sm"
|
||||
leftIcon={<LightningIcon />}
|
||||
isDisabled={!window.webln}
|
||||
onClick={payWithWebLn}
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
icon={showMore.isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
aria-label="More Options"
|
||||
@ -134,17 +134,18 @@ export default function PayStep({ callbacks, onComplete }: { callbacks: PayReque
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<LightningIcon />}
|
||||
colorScheme="yellow"
|
||||
onClick={payAllWithWebLN}
|
||||
isLoading={payingAll}
|
||||
isDisabled={!window.webln}
|
||||
>
|
||||
Pay All
|
||||
</Button>
|
||||
{!!window.webln && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<LightningIcon />}
|
||||
colorScheme="yellow"
|
||||
onClick={payAllWithWebLN}
|
||||
isLoading={payingAll}
|
||||
>
|
||||
Pay All
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import Code01 from "./icons/code-01";
|
||||
import DistributeSpacingVertical from "./icons/distribute-spacing-vertical";
|
||||
import Grid01 from "./icons/grid-01";
|
||||
import Microscope from "./icons/microscope";
|
||||
import Server04 from "./icons/server-04";
|
||||
import ChevronDown from "./icons/chevron-down";
|
||||
import ChevronUp from "./icons/chevron-up";
|
||||
import ChevronLeft from "./icons/chevron-left";
|
||||
@ -63,6 +62,11 @@ import ReverseLeft from "./icons/reverse-left";
|
||||
import Pin01 from "./icons/pin-01";
|
||||
import Translate01 from "./icons/translate-01";
|
||||
import MessageChatSquare from "./icons/message-chat-square";
|
||||
import Package from "./icons/package";
|
||||
import Magnet from "./icons/magnet";
|
||||
import Recording02 from "./icons/recording-02";
|
||||
import Upload01 from "./icons/upload-01";
|
||||
import Modem02 from "./icons/modem-02";
|
||||
|
||||
const defaultProps: IconProps = { boxSize: 4 };
|
||||
|
||||
@ -90,7 +94,7 @@ export const ChevronLeftIcon = ChevronLeft;
|
||||
export const ChevronRightIcon = ChevronRight;
|
||||
|
||||
export const LightningIcon = Zap;
|
||||
export const RelayIcon = Server04;
|
||||
export const RelayIcon = Modem02;
|
||||
export const BroadcastEventIcon = Share07;
|
||||
export const ShareIcon = Share07;
|
||||
export const PinIcon = Pin01;
|
||||
@ -232,7 +236,11 @@ export const WalletIcon = Wallet02;
|
||||
export const DownloadIcon = Download01;
|
||||
|
||||
export const TranslateIcon = Translate01;
|
||||
|
||||
export const ChannelsIcon = MessageChatSquare;
|
||||
|
||||
export const ThreadIcon = MessageChatSquare;
|
||||
export const ThingsIcon = Package;
|
||||
export const TorrentIcon = Magnet;
|
||||
export const TrackIcon = Recording02;
|
||||
|
||||
export const InboxIcon = Download01;
|
||||
export const OutboxIcon = Upload01;
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { useAsync } from "react-use";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Button, ButtonGroup, Card, CardProps, Heading, IconButton, Link } from "@chakra-ui/react";
|
||||
import { getDecodedToken, Token } from "@cashu/cashu-ts";
|
||||
import { getDecodedToken, Token, CashuMint } from "@cashu/cashu-ts";
|
||||
|
||||
import { CopyIconButton } from "./copy-icon-button";
|
||||
import { useUserMetadata } from "../hooks/use-user-metadata";
|
||||
import useCurrentAccount from "../hooks/use-current-account";
|
||||
import { ECashIcon, WalletIcon } from "./icons";
|
||||
import { getMint } from "../services/cashu-mints";
|
||||
|
||||
function RedeemButton({ token }: { token: string }) {
|
||||
const account = useCurrentAccount()!;
|
||||
@ -26,6 +28,15 @@ export default function InlineCachuCard({ token, ...props }: Omit<CardProps, "ch
|
||||
const account = useCurrentAccount();
|
||||
|
||||
const [cashu, setCashu] = useState<Token>();
|
||||
const { value: spendable } = useAsync(async () => {
|
||||
if (!cashu) return;
|
||||
for (const token of cashu.token) {
|
||||
const mint = await getMint(token.mint);
|
||||
const spent = await mint.check({ proofs: token.proofs.map((p) => ({ secret: p.secret })) });
|
||||
if (spent.spendable.some((v) => v === false)) return false;
|
||||
}
|
||||
return true;
|
||||
}, [cashu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token.startsWith("cashuA") || token.length < 10) return;
|
||||
@ -42,7 +53,9 @@ export default function InlineCachuCard({ token, ...props }: Omit<CardProps, "ch
|
||||
<Card p="4" flexDirection="row" borderColor="green.500" alignItems="center" gap="4" flexWrap="wrap" {...props}>
|
||||
<ECashIcon boxSize={10} color="green.500" />
|
||||
<Box>
|
||||
<Heading size="md">{amount} Cashu sats</Heading>
|
||||
<Heading size="md" textDecoration={spendable === false ? "line-through" : undefined}>
|
||||
{amount} Cashu sats{spendable === false ? " (Spent)" : ""}
|
||||
</Heading>
|
||||
{cashu && <small>Mint: {new URL(cashu.token[0].mint).hostname}</small>}
|
||||
</Box>
|
||||
{cashu.memo && <Box>Memo: {cashu.memo}</Box>}
|
||||
|
38
src/components/keyboard-shortcut.tsx
Normal file
38
src/components/keyboard-shortcut.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Code, CodeProps } from "@chakra-ui/react";
|
||||
import { useRef } from "react";
|
||||
import { useKeyPressEvent } from "react-use";
|
||||
|
||||
export default function KeyboardShortcut({
|
||||
letter,
|
||||
requireMeta,
|
||||
onPress,
|
||||
...props
|
||||
}: {
|
||||
letter: string;
|
||||
requireMeta?: boolean;
|
||||
onPress?: (e: KeyboardEvent) => void;
|
||||
} & Omit<CodeProps, "children">) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useKeyPressEvent(
|
||||
(e) => (requireMeta ? e.ctrlKey || e.metaKey : true) && e.key === letter,
|
||||
(e) => {
|
||||
// ignore if the user is focused on an input
|
||||
if (document.activeElement instanceof HTMLInputElement) return;
|
||||
|
||||
if (onPress) {
|
||||
e.preventDefault();
|
||||
onPress(e);
|
||||
} else if (ref.current?.parentElement) {
|
||||
e.preventDefault();
|
||||
ref.current.parentElement.click();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Code fontSize="md" mx="2" textDecoration="none" textTransform="capitalize" ref={ref} {...props}>
|
||||
{requireMeta ? (navigator.userAgent.includes("Macintosh") ? "⌘" : "^") : ""}
|
||||
{letter}
|
||||
</Code>
|
||||
);
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { useContext } from "react";
|
||||
import { Avatar, Box, Button, Flex, FlexProps, Heading, LinkOverlay } from "@chakra-ui/react";
|
||||
import { Avatar, Box, Button, Flex, FlexProps, Heading, IconButton, LinkOverlay } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
@ -7,8 +7,11 @@ import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import AccountSwitcher from "./account-switcher";
|
||||
import PublishLog from "../publish-log";
|
||||
import NavItems from "./nav-items";
|
||||
import { PostModalContext } from "../../providers/post-modal-provider";
|
||||
import { PostModalContext } from "../../providers/route/post-modal-provider";
|
||||
import { WritingIcon } from "../icons";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { offlineMode } from "../../services/offline-mode";
|
||||
import WifiOff from "../icons/wifi-off";
|
||||
|
||||
const hideScrollbar = css`
|
||||
-ms-overflow-style: none;
|
||||
@ -21,6 +24,7 @@ const hideScrollbar = css`
|
||||
export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
const account = useCurrentAccount();
|
||||
const { openModal } = useContext(PostModalContext);
|
||||
const offline = useSubject(offlineMode);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@ -44,6 +48,14 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
noStrudel
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
{offline && (
|
||||
<IconButton
|
||||
aria-label="Disable offline mode"
|
||||
title="Disable offline mode"
|
||||
icon={<WifiOff boxSize={5} color="orange" />}
|
||||
onClick={() => offlineMode.next(false)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{account && (
|
||||
<>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user