Merge branch 'next'

This commit is contained in:
hzrd149 2024-02-03 18:09:41 +00:00
commit 6d1cfb7afa
540 changed files with 14859 additions and 6635 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add offline mode

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Restore scroll position when returning to the timeline

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Show quotes as mentions in notifications

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add basic support for flare video kind

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show unavailable events in threads

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Support kind 16 generic reposts

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for using nostr-relay-tray as cache relay

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
count nevent and naddr as pubkey mentions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild tools menu

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Improve display of unknown events

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add track view for stemstr tracks

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for local image proxy and cors servers

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Show NIP-05 verified icons in @ mentions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add POW option when writing note

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for nsecBunker OAuth flow

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix issue with search relays getting reset

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Remove ackee

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add threads notifications view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for bunker://npub@relay NIP-46 login

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add search when selecting list in feed

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Improve channel message layout

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add NIP-66 relay stats service

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for OAuth signup flow

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add NIP definitions when hovering over "NIP-xx"

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add messages to launchpad

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix bug with stuck timelines

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add CACHE_RELAY option to docker container

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Overhaul core relay code

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Upgrade nostr-tools to v2

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple bookmarks view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show Videos an articles on bookmark list

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add Simple Satellite CDN view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for .mp3 and .wav urls

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist
.github
.vscode
.changeset
scripts
screenshots
relay

16
.vscode/settings.json vendored
View File

@ -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"
}

View File

@ -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
View 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
View 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

View File

@ -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"

View File

@ -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>

View File

@ -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"
}
}

View 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 {};

View File

@ -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 /> },
{

View File

@ -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>();

View File

@ -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;

View File

@ -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) {

View File

@ -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);

View File

@ -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
View 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),
);
}
}

View File

@ -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);
}
}
}

View File

@ -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 })),

View 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>
);
}

View 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>
);
}

View 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} />;
}

View File

@ -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";

View File

@ -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;

View File

@ -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;

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View File

@ -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 (

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
))}

View File

@ -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;

View File

@ -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>
)}

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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";

View 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} />;
}

View File

@ -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>

View File

@ -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);

View File

@ -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) => {

View File

@ -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} />}
</>
);
}

View File

@ -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":

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>(

View File

@ -8,3 +8,5 @@ export * from "./emoji";
export * from "./image";
export * from "./cashu";
export * from "./video";
export * from "./simplex";
export * from "./reddit";

View 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()} />;
}

View File

@ -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) {

View File

@ -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;
},
});
}

View 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>
);
}

View File

@ -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;

View File

@ -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>
);
));

View File

@ -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);

View File

@ -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],
);
}

View File

@ -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}
/>
))}

View 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}
/>
);
}

View File

@ -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" />;

View File

@ -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;
};

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>}

View 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>
);
}

View File

@ -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