Merge branch 'next' into files

This commit is contained in:
hzrd149 2023-09-07 11:05:41 -05:00
commit 9c93a3d9bf
271 changed files with 9440 additions and 3565 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
dist
node_modules
cypress/videos
stats.html

View File

@ -1,5 +1,66 @@
# nostrudel
## 0.26.0
### Minor Changes
- 8fd08ed: Add reply button to note feed
- 1b5ee34: Add emoji edit view
- 7a5a4b1: Add emoji pack views
- 2a490dd: Add goal views
- 27abb20: Show host emojis when writing stream chat message
- 3a2745e: Add @ user autocomplete when writing notes
- 2a490dd: Improve event embed card
- c10a17e: Add emoji autocomplete when writing notes
- 6dd6196: Rebuild stream view layout
### Patch Changes
- 8bf5d82: Optimize caching time for user metadata events
## 0.25.0
### Minor Changes
- f83d1ad: Show streamer cards in stream view on desktop
- c79c292: Show emoji reactions on notes
- 0af6c2c: Add bookmark button to notes
- 8ea8c88: Add more details to publish details modal
- d53a34c: Add browse lists view
- 343a23c: Add sats per minute button on stream view on desktop
- 6bb4589: Add option to favorite lists
- 8ea8c88: Filter relay reviews by list
- f6f4656: Allow user to select people list for home feed
- 0af6c2c: Show note lists on lists view
- 63474a7: Add delete button for lists
### Patch Changes
- 954ec50: Fix reactions showing on wrong notes
- fbc1ea4: Fix mentioning npub would freeze app
## 0.24.0
### Minor Changes
- 03d84eb: Show notes in relay view
- 1e75dbd: Improve layout of image galleries
- 07f67cc: Show all images in lightbox
- d2948e7: Rebuild event publish details
- 1148093: Render multiple images as image gallery
- d8b29b4: Add relay review form
- 9b6c653: Add simple timeline health view
- b7deb16: Clean up navigation menu
- 018c917: Add mobile friendly lightbox
- ce550f5: Show label for paid relays
- e052991: Add inline reply form
- 70bada5: Add <url> and <encoded_url> options to CORS proxy url
- 70bada5: Use corsproxy.io as default service for CORS proxy
### Patch Changes
- 1bc4500: Fix non-english characters breaking links
## 0.23.0
### Minor Changes

View File

@ -2,7 +2,7 @@ describe("Embeds", () => {
describe("hashtags", () => {
it('should handle uppercase hashtags and ","', () => {
cy.visit(
"#/n/nevent1qqsrj5ns6wva3fcghlyx0hp7hhajqtqk3kuckp7xhhscrm4jl7futegpz9mhxue69uhkummnw3e82efwvdhk6qgswaehxw309ahx7um5wgh8w6twv5pkpt8l"
"#/n/nevent1qqsrj5ns6wva3fcghlyx0hp7hhajqtqk3kuckp7xhhscrm4jl7futegpz9mhxue69uhkummnw3e82efwvdhk6qgswaehxw309ahx7um5wgh8w6twv5pkpt8l",
);
cy.findByRole("link", { name: "#Japan" }).should("be.visible");
@ -15,18 +15,18 @@ describe("Embeds", () => {
describe("links", () => {
it("embed trustless.computer links", () => {
cy.visit(
"#/n/nevent1qqsfn2mv3pe2v7jak4r5wnyengt36t0rx26w04hgysrmtpml8jnlk5cprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2qgawaehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2aq2wry06"
"#/n/nevent1qqsfn2mv3pe2v7jak4r5wnyengt36t0rx26w04hgysrmtpml8jnlk5cprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2qgawaehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2aq2wry06",
);
cy.get('[href="https://trustless.computer/"]').should("be.visible");
cy.get(
'[href="https://mempool.space/tx/461c6f56015c94d74837b68c9d08f4b80e7db7ca1e5ac4c53d9aa8c76b667672"]'
'[href="https://mempool.space/tx/461c6f56015c94d74837b68c9d08f4b80e7db7ca1e5ac4c53d9aa8c76b667672"]',
).should("be.visible");
});
it("embeds links", () => {
cy.visit(
"#/n/nevent1qqsvg6kt4hl79qpp5p673g7ref6r0c5jvp4yys7mmvs4m50t30sy9dgpz9mhxue69uhkummnw3e82efwvdhk6qgjwaehxw309aex2mrp0yhxvdm69e5k7r3xlpe"
"#/n/nevent1qqsvg6kt4hl79qpp5p673g7ref6r0c5jvp4yys7mmvs4m50t30sy9dgpz9mhxue69uhkummnw3e82efwvdhk6qgjwaehxw309aex2mrp0yhxvdm69e5k7r3xlpe",
);
cy.get('[href="https://getalby.com/"]').should("exist");
@ -38,11 +38,11 @@ describe("Embeds", () => {
it("embeds simplex.chat links", () => {
cy.visit(
"#/n/nevent1qqsymds0vlpp4f5s0dckjf4qz283pdsen0rmx8lu7ct6hpnxag2hpacpremhxue69uhkummnw3ez6un9d3shjtnwda4k7arpwfhjucm0d5q3qamnwvaz7tmwdaehgu3wwa5kueghxyq76"
"#/n/nevent1qqsymds0vlpp4f5s0dckjf4qz283pdsen0rmx8lu7ct6hpnxag2hpacpremhxue69uhkummnw3ez6un9d3shjtnwda4k7arpwfhjucm0d5q3qamnwvaz7tmwdaehgu3wwa5kueghxyq76",
);
cy.get(
'[href="https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FVlHiRmia02CDgga7w-uNb2FQZTZsj3UR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAd2GEWU9Zjrljhw8O4FldcxrqehkDWezXl-cWD-VkeEw%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion"]'
'[href="https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FVlHiRmia02CDgga7w-uNb2FQZTZsj3UR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAd2GEWU9Zjrljhw8O4FldcxrqehkDWezXl-cWD-VkeEw%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion"]',
).should("be.visible");
});
});
@ -50,7 +50,7 @@ describe("Embeds", () => {
describe("Nostr links", () => {
it("should embed noub1...", () => {
cy.visit(
"#/n/nevent1qqsd5yw7sntqfc4e7u4aempvgctry2plz653t9gpf97ctk5vc0ftskgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3zamnwvaz7tmwdaehgun4v5hxxmmdfxdj3a"
"#/n/nevent1qqsd5yw7sntqfc4e7u4aempvgctry2plz653t9gpf97ctk5vc0ftskgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3zamnwvaz7tmwdaehgun4v5hxxmmdfxdj3a",
);
cy.contains("Alby team");
@ -69,7 +69,7 @@ describe("Embeds", () => {
describe("youtube", () => {
it("should embed playlists", () => {
cy.visit(
"#/n/nevent1qqs8w6e63smpr5ccmz4l0w5pvnkp6r7z2fxaadjwu2g74y95pl9xv0cpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqqkgf54"
"#/n/nevent1qqs8w6e63smpr5ccmz4l0w5pvnkp6r7z2fxaadjwu2g74y95pl9xv0cpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqqkgf54",
);
cy.findByTitle(/youtube video player/i).should("be.visible");
@ -80,31 +80,31 @@ describe("Embeds", () => {
describe("Music", () => {
it("should handle wavlake links", () => {
cy.visit(
"#/n/nevent1qqsve4ud5v8gjds2f2h7exlmjvhqayu4s520pge7frpwe22wezny0pcpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2mxs3z0"
"#/n/nevent1qqsve4ud5v8gjds2f2h7exlmjvhqayu4s520pge7frpwe22wezny0pcpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2mxs3z0",
);
cy.findByTitle("Wavlake Embed").should("be.visible");
});
it("should handle spotify links", () => {
cy.visit(
"#/n/nevent1qqsx0lz7m72qzq499exwhnfszvgwea8tv38x9wkv32yhkmwwmhgs7jgprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk25m3sln"
"#/n/nevent1qqsx0lz7m72qzq499exwhnfszvgwea8tv38x9wkv32yhkmwwmhgs7jgprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk25m3sln",
);
cy.findByTitle("Spotify List Embed").should("exist");
cy.visit(
"#/n/nevent1qqsqxkmz49hydf8ppa9k6x6zrcq7m4evhhlye0j3lcnz8hrl2q6np4spz3mhxue69uhhyetvv9ujuerpd46hxtnfdult02qz"
"#/n/nevent1qqsqxkmz49hydf8ppa9k6x6zrcq7m4evhhlye0j3lcnz8hrl2q6np4spz3mhxue69uhhyetvv9ujuerpd46hxtnfdult02qz",
);
cy.findByTitle("Spotify Embed").should("exist");
});
it("should handle apple music links", () => {
cy.visit(
"#/n/nevent1qqs9kqt9d7r4zjpawcyl82x5qsn4hals4wn294dv95knrahs4mggwasprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2whhzvz"
"#/n/nevent1qqs9kqt9d7r4zjpawcyl82x5qsn4hals4wn294dv95knrahs4mggwasprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2whhzvz",
);
cy.findByTitle("Apple Music Embed").should("exist");
cy.visit(
"#/n/nevent1qqszyrz4uug75j4086kj4f8peg3g0v8g9f04zjxplnpq0uxljtthggqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2aeexmq"
"#/n/nevent1qqszyrz4uug75j4086kj4f8peg3g0v8g9f04zjxplnpq0uxljtthggqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2aeexmq",
);
cy.findByTitle("Apple Music List Embed").should("exist");
});
@ -118,7 +118,7 @@ describe("Embeds", () => {
describe("Emoji", () => {
it("should embed emojis", () => {
cy.visit(
"#/n/nevent1qqsdj7k47uh4z0ypl2m29lvd4ar9zpf6dcy7ls0q6g6qctnxfj5n3pcpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqdyqlpq"
"#/n/nevent1qqsdj7k47uh4z0ypl2m29lvd4ar9zpf6dcy7ls0q6g6qctnxfj5n3pcpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqdyqlpq",
);
cy.findByRole("img", { name: /pepeD/i }).should("be.visible");

View File

@ -50,7 +50,7 @@ describe("Login view", () => {
it("should redirect after login", () => {
cy.visit(
"#/n/nevent1qqs88gdxv36qsjfwr66k7wxuq9r2tg8rsdcnfkcqdg4sc6vlnsma98qpzpmhxue69uhkummnw3ezuamfdejsz9rhwden5te0wfjkccte9ejxzmt4wvhxjmccew89d"
"#/n/nevent1qqs88gdxv36qsjfwr66k7wxuq9r2tg8rsdcnfkcqdg4sc6vlnsma98qpzpmhxue69uhkummnw3ezuamfdejsz9rhwden5te0wfjkccte9ejxzmt4wvhxjmccew89d",
);
cy.findByRole("link", { name: /login/i }).click();

View File

@ -1,7 +1,7 @@
describe("Profile view", () => {
it("should load a rss feed profile", () => {
cy.visit(
"#/u/nprofile1qqsp6hxqjatvxtesgszs8aee0fcjccxa3ef3mzjva4uv2yr5lucp6jcpzemhxue69uhhyumnd3shjtnwdaehgu3wd4hk2s8c5un"
"#/u/nprofile1qqsp6hxqjatvxtesgszs8aee0fcjccxa3ef3mzjva4uv2yr5lucp6jcpzemhxue69uhhyumnd3shjtnwdaehgu3wd4hk2s8c5un",
);
cy.contains("fjsmu");

View File

@ -2,7 +2,7 @@ describe("No account", () => {
describe("note view", () => {
it("should fetch and render note", () => {
cy.visit(
"#/n/nevent1qqs84hwdlls703w4yf66qsszxjqfc0xselfxrzr6n4qp40vzdnczragpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5jcwczn"
"#/n/nevent1qqs84hwdlls703w4yf66qsszxjqfc0xselfxrzr6n4qp40vzdnczragpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5jcwczn",
);
cy.get(".chakra-card")

View File

@ -1,7 +1,7 @@
describe("Thread", () => {
it("should handle quote notes with e tags correctly", () => {
cy.visit(
"#/n/nevent1qqsx2lnyuke6vmsrz9fdrd6uwjy0g0e9l6menfgdj5truugkh9qmkkgpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqgc9md6"
"#/n/nevent1qqsx2lnyuke6vmsrz9fdrd6uwjy0g0e9l6menfgdj5truugkh9qmkkgpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqgc9md6",
);
// find first note

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />

View File

@ -1,6 +1,6 @@
{
"name": "nostrudel",
"version": "0.23.0",
"version": "0.26.0",
"private": true,
"license": "MIT",
"scripts": {
@ -9,37 +9,46 @@
"build": "tsc --project tsconfig.json && vite build",
"format": "prettier --ignore-path .prettierignore -w .",
"e2e": "cypress open",
"test": "cypress run --e2e --browser=chrome"
"test": "cypress run --e2e --browser=chrome",
"analyze": "npx vite-bundle-visualizer -o ./stats.html"
},
"dependencies": {
"@chakra-ui/icons": "^2.0.19",
"@chakra-ui/react": "^2.7.1",
"@emotion/react": "^11.11.0",
"@chakra-ui/icons": "^2.1.0",
"@chakra-ui/react": "^2.8.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@getalby/bitcoin-connect-react": "^1.1.0",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"bech32": "^2.0.0",
"blurhash": "^2.0.5",
"cheerio": "^1.0.0-rc.12",
"dayjs": "^1.11.9",
"debug": "^4.3.4",
"framer-motion": "^7.10.3",
"hls.js": "^1.4.7",
"emojilib": "2",
"framer-motion": "^10.16.0",
"hls.js": "^1.4.10",
"idb": "^7.1.1",
"identicon.js": "^2.3.3",
"leaflet": "^1.9.4",
"leaflet.locatecontrol": "^0.79.0",
"light-bolt11-decoder": "^3.0.0",
"lodash.throttle": "^4.1.1",
"match-sorter": "^6.3.1",
"nanoid": "^4.0.2",
"ngeohash": "^0.6.3",
"noble-secp256k1": "^1.2.14",
"nostr-tools": "^1.12.1",
"nostr-tools": "^1.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.10",
"react-hook-form": "^7.45.1",
"react-error-boundary": "^4.0.11",
"react-hook-form": "^7.45.4",
"react-photo-album": "^2.3.0",
"react-qr-barcode-scanner": "^1.0.6",
"react-router-dom": "^6.14.1",
"react-router-dom": "^6.15.0",
"react-singleton-hook": "^4.0.1",
"react-use": "^17.4.0",
"webln": "^0.3.2"
"webln": "^0.3.2",
"yet-another-react-lightbox": "^3.12.1"
},
"devDependencies": {
"@changesets/cli": "^2.26.2",
@ -48,18 +57,20 @@
"@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.14",
"@types/react-dom": "^18.2.6",
"@vitejs/plugin-react": "^4.0.1",
"cypress": "^12.16.0",
"prettier": "^2.8.8",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@vitejs/plugin-react": "^4.0.4",
"cypress": "^12.17.4",
"prettier": "^3.0.2",
"typescript": "^5.1.6",
"vite": "^4.3.9",
"vite": "^4.4.9",
"vite-plugin-pwa": "^0.16.4"
},
"resolutions": {
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6"
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7"
}
}

View File

@ -1,15 +1,15 @@
import React, { Suspense } from "react";
import { createHashRouter, Outlet, RouterProvider, ScrollRestoration } from "react-router-dom";
import { Spinner } from "@chakra-ui/react";
import { css, Global } from "@emotion/react";
import { ErrorBoundary } from "./components/error-boundary";
import Layout from "./components/layout";
import HomeView from "./views/home";
import HomeView from "./views/home/index";
import SettingsView from "./views/settings";
import LoginView from "./views/login";
import ProfileView from "./views/profile";
import FollowingTab from "./views/home/following-tab";
import GlobalTab from "./views/home/global-tab";
import HashTagView from "./views/hashtag";
import UserView from "./views/user";
import UserNotesTab from "./views/user/notes";
@ -28,15 +28,29 @@ import DirectMessageChatView from "./views/messages/chat";
import NostrLinkView from "./views/link";
import UserReportsTab from "./views/user/reports";
import ToolsHomeView from "./views/tools";
import Nip19ToolsView from "./views/tools/nip19";
import UserAboutTab from "./views/user/about";
import UserLikesTab from "./views/user/likes";
import UserReactionsTab from "./views/user/reactions";
import useSetColorMode from "./hooks/use-set-color-mode";
import UserStreamsTab from "./views/user/streams";
import { PageProviders } from "./providers";
import RelaysView from "./views/relays";
import RelayReviewsView from "./views/relays/reviews";
import RelayView from "./views/relays/relay";
import RelayReviewsView from "./views/relays/reviews";
import ListsView from "./views/lists";
import ListDetailsView from "./views/lists/list-details";
import UserListsTab from "./views/user/lists";
import BrowseListView from "./views/lists/browse";
import EmojiPacksBrowseView from "./views/emoji-packs/browse";
import EmojiPackView from "./views/emoji-packs/emoji-pack";
import UserEmojiPacksTab from "./views/user/emoji-packs";
import EmojiPacksView from "./views/emoji-packs";
import GoalsView from "./views/goals";
import GoalsBrowseView from "./views/goals/browse";
import GoalDetailsView from "./views/goals/goal-details";
import UserGoalsTab from "./views/user/goals";
import NetworkView from "./views/tools/network";
import MutedByView from "./views/user/muted-by";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
@ -44,6 +58,31 @@ const SearchView = React.lazy(() => import("./views/search"));
const MapView = React.lazy(() => import("./views/map"));
const FilesView = React.lazy(() => import("./views/files"));
const overrideReactTextareaAutocompleteStyles = css`
.rta__autocomplete {
z-index: var(--chakra-zIndices-popover);
font-size: var(--chakra-fontSizes-md);
}
.rta__list {
background: var(--chakra-colors-chakra-subtle-bg);
color: var(--chakra-colors-chakra-body-text);
border: var(--chakra-borders-1px) var(--chakra-colors-chakra-border-color);
border-radius: var(--chakra-sizes-1);
overflow: hidden;
}
.rta__entity {
background: none;
color: inherit;
padding: var(--chakra-sizes-1) var(--chakra-sizes-2);
}
.rta__entity--selected {
background: var(--chakra-ring-color);
}
.rta__item:not(:last-child) {
border-bottom: var(--chakra-borders-1px) var(--chakra-colors-chakra-border-color);
}
`;
const RootPage = () => {
useSetColorMode();
@ -95,11 +134,15 @@ const router = createHashRouter([
{ path: "notes", element: <UserNotesTab /> },
{ path: "streams", element: <UserStreamsTab /> },
{ path: "zaps", element: <UserZapsTab /> },
{ path: "likes", element: <UserLikesTab /> },
{ path: "likes", element: <UserReactionsTab /> },
{ path: "lists", element: <UserListsTab /> },
{ path: "followers", element: <UserFollowersTab /> },
{ path: "following", element: <UserFollowingTab /> },
{ path: "goals", element: <UserGoalsTab /> },
{ path: "emojis", element: <UserEmojiPacksTab /> },
{ path: "relays", element: <UserRelaysTab /> },
{ path: "reports", element: <UserReportsTab /> },
{ path: "muted-by", element: <MutedByView /> },
],
},
{
@ -120,7 +163,31 @@ const router = createHashRouter([
path: "tools",
children: [
{ path: "", element: <ToolsHomeView /> },
{ path: "nip19", element: <Nip19ToolsView /> },
{ path: "network", element: <NetworkView /> },
],
},
{
path: "lists",
children: [
{ path: "", element: <ListsView /> },
{ path: "browse", element: <BrowseListView /> },
{ path: ":addr", element: <ListDetailsView /> },
],
},
{
path: "goals",
children: [
{ path: "", element: <GoalsView /> },
{ path: "browse", element: <GoalsBrowseView /> },
{ path: ":id", element: <GoalDetailsView /> },
],
},
{
path: "emojis",
children: [
{ path: "", element: <EmojiPacksView /> },
{ path: "browse", element: <EmojiPacksBrowseView /> },
{ path: ":addr", element: <EmojiPackView /> },
],
},
{
@ -132,11 +199,6 @@ const router = createHashRouter([
{
path: "",
element: <HomeView />,
children: [
{ path: "", element: <FollowingTab /> },
{ path: "following", element: <FollowingTab /> },
{ path: "global", element: <GlobalTab /> },
],
},
],
},
@ -144,6 +206,7 @@ const router = createHashRouter([
export const App = () => (
<ErrorBoundary>
<Global styles={overrideReactTextareaAutocompleteStyles} />
<Suspense fallback={<Spinner />}>
<RouterProvider router={router} />
</Suspense>

View File

@ -1,8 +1,9 @@
import { getEventUID } from "../helpers/nostr/event";
import { getEventUID } from "../helpers/nostr/events";
import { NostrEvent } from "../types/nostr-event";
import Subject from "./subject";
type EventFilter = (event: NostrEvent) => boolean;
export type EventFilter = (event: NostrEvent) => boolean;
export default class EventStore {
name?: string;
events = new Map<string, NostrEvent>();
@ -15,8 +16,9 @@ export default class EventStore {
return Array.from(this.events.values()).sort((a, b) => b.created_at - a.created_at);
}
onEvent = new Subject<NostrEvent>();
onClear = new Subject();
onEvent = new Subject<NostrEvent>(undefined, false);
onDelete = new Subject<string>(undefined, false);
onClear = new Subject(undefined, false);
addEvent(event: NostrEvent) {
const id = getEventUID(event);
@ -24,37 +26,54 @@ export default class EventStore {
if (!existing || event.created_at > existing.created_at) {
this.events.set(id, event);
this.onEvent.next(event);
return true;
}
return false;
}
getEvent(id: string) {
return this.events.get(id);
}
deleteEvent(id: string) {
if (this.events.has(id)) {
this.events.delete(id);
this.onDelete.next(id);
}
}
clear() {
this.events.clear();
this.onClear.next(null);
this.onClear.next(undefined);
}
connect(other: EventStore) {
other.onEvent.subscribe(this.addEvent, this);
other.onDelete.subscribe(this.deleteEvent, this);
}
disconnect(other: EventStore) {
other.onEvent.unsubscribe(this.addEvent, this);
other.onDelete.unsubscribe(this.deleteEvent, this);
}
getFirstEvent(nth = 0, filter?: EventFilter) {
const events = this.getSortedEvents();
const filteredEvents = filter ? events.filter(filter) : events;
for (let i = 0; i <= nth; i++) {
const event = filteredEvents[i];
if (event) return event;
let i = 0;
while (true) {
const event = events.shift();
if (!event) return;
if (filter && !filter(event)) continue;
if (i === nth) return event;
i++;
}
}
getLastEvent(nth = 0, filter?: EventFilter) {
const events = this.getSortedEvents();
const filteredEvents = filter ? events.filter(filter) : events;
for (let i = nth; i >= 0; i--) {
const event = filteredEvents[filteredEvents.length - 1 - i];
if (event) return event;
let i = 0;
while (true) {
const event = events.pop();
if (!event) return;
if (filter && !filter(event)) continue;
if (i === nth) return event;
i++;
}
}
}

View File

@ -1,11 +1,10 @@
import { nanoid } from "nanoid";
import { Subject } from "./subject";
import { NostrEvent } from "../types/nostr-event";
import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query";
import { IncomingEvent, Relay } from "./relay";
import relayPoolService from "../services/relay-pool";
let lastId = 0;
export class NostrMultiSubscription {
static INIT = "initial";
static OPEN = "open";
@ -21,7 +20,7 @@ export class NostrMultiSubscription {
seenEvents = new Set<string>();
constructor(relayUrls: string[], query?: NostrRequestFilter, name?: string) {
this.id = String(name || lastId++);
this.id = nanoid();
this.query = query;
this.name = name;
this.relayUrls = relayUrls;

View File

@ -1,50 +0,0 @@
import relayPoolService from "../services/relay-pool";
import { NostrEvent } from "../types/nostr-event";
import createDefer from "./deferred";
import { IncomingCommandResult, Relay } from "./relay";
import { ListenerFn, Subject } from "./subject";
export type PostResult = { url: string; message?: string; status: boolean };
export function nostrPostAction(relays: string[], event: NostrEvent, timeout: number = 5000) {
const subject = new Subject<PostResult>();
const onComplete = createDefer<void>();
const remaining = new Map<Relay, ListenerFn<IncomingCommandResult>>();
for (const url of relays) {
const relay = relayPoolService.requestRelay(url);
const handler = (result: IncomingCommandResult) => {
if (result.eventId === event.id) {
subject.next({
url,
status: result.status,
message: result.message,
});
relay.onCommandResult.unsubscribe(handler);
remaining.delete(relay);
if (remaining.size === 0) onComplete.resolve();
}
};
relay.onCommandResult.subscribe(handler);
remaining.set(relay, handler);
// send event
relay.send(["EVENT", event]);
}
setTimeout(() => {
if (remaining.size > 0) {
for (const [relay, handler] of remaining) {
relay.onCommandResult.unsubscribe(handler);
}
onComplete.resolve();
}
}, timeout);
return {
results: subject,
onComplete,
};
}

View File

@ -0,0 +1,71 @@
import { nanoid } from "nanoid";
import { isReplaceable } from "../helpers/nostr/events";
import { addToLog } from "../services/publish-log";
import relayPoolService from "../services/relay-pool";
import replaceableEventLoaderService from "../services/replaceable-event-requester";
import { NostrEvent } from "../types/nostr-event";
import createDefer from "./deferred";
import { IncomingCommandResult, Relay } from "./relay";
import Subject, { PersistentSubject } from "./subject";
export default class NostrPublishAction {
id = nanoid();
label: string;
relays: string[];
event: NostrEvent;
results = new PersistentSubject<IncomingCommandResult[]>([]);
onResult = new Subject<IncomingCommandResult>(undefined, false);
onComplete = createDefer<IncomingCommandResult[]>();
private remaining = new Set<Relay>();
constructor(label: string, relays: string[], event: NostrEvent, timeout: number = 5000) {
this.label = label;
this.relays = relays;
this.event = event;
for (const url of relays) {
const relay = relayPoolService.requestRelay(url);
this.remaining.add(relay);
relay.onCommandResult.subscribe(this.handleResult, this);
// send event
relay.send(["EVENT", event]);
}
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);
}
}
private handleResult(result: IncomingCommandResult) {
if (result.eventId === this.event.id) {
const relay = result.relay;
this.results.next([...this.results.value, result]);
this.onResult.next(result);
relay.onCommandResult.unsubscribe(this.handleResult, this);
this.remaining.delete(relay);
if (this.remaining.size === 0) this.onComplete.resolve(this.results.value);
}
}
private handleTimeout() {
for (const relay of this.remaining) {
this.handleResult({
message: "Timeout",
eventId: this.event.id,
status: false,
type: "OK",
relay,
});
}
}
}

View File

@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import { NostrEvent } from "../types/nostr-event";
import { NostrRequestFilter } from "../types/nostr-query";
import relayPoolService from "../services/relay-pool";
@ -5,8 +6,6 @@ import { IncomingEOSE, IncomingEvent, Relay } from "./relay";
import Subject from "./subject";
import createDefer from "./deferred";
let lastId = 0;
const REQUEST_DEFAULT_TIMEOUT = 1000 * 5;
export class NostrRequest {
static IDLE = "idle";
@ -21,8 +20,8 @@ export class NostrRequest {
onComplete = createDefer<void>();
seenEvents = new Set<string>();
constructor(relayUrls: string[], timeout?: number, name?: string) {
this.id = name || `request-${lastId++}`;
constructor(relayUrls: string[], timeout?: number) {
this.id = nanoid();
this.relays = new Set(relayUrls.map((url) => relayPoolService.requestRelay(url)));
for (const relay of this.relays) {

View File

@ -3,8 +3,7 @@ import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query";
import { IncomingEOSE, Relay } from "./relay";
import relayPoolService from "../services/relay-pool";
import { Subject } from "./subject";
let lastId = 10000;
import { nanoid } from "nanoid";
export class NostrSubscription {
static INIT = "initial";
@ -20,7 +19,7 @@ export class NostrSubscription {
onEOSE = new Subject<IncomingEOSE>();
constructor(relayUrl: string, query?: NostrRequestFilter, name?: string) {
this.id = String(name || lastId++);
this.id = nanoid();
this.query = query;
this.name = name;

View File

@ -40,12 +40,12 @@ const CONNECTION_TIMEOUT = 1000 * 30;
export class Relay {
url: string;
onOpen = new Subject<Relay>();
onClose = new Subject<Relay>();
onEvent = new Subject<IncomingEvent>();
onNotice = new Subject<IncomingNotice>();
onEOSE = new Subject<IncomingEOSE>();
onCommandResult = new Subject<IncomingCommandResult>();
onOpen = new Subject<Relay>(undefined, false);
onClose = new Subject<Relay>(undefined, false);
onEvent = new Subject<IncomingEvent>(undefined, false);
onNotice = new Subject<IncomingNotice>(undefined, false);
onEOSE = new Subject<IncomingEOSE>(undefined, false);
onCommandResult = new Subject<IncomingCommandResult>(undefined, false);
ws?: WebSocket;
mode: RelayMode = RelayMode.ALL;

View File

@ -14,14 +14,16 @@ export class Subject<Value> implements Connectable<Value> {
listeners: [ListenerFn<Value>, Object | undefined][] = [];
value?: Value;
constructor(value?: Value) {
this.value = value;
cacheValue: boolean;
constructor(value?: Value, cacheValue = true) {
this.cacheValue = cacheValue;
if (this.cacheValue) this.value = value;
}
next(value: Value) {
if (this.value === value) return;
this.value = value;
if (this.cacheValue) this.value = value;
for (const [listener, ctx] of this.listeners) {
if (ctx) listener.call(ctx, value);
else listener(value);
@ -35,13 +37,15 @@ export class Subject<Value> implements Connectable<Value> {
});
}
subscribe(listener: ListenerFn<Value>, ctx?: Object) {
subscribe(listener: ListenerFn<Value>, ctx?: Object, initCall = true) {
if (!this.findListener(listener, ctx)) {
this.listeners.push([listener, ctx]);
if (this.value !== undefined) {
if (ctx) listener.call(ctx, this.value);
else listener(this.value);
if (initCall) {
if (this.value !== undefined) {
if (ctx) listener.call(ctx, this.value);
else listener(this.value);
}
}
}
return this;
@ -99,7 +103,7 @@ export class Subject<Value> implements Connectable<Value> {
export class PersistentSubject<Value> extends Subject<Value> implements ConnectableApi<Value> {
value: Value;
constructor(value: Value) {
super();
super(value, true);
this.value = value;
}
}

View File

@ -1,4 +1,4 @@
import { getReferences } from "../helpers/nostr/event";
import { getReferences } from "../helpers/nostr/events";
import { NostrEvent } from "../types/nostr-event";
import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription";

View File

@ -1,13 +1,15 @@
import dayjs from "dayjs";
import { utils } from "nostr-tools";
import { Debugger } from "debug";
import { NostrEvent } from "../types/nostr-event";
import { NostrEvent, isATag, isETag } from "../types/nostr-event";
import { NostrQuery, NostrRequestFilter } from "../types/nostr-query";
import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription";
import Subject, { PersistentSubject } from "./subject";
import { logger } from "../helpers/debug";
import EventStore from "./event-store";
import { isReplaceable } from "../helpers/nostr/events";
import replaceableEventLoaderService from "../services/replaceable-event-requester";
import deleteEventService from "../services/delete-events";
function addToQuery(filter: NostrRequestFilter, query: NostrQuery) {
if (Array.isArray(filter)) {
@ -16,16 +18,14 @@ function addToQuery(filter: NostrRequestFilter, query: NostrQuery) {
return { ...filter, ...query };
}
const BLOCK_SIZE = 20;
const BLOCK_SIZE = 30;
type EventFilter = (event: NostrEvent) => boolean;
export type EventFilter = (event: NostrEvent) => boolean;
class RelayTimelineLoader {
export class RelayTimelineLoader {
relay: string;
query: NostrRequestFilter;
blockSize = BLOCK_SIZE;
private name?: string;
private requestId = 0;
private log: Debugger;
loading = false;
@ -35,13 +35,14 @@ class RelayTimelineLoader {
onBlockFinish = new Subject<void>();
constructor(relay: string, query: NostrRequestFilter, name: string, log?: Debugger) {
constructor(relay: string, query: NostrRequestFilter, log?: Debugger) {
this.relay = relay;
this.query = query;
this.name = name;
this.log = log || logger.extend(name);
this.log = log || logger.extend(relay);
this.events = new EventStore(relay);
deleteEventService.stream.subscribe(this.handleDeleteEvent, this);
}
loadNextBlock() {
@ -52,31 +53,45 @@ class RelayTimelineLoader {
query = addToQuery(query, { until: oldestEvent.created_at - 1 });
}
const request = new NostrRequest([this.relay], undefined, this.name + "-" + this.requestId++);
const request = new NostrRequest([this.relay], 20 * 1000);
let gotEvents = 0;
request.onEvent.subscribe((e) => {
// if(oldestEvent && e.created_at<oldestEvent.created_at){
// this.log('Got event older than oldest')
// }
if (this.handleEvent(e)) {
gotEvents++;
}
this.handleEvent(e);
gotEvents++;
});
request.onComplete.then(() => {
this.loading = false;
if (gotEvents === 0) this.complete = true;
this.log(`Got ${gotEvents} events`);
if (gotEvents === 0) {
this.complete = true;
this.log("Complete");
}
this.onBlockFinish.next();
});
request.start(query);
}
private handleDeleteEvent(deleteEvent: NostrEvent) {
const cord = deleteEvent.tags.find(isATag)?.[1];
const eventId = deleteEvent.tags.find(isETag)?.[1];
if (cord) this.events.deleteEvent(cord);
if (eventId) this.events.deleteEvent(eventId);
}
private handleEvent(event: NostrEvent) {
return this.events.addEvent(event);
}
cleanup() {
deleteEventService.stream.unsubscribe(this.handleDeleteEvent, this);
}
getFirstEvent(nth = 0, filter?: EventFilter) {
return this.events.getFirstEvent(nth, filter);
}
getLastEvent(nth = 0, filter?: EventFilter) {
return this.events.getLastEvent(nth, filter);
}
@ -93,13 +108,13 @@ export class TimelineLoader {
complete = new PersistentSubject(false);
loadNextBlockBuffer = 2;
eventFilter?: (event: NostrEvent) => boolean;
eventFilter?: EventFilter;
private name: string;
name: string;
private log: Debugger;
private subscription: NostrMultiSubscription;
private relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
constructor(name: string) {
this.name = name;
@ -111,7 +126,10 @@ export class TimelineLoader {
// 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);
deleteEventService.stream.subscribe(this.handleDeleteEvent, this);
}
private updateTimeline() {
@ -120,15 +138,26 @@ export class TimelineLoader {
} else this.timeline.next(this.events.getSortedEvents());
}
private handleEvent(event: NostrEvent) {
// if this is a replaceable event, mirror it over to the replaceable event service
if (isReplaceable(event.kind)) {
replaceableEventLoaderService.handleEvent(event);
}
this.events.addEvent(event);
}
private handleDeleteEvent(deleteEvent: NostrEvent) {
const cord = deleteEvent.tags.find(isATag)?.[1];
const eventId = deleteEvent.tags.find(isETag)?.[1];
if (cord) this.events.deleteEvent(cord);
if (eventId) this.events.deleteEvent(eventId);
}
private createLoaders() {
if (!this.query) return;
for (const relay of this.relays) {
if (!this.relayTimelineLoaders.has(relay)) {
const loader = new RelayTimelineLoader(relay, this.query, this.name, this.log.extend(relay));
const loader = new RelayTimelineLoader(relay, this.query, this.log.extend(relay));
this.relayTimelineLoaders.set(relay, loader);
this.events.connect(loader.events);
loader.onBlockFinish.subscribe(this.updateLoading, this);
@ -139,6 +168,7 @@ export class TimelineLoader {
private removeLoaders(filter?: (loader: RelayTimelineLoader) => boolean) {
for (const [relay, loader] of this.relayTimelineLoaders) {
if (!filter || filter(loader)) {
loader.cleanup();
this.events.disconnect(loader.events);
loader.onBlockFinish.unsubscribe(this.updateLoading, this);
loader.onBlockFinish.unsubscribe(this.updateComplete, this);
@ -162,17 +192,19 @@ export class TimelineLoader {
setQuery(query: NostrRequestFilter) {
if (JSON.stringify(this.query) === JSON.stringify(query)) return;
// remove all loaders
this.removeLoaders();
this.log("set query", query);
this.query = query;
this.events.clear();
this.timeline.next([]);
// forget all events
this.forgetEvents();
// create any missing loaders
this.createLoaders();
// update the complete flag
this.updateComplete();
// update the subscription
this.subscription.forgetEvents();
// update the subscription with the new query
this.subscription.setQuery(addToQuery(query, { limit: BLOCK_SIZE / 2 }));
}
setFilter(filter?: (event: NostrEvent) => boolean) {
@ -237,10 +269,17 @@ export class TimelineLoader {
reset() {
this.cursor = dayjs().unix();
this.relayTimelineLoaders.clear();
this.removeLoaders();
this.forgetEvents();
}
/** close the subscription and remove any event listeners for this timeline */
cleanup() {
this.close();
this.removeLoaders();
deleteEventService.stream.unsubscribe(this.handleDeleteEvent, this);
}
// TODO: this is only needed because the current logic dose not remove events when the relay they where fetched from is removed
/** @deprecated */
forgetEvents() {

View File

@ -0,0 +1,76 @@
import {
Flex,
FlexProps,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Tag,
TagProps,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { UserAvatar } from "./user-avatar";
import { getUserDisplayName } from "../helpers/user-metadata";
import { useUserMetadata } from "../hooks/use-user-metadata";
function UserTag({ pubkey, ...props }: { pubkey: string } & Omit<TagProps, "children">) {
const metadata = useUserMetadata(pubkey);
const npub = nip19.npubEncode(pubkey);
const displayName = getUserDisplayName(metadata, pubkey);
return (
<Tag as={RouterLink} to={`/u/${npub}`} {...props}>
<UserAvatar pubkey={pubkey} size="xs" mr="2" title={displayName} />
{displayName}
</Tag>
);
}
export function UserAvatarStack({
pubkeys,
maxUsers,
label = "Users",
...props
}: { pubkeys: string[]; maxUsers?: number; label?: string } & FlexProps) {
const { isOpen, onOpen, onClose } = useDisclosure();
const clamped = maxUsers ? pubkeys.slice(0, maxUsers) : pubkeys;
return (
<>
{label && <span>{label}</span>}
<Flex alignItems="center" gap="-4" overflow="hidden" cursor="pointer" onClick={onOpen} {...props}>
{clamped.map((pubkey) => (
<UserAvatar key={pubkey} pubkey={pubkey} size="2xs" />
))}
{clamped.length !== pubkeys.length && (
<Text mx="1" fontSize="sm" lineHeight={0}>
+{pubkeys.length - clamped.length}
</Text>
)}
</Flex>
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" pt="4" pb="2">
{label}:
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" pb="4" pt="0">
<Flex gap="2" wrap="wrap">
{pubkeys.map((pubkey) => (
<UserTag key={pubkey} pubkey={pubkey} p="2" fontWeight="bold" fontSize="md" />
))}
</Flex>
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@ -1,11 +1,11 @@
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton, Flex } from "@chakra-ui/react";
import { ModalProps } from "@chakra-ui/react";
import { Bech32Prefix, hexToBech32 } from "../../helpers/nip19";
import { getReferences } from "../../helpers/nostr/event";
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 { nip19 } from "nostr-tools";
export default function NoteDebugModal({ event, ...props }: { event: NostrEvent } & Omit<ModalProps, "children">) {
return (
@ -16,7 +16,7 @@ export default function NoteDebugModal({ event, ...props }: { event: NostrEvent
<ModalBody p="4">
<Flex gap="2" direction="column">
<RawValue heading="Event Id" value={event.id} />
<RawValue heading="Encoded id (NIP-19)" value={hexToBech32(event.id, Bech32Prefix.Note) ?? "failed"} />
<RawValue heading="Encoded id (NIP-19)" value={nip19.noteEncode(event.id)} />
<RawPre heading="Content" value={event.content} />
<RawJson heading="JSON" json={event} />
<RawJson heading="References" json={getReferences(event)} />

View File

@ -1,17 +1,15 @@
import { useMemo } from "react";
import { Flex, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from "@chakra-ui/react";
import { ModalProps } from "@chakra-ui/react";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import RawValue from "./raw-value";
import RawJson from "./raw-json";
import { useSharableProfileId } from "../../hooks/use-shareable-profile-id";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
import replaceableEventLoaderService from "../../services/replaceable-event-requester";
import { Kind } from "nostr-tools";
import { Kind, nip19 } from "nostr-tools";
export default function UserDebugModal({ pubkey, ...props }: { pubkey: string } & Omit<ModalProps, "children">) {
const npub = useMemo(() => normalizeToBech32(pubkey, Bech32Prefix.Pubkey), [pubkey]);
const npub = nip19.npubEncode(pubkey);
const metadata = useUserMetadata(pubkey);
const nprofile = useSharableProfileId(pubkey);
const relays = replaceableEventLoaderService.getEvent(Kind.RelayList, pubkey).value;

View File

@ -0,0 +1,59 @@
import {
ButtonGroup,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
Image,
Link,
Text,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import dayjs from "dayjs";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { getEmojisFromPack, getPackName } from "../../../helpers/nostr/emoji-packs";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import EmojiPackFavoriteButton from "../../../views/emoji-packs/components/emoji-pack-favorite-button";
import EmojiPackMenu from "../../../views/emoji-packs/components/emoji-pack-menu";
import { NostrEvent } from "../../../types/nostr-event";
export default function EmbeddedEmojiPack({ pack, ...props }: Omit<CardProps, "children"> & { pack: NostrEvent }) {
const emojis = getEmojisFromPack(pack);
const naddr = getSharableEventAddress(pack);
return (
<Card {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={`/emojis/${naddr}`}>
{getPackName(pack)}
</Link>
</Heading>
<Text>by</Text>
<UserAvatarLink pubkey={pack.pubkey} size="xs" />
<UserLink pubkey={pack.pubkey} isTruncated fontWeight="bold" fontSize="md" />
<ButtonGroup size="sm" ml="auto">
<EmojiPackFavoriteButton pack={pack} />
<EmojiPackMenu pack={pack} aria-label="emoji pack menu" />
</ButtonGroup>
</CardHeader>
<CardBody p="2">
{emojis.length > 0 && (
<Flex mb="2" wrap="wrap" gap="2">
{emojis.map(({ name, url }) => (
<Image key={name + url} src={url} title={name} w={8} h={8} />
))}
</Flex>
)}
</CardBody>
<CardFooter p="2" display="flex" pt="0">
<Text>Updated: {dayjs.unix(pack.created_at).fromNow()}</Text>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,33 @@
import { Card, CardBody, CardHeader, CardProps, Heading, Link, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import { getGoalName } from "../../../helpers/nostr/goal";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import GoalProgress from "../../../views/goals/components/goal-progress";
import GoalZapButton from "../../../views/goals/components/goal-zap-button";
export default function EmbeddedGoal({ goal, ...props }: Omit<CardProps, "children"> & { goal: NostrEvent }) {
const nevent = getSharableEventAddress(goal);
return (
<Card {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={`/goals/${nevent}`}>
{getGoalName(goal)}
</Link>
</Heading>
<Text>by</Text>
<UserAvatarLink pubkey={goal.pubkey} size="xs" />
<UserLink pubkey={goal.pubkey} isTruncated fontWeight="bold" fontSize="md" />
</CardHeader>
<CardBody p="2">
<GoalProgress goal={goal} />
<GoalZapButton goal={goal} mt="2" />
</CardBody>
</Card>
);
}

View File

@ -0,0 +1,40 @@
import dayjs from "dayjs";
import { Button, Card, CardBody, CardHeader, Spacer, useDisclosure } from "@chakra-ui/react";
import { NoteContents } from "../../note/note-contents";
import { NostrEvent } from "../../../types/nostr-event";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
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 { NoteLink } from "../../note-link";
import { ArrowDownSIcon, ArrowUpSIcon } from "../../icons";
export default function EmbeddedNote({ event }: { event: NostrEvent }) {
const { showSignatureVerification } = useSubject(appSettings);
const expand = useDisclosure();
return (
<TrustProvider event={event}>
<Card variant="outline">
<CardHeader padding="2" display="flex" gap="2" alignItems="center" flexWrap="wrap">
<UserAvatarLink pubkey={event.pubkey} size="sm" />
<UserLink pubkey={event.pubkey} fontWeight="bold" isTruncated fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Button size="sm" onClick={expand.onToggle} leftIcon={expand.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}>
Expand
</Button>
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={event} />}
<NoteLink noteId={event.id} color="current" whiteSpace="nowrap">
{dayjs.unix(event.created_at).fromNow()}
</NoteLink>
</CardHeader>
<CardBody p="0">{expand.isOpen && <NoteContents px="2" event={event} />}</CardBody>
</Card>
</TrustProvider>
);
}

View File

@ -0,0 +1,68 @@
import { Card, CardBody, CardProps, Flex, Heading, Image, Link, Tag, Text, useBreakpointValue } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import dayjs from "dayjs";
import { parseStreamEvent } from "../../../helpers/nostr/stream";
import { NostrEvent } from "../../../types/nostr-event";
import StreamStatusBadge from "../../../views/streams/components/status-badge";
import { UserLink } from "../../user-link";
import { UserAvatar } from "../../user-avatar";
import useEventNaddr from "../../../hooks/use-event-naddr";
export default function EmbeddedStream({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const stream = parseStreamEvent(event);
const naddr = useEventNaddr(stream.event, stream.relays);
const isVertical = useBreakpointValue({ base: true, md: false });
const navigate = useNavigate();
return (
<Card {...props} position="relative">
<CardBody p="2" gap="2">
<StreamStatusBadge stream={stream} position="absolute" top="4" left="4" />
{isVertical ? (
<Image
src={stream.image}
borderRadius="md"
cursor="pointer"
onClick={() => navigate(`/streams/${naddr}`)}
maxH="2in"
mx="auto"
mb="2"
/>
) : (
<Image
src={stream.image}
borderRadius="md"
maxH="2in"
maxW="30%"
mr="2"
float="left"
cursor="pointer"
onClick={() => navigate(`/streams/${naddr}`)}
/>
)}
<Heading size="md">
<Link as={RouterLink} to={`/streams/${naddr}`}>
{stream.title}
</Link>
</Heading>
<Flex gap="2" alignItems="center" my="2">
<UserAvatar pubkey={stream.host} size="xs" noProxy />
<Heading size="sm">
<UserLink pubkey={stream.host} />
</Heading>
</Flex>
{stream.starts && <Text>Started: {dayjs.unix(stream.starts).fromNow()}</Text>}
{stream.tags.length > 0 && (
<Flex gap="2" wrap="wrap">
{stream.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Flex>
)}
</CardBody>
</Card>
);
}

View File

@ -0,0 +1,36 @@
import { Card, CardBody, CardHeader, CardProps, Flex, Heading, 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 dayjs from "dayjs";
export default function EmbeddedUnknown({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const address = getSharableEventAddress(event);
return (
<Card {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<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>
{dayjs.unix(event.created_at).fromNow()}
</Link>
</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>
</Flex>
<Text whiteSpace="pre-wrap">{event.content}</Text>
</CardBody>
</Card>
);
}

View File

@ -0,0 +1,61 @@
import type { DecodeResult } from "nostr-tools/lib/nip19";
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 { Kind, nip19 } from "nostr-tools";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import RelayCard from "../../views/relays/components/relay-card";
import { STREAM_KIND } from "../../helpers/nostr/stream";
import { GOAL_KIND } from "../../helpers/nostr/goal";
import { safeDecode } from "../../helpers/nip19";
import EmbeddedStream from "./event-types/embedded-stream";
import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs";
import EmbeddedEmojiPack from "./event-types/embedded-emoji-pack";
import EmbeddedGoal from "./event-types/embedded-goal";
import EmbeddedUnknown from "./event-types/embedded-unknown";
export function EmbedEvent({ event }: { event: NostrEvent }) {
switch (event.kind) {
case Kind.Text:
return <EmbeddedNote event={event} />;
case STREAM_KIND:
return <EmbeddedStream event={event} />;
case GOAL_KIND:
return <EmbeddedGoal goal={event} />;
case EMOJI_PACK_KIND:
return <EmbeddedEmojiPack pack={event} />;
}
return <EmbeddedUnknown event={event} />;
}
export function EmbedEventPointer({ pointer }: { pointer: DecodeResult }) {
switch (pointer.type) {
case "note": {
const { event } = useSingleEvent(pointer.data);
if (event === undefined) return <NoteLink noteId={pointer.data} />;
return <EmbedEvent event={event} />;
}
case "nevent": {
const { event } = useSingleEvent(pointer.data.id, pointer.data.relays);
if (event === undefined) return <NoteLink noteId={pointer.data.id} />;
return <EmbedEvent event={event} />;
}
case "naddr": {
const event = useReplaceableEvent(pointer.data);
if (!event) return <span>{nip19.naddrEncode(pointer.data)}</span>;
return <EmbedEvent event={event} />;
}
case "nrelay":
return <RelayCard url={pointer.data} />;
}
return null;
}
export function EmbedEventNostrLink({ link }: { link: string }) {
const pointer = safeDecode(link);
return pointer ? <EmbedEventPointer pointer={pointer} /> : <>{link}</>;
}

View File

@ -1,35 +1,10 @@
import { Box, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react";
import appSettings from "../../services/settings/app-settings";
import { ImageGalleryLink } from "../image-gallery";
import { useTrusted } from "../../providers/trust";
import { Link } from "@chakra-ui/react";
import OpenGraphCard from "../open-graph-card";
import BlurredImage from "../blured-image";
import { isVideoURL } from "../../helpers/url";
const EmbeddedImage = ({ src }: { src: string }) => {
const trusted = useTrusted();
const ImageComponent = trusted || !appSettings.value.blurImages ? Image : BlurredImage;
const thumbnail = appSettings.value.imageProxy
? new URL(`/256,fit/${src}`, appSettings.value.imageProxy).toString()
: src;
return (
<ImageGalleryLink href={src} target="_blank" display="block" mx="-2">
<ImageComponent src={thumbnail} cursor="pointer" maxH={["initial", "35vh"]} mx={["auto", 0]} />
</ImageGalleryLink>
);
};
// note1n06jceulg3gukw836ghd94p0ppwaz6u3mksnnz960d8vlcp2fnqsgx3fu9
const imageExt = [".svg", ".gif", ".png", ".jpg", ".jpeg", ".webp", ".avif"];
export function renderImageUrl(match: URL) {
if (!imageExt.some((ext) => match.pathname.endsWith(ext))) return null;
return <EmbeddedImage src={match.toString()} />;
}
const videoExt = [".mp4", ".mkv", ".webm", ".mov"];
export function renderVideoUrl(match: URL) {
if (!videoExt.some((ext) => match.pathname.endsWith(ext))) return null;
if (!isVideoURL(match)) return null;
return <video src={match.toString()} controls style={{ maxWidth: "30rem", maxHeight: "20rem", width: "100%" }} />;
}
@ -43,5 +18,5 @@ export function renderGenericUrl(match: URL) {
}
export function renderOpenGraphUrl(match: URL) {
return <OpenGraphCard url={match} maxW="lg" />;
return <OpenGraphCard url={match} />;
}

View File

@ -1,17 +1,16 @@
import { Image } from "@chakra-ui/react";
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { DraftNostrEvent, NostrEvent, isEmojiTag } from "../../types/nostr-event";
import { getMatchEmoji } from "../../helpers/regexp";
export function embedEmoji(content: EmbedableContent, note: NostrEvent | DraftNostrEvent) {
return embedJSX(content, {
regexp: /:([a-zA-Z0-9_]+):/i,
regexp: getMatchEmoji(),
render: (match) => {
const emojiTag = note.tags.find(
(tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2]
);
const emojiTag = note.tags.filter(isEmojiTag).find((t) => t[1].toLowerCase() === match[1].toLowerCase());
if (emojiTag) {
return (
<Image src={emojiTag[2]} height="1.5em" display="inline-block" verticalAlign="middle" title={match[1]} />
<Image src={emojiTag[2]} h="1.2em" w="1.2em" display="inline-block" verticalAlign="middle" title={match[1]} />
);
}
return null;

View File

@ -0,0 +1,168 @@
import {
CSSProperties,
MouseEventHandler,
MutableRefObject,
forwardRef,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { Image, ImageProps, useBreakpointValue } from "@chakra-ui/react";
import appSettings from "../../services/settings/app-settings";
import { useTrusted } from "../../providers/trust";
import { EmbedableContent, defaultGetLocation } from "../../helpers/embeds";
import { getMatchLink } from "../../helpers/regexp";
import { useRegisterSlide } from "../lightbox-provider";
import { isImageURL } from "../../helpers/url";
import PhotoGallery, { PhotoWithoutSize } from "../photo-gallery";
import { NostrEvent } from "../../types/nostr-event";
import useAppSettings from "../../hooks/use-app-settings";
function useElementBlur(initBlur = false): { style: CSSProperties; onClick: MouseEventHandler } {
const [blur, setBlur] = useState(initBlur);
const onClick = useCallback<MouseEventHandler>(
(e) => {
if (blur) {
e.stopPropagation();
e.preventDefault();
setBlur(false);
}
},
[blur],
);
const style: CSSProperties = blur ? { filter: "blur(1.5rem)", cursor: "pointer" } : {};
return { onClick, style };
}
export type TrustImageProps = ImageProps;
export const TrustImage = forwardRef<HTMLImageElement, TrustImageProps>((props, ref) => {
const { blurImages } = useAppSettings();
const trusted = useTrusted();
const { onClick, style } = useElementBlur(!trusted);
const handleClick = useCallback<MouseEventHandler<HTMLImageElement>>(
(e) => {
onClick(e);
if (props.onClick && !e.isPropagationStopped()) {
props.onClick(e);
}
},
[onClick, props.onClick],
);
if (!blurImages) return <Image {...props} ref={ref} />;
else return <Image {...props} onClick={handleClick} style={{ ...style, ...props.style }} ref={ref} />;
});
export type EmbeddedImageProps = TrustImageProps & {
event?: NostrEvent;
};
export const EmbeddedImage = forwardRef<HTMLImageElement, EmbeddedImageProps>(({ src, event, ...props }, ref) => {
const thumbnail = appSettings.value.imageProxy
? new URL(`/256,fit/${src}`, appSettings.value.imageProxy).toString()
: src;
ref = ref || useRef<HTMLImageElement | null>(null);
const { show } = useRegisterSlide(
ref as MutableRefObject<HTMLImageElement | null>,
src ? { type: "image", src, event } : undefined,
);
return <TrustImage {...props} src={thumbnail} cursor="pointer" ref={ref} onClick={show} />;
});
export function ImageGallery({ images, event }: { images: string[]; event?: NostrEvent }) {
const photos = useMemo(() => {
return images.map((img) => {
const photo: PhotoWithoutSize = { src: img };
return photo;
});
}, [images]);
const rowMultiplier = useBreakpointValue({ base: 1.5, sm: 2, md: 3, lg: 4, xl: 5 }) ?? 4;
return (
<PhotoGallery
layout="rows"
photos={photos}
renderPhoto={({ photo, imageProps, wrapperStyle }) => <EmbeddedImage {...imageProps} />}
targetRowHeight={(containerWidth) => containerWidth / rowMultiplier}
/>
);
}
// nevent1qqs8397rp8tt60f3lm8zldt8uqljuqw9axp8z79w0qsmj3r96lmg4tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3zamnwvaz7tmwdaehgun4v5hxxmmd0mkwa9
export function embedImageGallery(content: EmbedableContent, event?: NostrEvent): EmbedableContent {
return content
.map((subContent, i) => {
if (typeof subContent === "string") {
const matches = Array.from(subContent.matchAll(getMatchLink()));
const newContent: EmbedableContent = [];
let lastBatchEnd = 0;
let batch: RegExpMatchArray[] = [];
const renderBatch = () => {
if (batch.length > 1) {
// render previous batch
const lastMatchPosition = defaultGetLocation(batch[batch.length - 1]);
const before = subContent.substring(lastBatchEnd, defaultGetLocation(batch[0]).start);
const render = <ImageGallery images={batch.map((m) => m[0])} event={event} />;
newContent.push(before, render);
lastBatchEnd = lastMatchPosition.end;
}
batch = [];
};
for (const match of matches) {
try {
const url = new URL(match[0]);
if (!isImageURL(url)) throw new Error("not an image");
// if this is the first image, add it to the batch
if (batch.length === 0) {
batch = [match];
continue;
}
const last = defaultGetLocation(batch[batch.length - 1]);
const position = defaultGetLocation(match);
const space = subContent.substring(last.end, position.start).trim();
// if there was a non-space between this and the last batch
if (space.length > 0) renderBatch();
batch.push(match);
} catch (e) {
// start a new batch without current match
batch = [];
}
}
renderBatch();
newContent.push(subContent.substring(lastBatchEnd));
return newContent;
}
return subContent;
})
.flat();
}
// nostr:nevent1qqsfhafvv705g5wt8rcaytkj6shsshw3dwgamgfe3za8knk0uq4yesgpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqsrnltk
export function renderImageUrl(match: URL) {
if (!isImageURL(match)) return null;
return <EmbeddedImage src={match.toString()} maxH={["initial", "35vh"]} />;
}

View File

@ -5,3 +5,4 @@ export * from "./common";
export * from "./youtube";
export * from "./nostr";
export * from "./emoji";
export * from "./image";

View File

@ -4,7 +4,7 @@ import { InlineInvoiceCard } from "../inline-invoice-card";
export function embedLightningInvoice(content: EmbedableContent) {
return embedJSX(content, {
name: "Lightning Invoice",
regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/im,
regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/gim,
render: (match) => <InlineInvoiceCard paymentRequest={match[2]} />,
});
}

View File

@ -1,37 +1,35 @@
import { nip19 } from "nostr-tools";
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import QuoteNote from "../note/quote-note";
import { UserLink } from "../user-link";
import { EventPointer, ProfilePointer } from "nostr-tools/lib/nip19";
import { Link } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { matchHashtag, matchNostrLink } from "../../helpers/regexp";
import { getMatchHashtag, getMatchNostrLink, stripInvisibleChar } from "../../helpers/regexp";
import { safeDecode } from "../../helpers/nip19";
import { EmbedEventPointer } from "../embed-event";
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
export function embedNostrLinks(content: EmbedableContent) {
return embedJSX(content, {
name: "nostr-link",
regexp: matchNostrLink,
regexp: getMatchNostrLink(),
render: (match) => {
try {
const decoded = nip19.decode(match[2]);
const decoded = safeDecode(match[2]);
if (!decoded) return null;
switch (decoded.type) {
case "npub":
return <UserLink color="blue.500" pubkey={decoded.data} showAt />;
case "nprofile":
return <UserLink color="blue.500" pubkey={decoded.data.pubkey} showAt />;
case "note":
return <QuoteNote noteId={decoded.data} />;
case "nevent":
return <QuoteNote noteId={decoded.data.id} relays={decoded.data.relays} />;
default:
return null;
}
} catch (e) {
return null;
switch (decoded.type) {
case "npub":
return <UserLink color="blue.500" pubkey={decoded.data} showAt />;
case "nprofile":
return <UserLink color="blue.500" pubkey={decoded.data.pubkey} showAt />;
case "note":
case "nevent":
case "naddr":
case "nrelay":
return <EmbedEventPointer pointer={decoded} />;
default:
return null;
}
},
});
@ -40,7 +38,7 @@ export function embedNostrLinks(content: EmbedableContent) {
export function embedNostrMentions(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
return embedJSX(content, {
name: "nostr-mention",
regexp: /#\[(\d+)\]/,
regexp: /#\[(\d+)\]/g,
render: (match) => {
const index = parseInt(match[1]);
const tag = event?.tags[index];
@ -60,11 +58,14 @@ export function embedNostrMentions(content: EmbedableContent, event: NostrEvent
}
export function embedNostrHashtags(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
const hashtags = event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]?.toLowerCase()) as string[];
const hashtags = event.tags
.filter((t) => t[0] === "t" && t[1])
.map((t) => t[1]?.toLowerCase())
.map(stripInvisibleChar);
return embedJSX(content, {
name: "nostr-hashtag",
regexp: matchHashtag,
regexp: getMatchHashtag(),
getLocation: (match) => {
if (match.index === undefined) throw new Error("match dose not have index");

View File

@ -0,0 +1,76 @@
import { Button, ButtonProps, IconButton, Image, useDisclosure } from "@chakra-ui/react";
import { useCallback, useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import useEventReactions from "../hooks/use-event-reactions";
import { DislikeIcon, LikeIcon } from "./icons";
import { draftEventReaction, groupReactions } from "../helpers/nostr/reactions";
import ReactionDetailsModal from "./reaction-details-modal";
import { useSigningContext } from "../providers/signing-provider";
import clientRelaysService from "../services/client-relays";
import NostrPublishAction from "../classes/nostr-publish-action";
import eventReactionsService from "../services/event-reactions";
import { useCurrentAccount } from "../hooks/use-current-account";
export function ReactionIcon({ emoji, url }: { emoji: string; url?: string }) {
if (emoji === "+") return <LikeIcon />;
if (emoji === "-") return <DislikeIcon />;
if (url) return <Image src={url} title={emoji} alt={emoji} w="1em" h="1em" display="inline" />;
return <span>{emoji}</span>;
}
function ReactionGroupButton({
emoji,
url,
count,
...props
}: Omit<ButtonProps, "leftIcon" | "children"> & { emoji: string; count: number; url?: string }) {
if (count <= 1) {
return <IconButton icon={<ReactionIcon emoji={emoji} url={url} />} aria-label="Reaction" {...props} />;
}
return (
<Button leftIcon={<ReactionIcon emoji={emoji} url={url} />} title={emoji} {...props}>
{count > 1 && count}
</Button>
);
}
export default function EventReactionButtons({ event, max }: { event: NostrEvent; max?: number }) {
const account = useCurrentAccount();
const detailsModal = useDisclosure();
const reactions = useEventReactions(event.id) ?? [];
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
const { requestSignature } = useSigningContext();
const addReaction = useCallback(async (emoji = "+", url?: string) => {
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);
}
}, []);
if (grouped.length === 0) return null;
const clamped = Array.from(grouped);
if (max !== undefined) clamped.length = max;
return (
<>
{clamped.map((group) => (
<ReactionGroupButton
key={group.emoji}
emoji={group.emoji}
url={group.url}
count={group.count}
onClick={() => addReaction(group.emoji, group.url)}
colorScheme={account && group.pubkeys.includes(account?.pubkey) ? "brand" : undefined}
/>
))}
<Button onClick={detailsModal.onOpen}>Show all</Button>
{detailsModal.isOpen && <ReactionDetailsModal isOpen onClose={detailsModal.onClose} reactions={reactions} />}
</>
);
}

View File

@ -1,13 +1,17 @@
import { NostrEvent } from "../types/nostr-event";
import { memo } from "react";
import { verifySignature } from "nostr-tools";
import { useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import { CheckIcon, VerificationFailed } from "./icons";
import useAppSettings from "../hooks/use-app-settings";
export default function EventVerificationIcon({ event }: { event: NostrEvent }) {
const valid = useMemo(() => verifySignature(event), [event]);
function EventVerificationIcon({ event }: { event: NostrEvent }) {
const { showSignatureVerification } = useAppSettings();
if (!showSignatureVerification) return null;
if (!valid) {
if (!verifySignature(event)) {
return <VerificationFailed color="red.500" />;
}
return <CheckIcon color="green.500" />;
}
export default memo(EventVerificationIcon);

View File

@ -260,6 +260,30 @@ export const AtIcon = createIcon({
defaultProps,
});
export const FollowingIcon = createIcon({
displayName: "FollowingIcon",
d: "M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM17.7929 19.9142L21.3284 16.3787L22.7426 17.7929L17.7929 22.7426L14.2574 19.2071L15.6716 17.7929L17.7929 19.9142Z",
defaultProps,
});
export const FollowIcon = createIcon({
displayName: "FollowIcon",
d: "M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM18 17V14H20V17H23V19H20V22H18V19H15V17H18Z",
defaultProps,
});
export const UnfollowIcon = createIcon({
displayName: "UnfollowIcon",
d: "M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM19 17.5858L21.1213 15.4645L22.5355 16.8787L20.4142 19L22.5355 21.1213L21.1213 22.5355L19 20.4142L16.8787 22.5355L15.4645 21.1213L17.5858 19L15.4645 16.8787L16.8787 15.4645L19 17.5858Z",
defaultProps,
});
export const ListIcon = createIcon({
displayName: "ListIcon",
d: "M8 4H21V6H8V4ZM3 3.5H6V6.5H3V3.5ZM3 10.5H6V13.5H3V10.5ZM3 17.5H6V20.5H3V17.5ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z",
defaultProps,
});
export const LiveStreamIcon = createIcon({
displayName: "LiveStreamIcon",
d: "M16 4C16.5523 4 17 4.44772 17 5V9.2L22.2133 5.55071C22.4395 5.39235 22.7513 5.44737 22.9096 5.6736C22.9684 5.75764 23 5.85774 23 5.96033V18.0397C23 18.3158 22.7761 18.5397 22.5 18.5397C22.3974 18.5397 22.2973 18.5081 22.2133 18.4493L17 14.8V19C17 19.5523 16.5523 20 16 20H2C1.44772 20 1 19.5523 1 19V5C1 4.44772 1.44772 4 2 4H16ZM15 6H3V18H15V6ZM7.4 8.82867C7.47607 8.82867 7.55057 8.85036 7.61475 8.8912L11.9697 11.6625C12.1561 11.7811 12.211 12.0284 12.0924 12.2148C12.061 12.2641 12.0191 12.306 11.9697 12.3375L7.61475 15.1088C7.42837 15.2274 7.18114 15.1725 7.06254 14.9861C7.02169 14.9219 7 14.8474 7 14.7713V9.22867C7 9.00776 7.17909 8.82867 7.4 8.82867ZM21 8.84131L17 11.641V12.359L21 15.1587V8.84131Z",
@ -273,11 +297,17 @@ export const ImageGridTimelineIcon = createIcon({
});
export const TextTimelineIcon = createIcon({
displayName: "ImageGridTimeline",
displayName: "TextTimelineIcon",
d: "M8 4H21V6H8V4ZM4.5 6.5C3.67157 6.5 3 5.82843 3 5C3 4.17157 3.67157 3.5 4.5 3.5C5.32843 3.5 6 4.17157 6 5C6 5.82843 5.32843 6.5 4.5 6.5ZM4.5 13.5C3.67157 13.5 3 12.8284 3 12C3 11.1716 3.67157 10.5 4.5 10.5C5.32843 10.5 6 11.1716 6 12C6 12.8284 5.32843 13.5 4.5 13.5ZM4.5 20.4C3.67157 20.4 3 19.7284 3 18.9C3 18.0716 3.67157 17.4 4.5 17.4C5.32843 17.4 6 18.0716 6 18.9C6 19.7284 5.32843 20.4 4.5 20.4ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z",
defaultProps,
});
export const TimelineHealthIcon = createIcon({
displayName: "TimelineHealthIcon",
d: "M13.1962 2.26791L16.4462 7.89708C16.7223 8.37537 16.5584 8.98696 16.0801 9.2631L14.7806 10.0122L15.7811 11.7452L14.049 12.7452L13.0485 11.0122L11.75 11.7631C11.2717 12.0392 10.6601 11.8754 10.384 11.3971L8.5462 8.2146C6.49383 8.8373 5 10.7442 5 13C5 13.6253 5.1148 14.2238 5.32447 14.7756C6.0992 14.284 7.01643 14 8 14C9.68408 14 11.1737 14.8326 12.0797 16.1086L19.7681 11.6704L20.7681 13.4024L12.8898 17.9509C12.962 18.2892 13 18.6401 13 19C13 19.3427 12.9655 19.6773 12.8999 20.0006L21 20V22L4.00054 22.0012C3.3723 21.1653 3 20.1261 3 19C3 17.9927 3.29782 17.0551 3.81021 16.2702C3.29276 15.2948 3 14.1816 3 13C3 10.0047 4.88131 7.44875 7.52677 6.44942L7.13397 5.76791C6.58169 4.81133 6.90944 3.58815 7.86603 3.03586L10.4641 1.53586C11.4207 0.983577 12.6439 1.31133 13.1962 2.26791ZM8 16C6.34315 16 5 17.3431 5 19C5 19.3506 5.06014 19.6871 5.17067 19.9999H10.8293C10.9399 19.6871 11 19.3506 11 19C11 17.3431 9.65685 16 8 16ZM11.4641 3.26791L8.86602 4.76791L11.616 9.53105L14.2141 8.03105L11.4641 3.26791Z",
defaultProps,
});
export const MapIcon = createIcon({
displayName: "MapIcon",
d: "M4 6.14286V18.9669L9.06476 16.7963L15.0648 19.7963L20 17.6812V4.85714L21.303 4.2987C21.5569 4.18992 21.8508 4.30749 21.9596 4.56131C21.9862 4.62355 22 4.69056 22 4.75827V19L15 22L9 19L2.69696 21.7013C2.44314 21.8101 2.14921 21.6925 2.04043 21.4387C2.01375 21.3765 2 21.3094 2 21.2417V7L4 6.14286ZM16.2426 11.2426L12 15.4853L7.75736 11.2426C5.41421 8.89949 5.41421 5.10051 7.75736 2.75736C10.1005 0.414214 13.8995 0.414214 16.2426 2.75736C18.5858 5.10051 18.5858 8.89949 16.2426 11.2426ZM12 12.6569L14.8284 9.82843C16.3905 8.26633 16.3905 5.73367 14.8284 4.17157C13.2663 2.60948 10.7337 2.60948 9.17157 4.17157C7.60948 5.73367 7.60948 8.26633 9.17157 9.82843L12 12.6569Z",
@ -302,6 +332,54 @@ export const StarHalfIcon = createIcon({
defaultProps,
});
export const ErrorIcon = createIcon({
displayName: "ErrorIcon",
d: "M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z",
defaultProps,
});
export const BookmarkIcon = createIcon({
displayName: "BookmarkIcon",
d: "M5 2H19C19.5523 2 20 2.44772 20 3V22.1433C20 22.4194 19.7761 22.6434 19.5 22.6434C19.4061 22.6434 19.314 22.6168 19.2344 22.5669L12 18.0313L4.76559 22.5669C4.53163 22.7136 4.22306 22.6429 4.07637 22.4089C4.02647 22.3293 4 22.2373 4 22.1433V3C4 2.44772 4.44772 2 5 2ZM18 4H6V19.4324L12 15.6707L18 19.4324V4Z",
defaultProps,
});
export const BookmarkedIcon = createIcon({
displayName: "BookmaredkIcon",
d: "M4 2H20C20.5523 2 21 2.44772 21 3V22.2763C21 22.5525 20.7761 22.7764 20.5 22.7764C20.4298 22.7764 20.3604 22.7615 20.2963 22.7329L12 19.0313L3.70373 22.7329C3.45155 22.8455 3.15591 22.7322 3.04339 22.4801C3.01478 22.4159 3 22.3465 3 22.2763V3C3 2.44772 3.44772 2 4 2ZM12 13.5L14.9389 15.0451L14.3776 11.7725L16.7553 9.45492L13.4695 8.97746L12 6L10.5305 8.97746L7.24472 9.45492L9.62236 11.7725L9.06107 15.0451L12 13.5Z",
defaultProps,
});
export const PlayIcon = createIcon({
displayName: "PlayIcon",
d: "M16.3944 11.9998L10 7.73686V16.2628L16.3944 11.9998ZM19.376 12.4158L8.77735 19.4816C8.54759 19.6348 8.23715 19.5727 8.08397 19.3429C8.02922 19.2608 8 19.1643 8 19.0656V4.93408C8 4.65794 8.22386 4.43408 8.5 4.43408C8.59871 4.43408 8.69522 4.4633 8.77735 4.51806L19.376 11.5838C19.6057 11.737 19.6678 12.0474 19.5146 12.2772C19.478 12.3321 19.4309 12.3792 19.376 12.4158Z",
defaultProps,
});
export const StopIcon = createIcon({
displayName: "StopIcon",
d: "M7 7V17H17V7H7ZM6 5H18C18.5523 5 19 5.44772 19 6V18C19 18.5523 18.5523 19 18 19H6C5.44772 19 5 18.5523 5 18V6C5 5.44772 5.44772 5 6 5Z",
defaultProps,
});
export const AddReactionIcon = createIcon({
displayName: "AddReactionIcon",
d: "M19.0001 13.9999V16.9999H22.0001V18.9999H18.9991L19.0001 21.9999H17.0001L16.9991 18.9999H14.0001V16.9999H17.0001V13.9999H19.0001ZM20.2426 4.75736C22.505 7.0244 22.5829 10.636 20.4795 12.992L19.06 11.574C20.3901 10.0499 20.3201 7.65987 18.827 6.1701C17.3244 4.67092 14.9076 4.60701 13.337 6.01688L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L13.412 20.069L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736Z",
defaultProps,
});
export const EmojiIcon = createIcon({
displayName: "EmojiIcon",
d: "M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM7 12H9C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12Z",
defaultProps,
});
export const GoalIcon = createIcon({
displayName: "GoalIcon",
d: "M5 3V19H21V21H3V3H5ZM20.2929 6.29289L21.7071 7.70711L16 13.4142L13 10.415L8.70711 14.7071L7.29289 13.2929L13 7.58579L16 10.585L20.2929 6.29289Z",
defaultProps,
});
export const FileIcon = createIcon({
displayName: "FileIcon",
d: "M9 2.00318V2H19.9978C20.5513 2 21 2.45531 21 2.9918V21.0082C21 21.556 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5501 3 20.9932V8L9 2.00318ZM5.82918 8H9V4.83086L5.82918 8ZM11 4V9C11 9.55228 10.5523 10 10 10H5V20H19V4H11Z",

View File

@ -1,76 +0,0 @@
import {
LinkProps,
Link,
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Image,
ModalFooter,
Button,
} from "@chakra-ui/react";
import { PropsWithChildren, createContext, forwardRef, useContext, useState } from "react";
const GalleryContext = createContext({
isOpen: false,
openImage(url: string) {},
});
export function useGalleryContext() {
return useContext(GalleryContext);
}
export const ImageGalleryLink = forwardRef(({ children, href, ...props }: Omit<LinkProps, "onClick">, ref) => {
const { openImage } = useGalleryContext();
return (
<Link
{...props}
href={href}
onClick={(e) => {
if (href) {
e.preventDefault();
openImage(href);
}
}}
ref={ref}
>
{children}
</Link>
);
});
export function ImageGalleryProvider({ children }: PropsWithChildren) {
const { isOpen, onOpen, onClose } = useDisclosure();
const [image, setImage] = useState("");
const openImage = (url: string) => {
setImage(url);
onOpen();
};
const context = { isOpen, openImage };
return (
<GalleryContext.Provider value={context}>
{children}
<Modal isOpen={isOpen} onClose={onClose} size="6xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Image</ModalHeader>
<ModalCloseButton />
<ModalBody p="0">
<Image src={image} w="full" />
</ModalBody>
<ModalFooter>
<Button colorScheme="brand" onClick={onClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</GalleryContext.Provider>
);
}

View File

@ -32,9 +32,9 @@ function AccountItem({ account }: { account: Account }) {
};
return (
<Box display="flex" gap="4" alignItems="center" cursor="pointer" onClick={handleClick}>
<Box display="flex" gap="2" alignItems="center" cursor="pointer" onClick={handleClick}>
<UserAvatar pubkey={pubkey} size="sm" />
<Box flex={1}>
<Box flex={1} overflow="hidden">
<Text isTruncated>{getUserDisplayName(metadata, pubkey)}</Text>
<AccountInfoBadge fontSize="0.7em" account={account} />
</Box>

View File

@ -1,94 +1,67 @@
import { SettingsIcon } from "@chakra-ui/icons";
import { Avatar, Button, Flex, FlexProps, Heading, IconButton, LinkOverlay, Text, VStack } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { Avatar, Button, Flex, FlexProps, Heading, IconButton, LinkOverlay, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import accountService from "../../services/account";
import { ConnectedRelays } from "../connected-relays";
import {
ChatIcon,
EditIcon,
FeedIcon,
FileIcon,
LiveStreamIcon,
LogoutIcon,
MapIcon,
NotificationIcon,
ProfileIcon,
RelayIcon,
SearchIcon,
} from "../icons";
import { EditIcon, LogoutIcon } from "../icons";
import ProfileLink from "./profile-link";
import AccountSwitcher from "./account-switcher";
import { useContext } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
import PublishLog from "../publish-log";
import NavItems from "./nav-items";
export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
const navigate = useNavigate();
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
return (
<Flex {...props} gap="2" direction="column" width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Flex gap="2" alignItems="center" position="relative">
<LinkOverlay as={RouterLink} to="/" />
<Avatar src="/apple-touch-icon.png" size="sm" />
<Heading size="md">noStrudel</Heading>
</Flex>
<ProfileLink />
<AccountSwitcher />
<Button onClick={() => navigate("/")} leftIcon={<FeedIcon />}>
Home
</Button>
<Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />}>
Notifications
</Button>
<Button onClick={() => navigate("/dm")} leftIcon={<ChatIcon />}>
Messages
</Button>
<Button onClick={() => navigate("/search")} leftIcon={<SearchIcon />}>
Search
</Button>
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />}>
Streams
</Button>
<Button onClick={() => navigate("/files")} leftIcon={<FileIcon />}>
Files
</Button>
<Button onClick={() => navigate("/map")} leftIcon={<MapIcon />}>
Map
</Button>
<Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}>
Profile
</Button>
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}>
Relays
</Button>
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />}>
Settings
</Button>
{account && (
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />}>
Logout
</Button>
)}
{account?.readonly && (
<Text color="red.200" textAlign="center">
Readonly Mode
</Text>
)}
<ConnectedRelays />
<Flex justifyContent="flex-end" py="8">
<IconButton
icon={<EditIcon />}
aria-label="New post"
w="4rem"
h="4rem"
fontSize="1.5rem"
borderRadius="50%"
colorScheme="brand"
onClick={() => openModal()}
/>
<Flex
{...props}
gap="2"
direction="column"
width="15rem"
pt="2"
alignItems="stretch"
flexShrink={0}
h="100vh"
overflowY="auto"
overflowX="hidden"
>
<Flex direction="column" flexShrink={0} gap="2">
<Flex gap="2" alignItems="center" position="relative">
<LinkOverlay as={RouterLink} to="/" />
<Avatar src="/apple-touch-icon.png" size="sm" />
<Heading size="md">noStrudel</Heading>
</Flex>
<ProfileLink />
<AccountSwitcher />
<NavItems />
{account && (
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />} justifyContent="flex-start">
Logout
</Button>
)}
{account?.readonly && (
<Text color="red.200" textAlign="center">
Readonly Mode
</Text>
)}
<ConnectedRelays />
<Flex justifyContent="flex-end" py="8">
<IconButton
icon={<EditIcon />}
aria-label="New post"
w="4rem"
h="4rem"
fontSize="1.5rem"
borderRadius="50%"
colorScheme="brand"
onClick={() => openModal()}
/>
</Flex>
</Flex>
<PublishLog overflowY="auto" minH="15rem" />
</Flex>
);
}

View File

@ -7,25 +7,17 @@ import DesktopSideNav from "./desktop-side-nav";
import MobileBottomNav from "./mobile-bottom-nav";
export default function Layout({ children }: { children: React.ReactNode }) {
const showSideNav = useBreakpointValue({ base: true, md: false });
const isMobile = useBreakpointValue({ base: true, md: false });
return (
<>
<ReloadPrompt mb="2" />
<Container size="lg" display="flex" padding="0" gap="4" alignItems="flex-start">
{!showSideNav && <DesktopSideNav position="sticky" top="0" />}
<Flex
flexGrow={1}
direction="column"
w="full"
overflowX="hidden"
overflowY="visible"
pb={showSideNav ? "14" : 0}
minH="50vh"
>
{!isMobile && <DesktopSideNav position="sticky" top="0" />}
<Flex flexGrow={1} direction="column" w="full" overflow="hidden" pb={isMobile ? "14" : 0} minH="50vh">
<ErrorBoundary>{children}</ErrorBoundary>
</Flex>
{showSideNav && (
{isMobile && (
<MobileBottomNav
position="fixed"
bottom="0"

View File

@ -11,27 +11,18 @@ import {
Flex,
Text,
} from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { Link as RouterLink } from "react-router-dom";
import { ConnectedRelays } from "../connected-relays";
import {
FileIcon,
HomeIcon,
LiveStreamIcon,
LogoutIcon,
MapIcon,
ProfileIcon,
RelayIcon,
SearchIcon,
SettingsIcon,
} from "../icons";
import { LogoutIcon } from "../icons";
import { UserAvatar } from "../user-avatar";
import { UserLink } from "../user-link";
import AccountSwitcher from "./account-switcher";
import { useCurrentAccount } from "../../hooks/use-current-account";
import accountService from "../../services/account";
import NavItems from "./nav-items";
export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "children">) {
const navigate = useNavigate();
const account = useCurrentAccount();
return (
@ -39,7 +30,7 @@ export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "childr
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader px="4" py="4">
<DrawerHeader px="2" py="4">
{account ? (
<Flex gap="2">
<UserAvatar pubkey={account.pubkey} size="sm" noProxy />
@ -55,32 +46,9 @@ export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "childr
<DrawerBody padding={0} overflowY="auto" overflowX="hidden">
<AccountSwitcher />
<Flex direction="column" gap="2" padding="2">
<Button onClick={() => navigate(`/`)} leftIcon={<HomeIcon />}>
Home
</Button>
<Button onClick={() => navigate(`/search`)} leftIcon={<SearchIcon />}>
Search
</Button>
<Button onClick={() => navigate(`/profile`)} leftIcon={<ProfileIcon />}>
Profile
</Button>
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />}>
Streams
</Button>
<Button onClick={() => navigate("/files")} leftIcon={<FileIcon />}>
Files
</Button>
<Button onClick={() => navigate("/map")} leftIcon={<MapIcon />}>
Map
</Button>
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}>
Relays
</Button>
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />}>
Settings
</Button>
<NavItems isInDrawer />
{account ? (
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />}>
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />} justifyContent="flex-start">
Logout
</Button>
) : (

View File

@ -0,0 +1,75 @@
import { AbsoluteCenter, Box, Button, Divider } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import {
ChatIcon,
EmojiIcon,
FeedIcon,
FileIcon,
GoalIcon,
ListIcon,
LiveStreamIcon,
MapIcon,
NotificationIcon,
RelayIcon,
SearchIcon,
SettingsIcon,
ToolsIcon,
} from "../icons";
export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean }) {
const navigate = useNavigate();
return (
<>
<Button onClick={() => navigate("/")} leftIcon={<FeedIcon />} justifyContent="flex-start">
Notes
</Button>
<Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />} justifyContent="flex-start">
Notifications
</Button>
<Button onClick={() => navigate("/dm")} leftIcon={<ChatIcon />} justifyContent="flex-start">
Messages
</Button>
<Button onClick={() => navigate("/search")} leftIcon={<SearchIcon />} justifyContent="flex-start">
Search
</Button>
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />} justifyContent="flex-start">
Relays
</Button>
<Box position="relative" py="4">
<Divider />
<AbsoluteCenter
backgroundColor={isInDrawer ? "var(--drawer-bg)" : "var(--chakra-colors-chakra-body-bg)"}
px="2"
>
Other Stuff
</AbsoluteCenter>
</Box>
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />} justifyContent="flex-start">
Streams
</Button>
<Button onClick={() => navigate("/lists")} leftIcon={<ListIcon />} justifyContent="flex-start">
Lists
</Button>
<Button onClick={() => navigate("/goals")} leftIcon={<GoalIcon />} justifyContent="flex-start">
Goals
</Button>
<Button onClick={() => navigate("/emojis")} leftIcon={<EmojiIcon />} justifyContent="flex-start">
Emojis
</Button>
<Button onClick={() => navigate("/files")} leftIcon={<FileIcon />} justifyContent="flex-start">
Files
</Button>
<Button onClick={() => navigate("/map")} leftIcon={<MapIcon />} justifyContent="flex-start">
Map
</Button>
<Button onClick={() => navigate("/tools")} leftIcon={<ToolsIcon />} justifyContent="flex-start">
Tools
</Button>
<Divider my="2" />
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />} justifyContent="flex-start">
Settings
</Button>
</>
);
}

View File

@ -1,27 +1,29 @@
import { Box, Button, LinkBox, Text } from "@chakra-ui/react";
import { Box, Button, LinkBox, LinkOverlay } from "@chakra-ui/react";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { UserAvatar } from "../user-avatar";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { truncatedId } from "../../helpers/nostr/event";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { nip19 } from "nostr-tools";
import { getUserDisplayName } from "../../helpers/user-metadata";
function ProfileButton() {
const account = useCurrentAccount()!;
const metadata = useUserMetadata(account.pubkey);
return (
<LinkBox
as={RouterLink}
to={`/u/${normalizeToBech32(account.pubkey, Bech32Prefix.Pubkey)}`}
display="flex"
gap="2"
overflow="hidden"
>
<UserAvatar pubkey={account.pubkey} noProxy />
<LinkBox borderRadius="lg" borderWidth={1} p="2" display="flex" gap="2" alignItems="center">
<UserAvatar pubkey={account.pubkey} noProxy size="sm" />
<Box>
<Text fontWeight="bold">{metadata?.name}</Text>
<Text>{truncatedId(normalizeToBech32(account.pubkey) ?? "")}</Text>
<LinkOverlay
as={RouterLink}
to={`/u/${nip19.npubEncode(account.pubkey)}`}
whiteSpace="nowrap"
fontWeight="bold"
fontSize="lg"
>
{getUserDisplayName(metadata, account.pubkey)}
</LinkOverlay>
</Box>
</LinkBox>
);

View File

@ -0,0 +1,213 @@
import {
DependencyList,
MutableRefObject,
PropsWithChildren,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button, Flex, FlexProps, Spacer, useDisclosure } from "@chakra-ui/react";
import { useUnmount } from "react-use";
import { Link as RouterLink } from "react-router-dom";
import Lightbox, { RenderSlideContainerProps, Slide } from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Counter from "yet-another-react-lightbox/plugins/counter";
import Download from "yet-another-react-lightbox/plugins/download";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/counter.css";
// extend slide type to include eventId
declare module "yet-another-react-lightbox" {
interface GenericSlide {
event?: NostrEvent;
}
}
import { NostrEvent } from "../types/nostr-event";
import { UserAvatarLink } from "./user-avatar-link";
import { UserLink } from "./user-link";
import { UserDnsIdentityIcon } from "./user-dns-identity-icon";
import styled from "@emotion/styled";
import { getSharableNoteId } from "../helpers/nip19";
type RefType = MutableRefObject<HTMLElement | null>;
function getElementPath(element: HTMLElement): HTMLElement[] {
if (!element.parentElement) return [element];
return [...getElementPath(element.parentElement), element];
}
function comparePaths(a: HTMLElement[] | null, b: HTMLElement[] | null) {
if (a && !b) return -1;
if (!b && a) return 1;
if (!a || !b) return 0;
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] !== b[i] && a[i].parentElement === b[i].parentElement) {
const parent = a[i].parentElement;
if (!parent) return 0;
const children = Array.from(parent.children);
return Math.sign(children.indexOf(a[i]) - children.indexOf(b[i]));
}
}
return 0;
}
const LightboxContext = createContext({
isOpen: false,
removeSlide(ref: RefType) {},
showSlide(ref: RefType) {},
addSlide(ref: RefType, slide: Slide) {},
});
export function useLightbox() {
return useContext(LightboxContext);
}
export function useRegisterSlide(ref?: RefType, slide?: Slide, watch: DependencyList[] = []) {
const { showSlide, addSlide, removeSlide } = useLightbox();
const show = useCallback(() => {
if (ref) showSlide(ref);
}, [ref, showSlide]);
useEffect(() => {
if (ref && slide) addSlide(ref, slide);
}, [ref, ...watch]);
useUnmount(() => {
if (ref) removeSlide(ref);
});
return { show };
}
type DynamicSlide = {
ref: RefType;
slide: Slide;
};
const refPaths = new WeakMap<RefType, HTMLElement[]>();
function getRefPath(ref: RefType) {
if (ref.current === null) return null;
const cache = refPaths.get(ref);
if (cache) return cache;
const path = getElementPath(ref.current);
refPaths.set(ref, path);
return path;
}
function EventSlideHeader({ event, ...props }: { event: NostrEvent } & Omit<FlexProps, "children">) {
const encoded = useMemo(() => getSharableNoteId(event.id), [event.id]);
return (
<Flex gap="2" alignItems="center" p="2" {...props}>
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Spacer />
<Button as={RouterLink} to={`/n/${encoded}`} colorScheme="brand" size="sm">
View Note
</Button>
</Flex>
);
}
const StyledContainer = styled(Flex)`
& > .yarl__fullsize {
overflow: hidden;
}
`;
export function CustomSlideContainer({ slide, children }: RenderSlideContainerProps) {
if (slide.event) {
return (
<StyledContainer direction="column" w="full" h="full" overflow="hidden">
<EventSlideHeader event={slide.event} w="full" maxW="4xl" mx="auto" bg="Background" flexShrink={0} />
{children}
</StyledContainer>
);
}
return <>{children}</>;
}
export function LightboxProvider({ children }: PropsWithChildren) {
const lightbox = useDisclosure();
const [index, setIndex] = useState(0);
const [slides, setSlides] = useState<DynamicSlide[]>([]);
const orderedSlides = useRef<DynamicSlide[]>([]);
orderedSlides.current = Array.from(Object.values(slides)).sort((a, b) =>
comparePaths(getRefPath(a.ref), getRefPath(b.ref)),
);
const addSlide = useCallback(
(ref: RefType, slide: Slide) => {
setSlides((arr) => {
if (arr.some((s) => s.ref === ref)) {
return arr.map((s) => (s.ref === ref ? { ref, slide } : s));
}
return arr.concat({ ref, slide });
});
},
[setSlides],
);
const removeSlide = useCallback(
(ref: RefType) => {
setSlides((arr) => arr.filter((s) => s.ref !== ref));
},
[setSlides],
);
const showSlide = useCallback(
(ref: RefType) => {
for (let i = 0; i < orderedSlides.current.length; i++) {
if (orderedSlides.current[i].ref === ref) {
// set slide index
setIndex(i);
lightbox.onOpen();
return;
}
}
// else select first image
setIndex(0);
lightbox.onOpen();
},
[lightbox.onOpen, setIndex],
);
const context = useMemo(
() => ({ isOpen: lightbox.isOpen, removeSlide, addSlide, showSlide }),
[lightbox.isOpen, removeSlide, addSlide, showSlide],
);
const lightboxSlides = useMemo(() => orderedSlides.current.map((s) => s.slide), [orderedSlides.current, slides]);
const handleView = useCallback(
({ index }: { index: number }) => {
setIndex(index);
},
[setIndex],
);
return (
<LightboxContext.Provider value={context}>
{children}
<Lightbox
index={index}
open={lightbox.isOpen}
slides={lightboxSlides}
close={lightbox.onClose}
plugins={[Zoom, Counter, Download]}
zoom={{ scrollToZoom: true, maxZoomPixelRatio: 4, wheelZoomDistanceFactor: 500 }}
controller={{ closeOnBackdropClick: true, closeOnPullDown: true }}
on={{ view: handleView }}
render={{
slideContainer: CustomSlideContainer,
}}
/>
</LightboxContext.Provider>
);
}

View File

@ -0,0 +1,129 @@
import React, { TextareaHTMLAttributes } from "react";
import { Image, Input, InputProps, Textarea, TextareaProps } from "@chakra-ui/react";
import ReactTextareaAutocomplete, {
ItemComponentProps,
TextareaProps as ReactTextareaAutocompleteProps,
TriggerType,
} from "@webscopeio/react-textarea-autocomplete";
import "@webscopeio/react-textarea-autocomplete/style.css";
import { nip19 } from "nostr-tools";
import { matchSorter } from "match-sorter/dist/match-sorter.esm.js";
import { Emoji, useContextEmojis } from "../providers/emoji-provider";
import { UserDirectory, useUserDirectoryContext } from "../providers/user-directory-provider";
import { UserAvatar } from "./user-avatar";
import userMetadataService from "../services/user-metadata";
export type PeopleToken = { pubkey: string; names: string[] };
type Token = Emoji | PeopleToken;
function isEmojiToken(token: Token): token is Emoji {
return Object.hasOwn(token, "char");
}
function isPersonToken(token: Token): token is PeopleToken {
return Object.hasOwn(token, "pubkey");
}
const Item = ({ entity }: ItemComponentProps<Token>) => {
if (isEmojiToken(entity)) {
const { url, name, char } = entity;
if (url)
return (
<span>
{name}: <Image src={url} h="1.2em" w="1.2em" display="inline-block" verticalAlign="middle" title={name} />
</span>
);
else return <span>{`${name}: ${char}`}</span>;
} else if (isPersonToken(entity)) {
return (
<span>
<UserAvatar pubkey={entity.pubkey} size="xs" /> {entity.names[0]}
</span>
);
} else return null;
};
function output(token: Token) {
if (isEmojiToken(token)) {
return token.char;
} else if (isPersonToken(token)) {
return "nostr:" + nip19.npubEncode(token.pubkey);
} else return "";
}
function getUsersFromDirectory(directory: UserDirectory) {
const people: PeopleToken[] = [];
for (const pubkey of directory) {
const metadata = userMetadataService.getSubject(pubkey).value;
if (!metadata) continue;
const names: string[] = [];
if (metadata.display_name) names.push(metadata.display_name);
if (metadata.name) names.push(metadata.name);
if (names.length > 0) {
people.push({ pubkey, names });
}
}
return people;
}
const Loading: ReactTextareaAutocompleteProps<
Token,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>["loadingComponent"] = ({ data }) => <div>Loading</div>;
function useAutocompleteTriggers() {
const emojis = useContextEmojis();
const getDirectory = useUserDirectoryContext();
const triggers: TriggerType<Token> = {
":": {
dataProvider: (token: string) => {
return matchSorter(emojis, token.trim(), { keys: ["keywords"] }).slice(0, 10);
},
component: Item,
output,
},
"@": {
dataProvider: async (token: string) => {
const dir = getUsersFromDirectory(await getDirectory());
return matchSorter(dir, token.trim(), { keys: ["names"] }).slice(0, 10);
},
component: Item,
output,
},
};
return triggers;
}
export function MagicInput({ ...props }: InputProps) {
const triggers = useAutocompleteTriggers();
return (
// @ts-ignore
<ReactTextareaAutocomplete<Token, InputProps>
textAreaComponent={Input}
{...props}
loadingComponent={Loading}
renderToBody
minChar={0}
trigger={triggers}
/>
);
}
export default function MagicTextArea({ ...props }: TextareaProps) {
const triggers = useAutocompleteTriggers();
return (
// @ts-ignore
<ReactTextareaAutocomplete<Token, TextareaProps>
{...props}
textAreaComponent={Textarea}
loadingComponent={Loading}
renderToBody
minChar={0}
trigger={triggers}
/>
);
}

View File

@ -1,16 +1,16 @@
import { useMemo } from "react";
import { Link, LinkProps } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { truncatedId } from "../helpers/nostr/event";
import { nip19 } from "nostr-tools";
import { getSharableNoteId } from "../helpers/nip19";
import { truncatedId } from "../helpers/nostr/events";
export type NoteLinkProps = LinkProps & {
noteId: string;
};
export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: NoteLinkProps) => {
const encoded = useMemo(() => getSharableNoteId(noteId), [noteId]);
const encoded = useMemo(() => nip19.noteEncode(noteId), [noteId]);
return (
<Link as={RouterLink} to={`/n/${encoded}`} color={color} {...props}>

View File

@ -1,24 +0,0 @@
import { useContext } from "react";
import { IconButton } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { QuoteRepostIcon } from "../../icons";
import { PostModalContext } from "../../../providers/post-modal-provider";
import { buildQuoteRepost } from "../../../helpers/nostr/event";
import { useCurrentAccount } from "../../../hooks/use-current-account";
export function QuoteRepostButton({ event }: { event: NostrEvent }) {
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
const handleClick = () => openModal(buildQuoteRepost(event));
return (
<IconButton
icon={<QuoteRepostIcon />}
onClick={handleClick}
aria-label="Quote repost"
title="Quote repost"
isDisabled={account?.readonly ?? true}
/>
);
}

View File

@ -1,79 +0,0 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useState } from "react";
import { nostrPostAction } from "../../../classes/nostr-post-action";
import { random } from "../../../helpers/array";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useSigningContext } from "../../../providers/signing-provider";
import clientRelaysService from "../../../services/client-relays";
import eventReactionsService from "../../../services/event-reactions";
import { getEventRelays } from "../../../services/event-relays";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { LikeIcon } from "../../icons";
export default function ReactionButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
const { requestSignature } = useSigningContext();
const account = useCurrentAccount();
const reactions = useEventReactions(note.id) ?? [];
const [loading, setLoading] = useState(false);
const handleClick = async (reaction = "+") => {
const eventRelays = getEventRelays(note.id).value;
const event: DraftNostrEvent = {
kind: Kind.Reaction,
content: reaction,
tags: [
["e", note.id, random(eventRelays)],
["p", note.pubkey], // TODO: pick a relay for the user
],
created_at: dayjs().unix(),
};
const signed = await requestSignature(event);
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
nostrPostAction(writeRelays, signed);
eventReactionsService.handleEvent(signed);
}
setLoading(false);
};
const customReaction = () => {
const input = window.prompt("Enter Reaction");
if (!input || [...input].length !== 1) return;
handleClick(input);
};
const isLiked = !!account && reactions.some((event) => event.pubkey === account.pubkey);
return (
// <Popover placement="bottom" trigger="hover" openDelay={500}>
// <PopoverTrigger>
<Button
leftIcon={<LikeIcon />}
aria-label="Like Note"
title="Like Note"
onClick={() => handleClick("+")}
isLoading={loading}
colorScheme={isLiked ? "brand" : undefined}
{...props}
>
{reactions?.length ?? 0}
</Button>
// </PopoverTrigger>
// <PopoverContent>
// <PopoverArrow />
// <PopoverBody>
// <Flex gap="2">
// <IconButton icon={<LikeIcon />} onClick={() => handleClick("+")} aria-label="like" />
// <IconButton icon={<DislikeIcon />} onClick={() => handleClick("-")} aria-label="dislike" />
// <IconButton icon={<span>🤙</span>} onClick={() => handleClick("🤙")} aria-label="different like" />
// <IconButton icon={<span>❤️</span>} onClick={() => handleClick("❤️")} aria-label="different like" />
// <Button onClick={customReaction}>Custom</Button>
// </Flex>
// </PopoverBody>
// </PopoverContent>
// </Popover>
);
}

View File

@ -1,24 +0,0 @@
import { useContext } from "react";
import { IconButton } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { ReplyIcon } from "../../icons";
import { PostModalContext } from "../../../providers/post-modal-provider";
import { buildReply } from "../../../helpers/nostr/event";
import { useCurrentAccount } from "../../../hooks/use-current-account";
export function ReplyButton({ event }: { event: NostrEvent }) {
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
const reply = () => openModal(buildReply(event));
return (
<IconButton
icon={<ReplyIcon />}
title="Reply"
aria-label="Reply"
onClick={reply}
isDisabled={account?.readonly ?? true}
/>
);
}

View File

@ -0,0 +1,120 @@
import { useCallback, useState } from "react";
import {
IconButton,
IconButtonProps,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuItemOption,
MenuList,
MenuOptionGroup,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import { useSigningContext } from "../../../providers/signing-provider";
import useUserLists from "../../../hooks/use-user-lists";
import {
NOTE_LIST_KIND,
draftAddEvent,
draftRemoveEvent,
getEventsFromList,
getListName,
} from "../../../helpers/nostr/lists";
import { NostrEvent } from "../../../types/nostr-event";
import { getEventCoordinate } from "../../../helpers/nostr/events";
import clientRelaysService from "../../../services/client-relays";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { BookmarkIcon, BookmarkedIcon, PlusCircleIcon } from "../../icons";
import NewListModal from "../../../views/lists/components/new-list-modal";
export default function BookmarkButton({ event, ...props }: { event: NostrEvent } & Omit<IconButtonProps, "icon">) {
const toast = useToast();
const newListModal = useDisclosure();
const account = useCurrentAccount();
const { requestSignature } = useSigningContext();
const [isLoading, setLoading] = useState(false);
const lists = useUserLists(account?.pubkey).filter((list) => list.kind === NOTE_LIST_KIND);
const inLists = lists.filter((list) => getEventsFromList(list).some((p) => p.id === event.id));
const handleChange = useCallback(
async (cords: string | string[]) => {
if (!Array.isArray(cords)) return;
const writeRelays = clientRelaysService.getWriteUrls();
setLoading(true);
try {
const addToList = lists.find((list) => !inLists.includes(list) && cords.includes(getEventCoordinate(list)));
const removeFromList = lists.find(
(list) => inLists.includes(list) && !cords.includes(getEventCoordinate(list)),
);
if (addToList) {
const draft = draftAddEvent(addToList, event.id);
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Add to list", writeRelays, signed);
} else if (removeFromList) {
const draft = draftRemoveEvent(removeFromList, event.id);
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Remove from list", writeRelays, signed);
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
},
[lists, event.id],
);
return (
<>
<Menu closeOnSelect={false}>
<MenuButton
as={IconButton}
icon={inLists.length > 0 ? <BookmarkedIcon /> : <BookmarkIcon />}
isDisabled={account?.readonly ?? true}
{...props}
/>
<MenuList minWidth="240px">
{lists.length > 0 && (
<MenuOptionGroup
type="checkbox"
value={inLists.map((list) => getEventCoordinate(list))}
onChange={handleChange}
>
{lists.map((list) => (
<MenuItemOption
key={getEventCoordinate(list)}
value={getEventCoordinate(list)}
isDisabled={account?.readonly && isLoading}
isTruncated
maxW="90vw"
>
{getListName(list)}
</MenuItemOption>
))}
</MenuOptionGroup>
)}
<MenuDivider />
<MenuItem icon={<PlusCircleIcon />} onClick={newListModal.onOpen}>
New list
</MenuItem>
</MenuList>
</Menu>
{newListModal.isOpen && (
<NewListModal
onClose={newListModal.onClose}
isOpen
onCreated={newListModal.onClose}
initKind={NOTE_LIST_KIND}
allowSelectKind={false}
/>
)}
</>
);
}

View File

@ -0,0 +1,23 @@
import { ButtonGroup, ButtonGroupProps, Divider, useBreakpointValue } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import ReactionButton from "./reaction-button";
import EventReactionButtons from "../../event-reactions";
import useEventReactions from "../../../hooks/use-event-reactions";
export default function NoteReactions({ event, ...props }: Omit<ButtonGroupProps, "children"> & { event: NostrEvent }) {
const reactions = useEventReactions(event.id) ?? [];
const max = useBreakpointValue({ base: undefined, md: 4 });
return (
<ButtonGroup spacing="1" {...props}>
<ReactionButton event={event} />
{reactions.length > 0 && (
<>
<Divider orientation="vertical" h="1.5rem" />
<EventReactionButtons event={event} max={max} />
</>
)}
</ButtonGroup>
);
}

View File

@ -0,0 +1,37 @@
import { useContext } from "react";
import { ButtonProps, IconButton, IconButtonProps } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
import { NostrEvent } from "../../../types/nostr-event";
import { QuoteRepostIcon } from "../../icons";
import { PostModalContext } from "../../../providers/post-modal-provider";
import { getSharableNoteId } from "../../../helpers/nip19";
export type QuoteRepostButtonProps = Omit<ButtonProps, "children" | "onClick"> & {
event: NostrEvent;
};
export function QuoteRepostButton({
event,
"aria-label": ariaLabel = "Quote repost",
title = "Quote repost",
...props
}: QuoteRepostButtonProps) {
const { openModal } = useContext(PostModalContext);
const handleClick = () => {
const nevent = getSharableNoteId(event.id);
const draft = {
kind: Kind.Text,
tags: [],
content: "nostr:" + nevent,
created_at: dayjs().unix(),
};
openModal(draft);
};
return (
<IconButton icon={<QuoteRepostIcon />} onClick={handleClick} aria-label={ariaLabel} title={title} {...props} />
);
}

View File

@ -0,0 +1,54 @@
import {
ButtonProps,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useSigningContext } from "../../../providers/signing-provider";
import clientRelaysService from "../../../services/client-relays";
import eventReactionsService from "../../../services/event-reactions";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { AddReactionIcon } from "../../icons";
import ReactionPicker from "../../reaction-picker";
import { draftEventReaction } from "../../../helpers/nostr/reactions";
export default function ReactionButton({ event, ...props }: { event: NostrEvent } & Omit<ButtonProps, "children">) {
const { requestSignature } = useSigningContext();
const reactions = useEventReactions(event.id) ?? [];
const addReaction = async (emoji = "+", url?: string) => {
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);
}
};
return (
<Popover isLazy>
<PopoverTrigger>
<IconButton icon={<AddReactionIcon />} aria-label="Add Reaction" {...props}>
{reactions?.length ?? 0}
</IconButton>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<ReactionPicker onSelect={addReaction} />
</PopoverBody>
</PopoverContent>
</Popover>
);
}

View File

@ -9,32 +9,31 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { RepostIcon } from "../../icons";
import { buildRepost } from "../../../helpers/nostr/event";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import { nostrPostAction } from "../../../classes/nostr-post-action";
import { buildRepost } from "../../../helpers/nostr/events";
import clientRelaysService from "../../../services/client-relays";
import signingService from "../../../services/signing";
import QuoteNote from "../quote-note";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { useSigningContext } from "../../../providers/signing-provider";
export function RepostButton({ event }: { event: NostrEvent }) {
const { isOpen, onClose, onOpen } = useDisclosure();
const account = useCurrentAccount();
const [loading, setLoading] = useState(false);
const toast = useToast();
const { requestSignature } = useSigningContext();
const handleClick = async () => {
try {
if (!account) throw new Error("not logged in");
setLoading(true);
const draftRepost = buildRepost(event);
const repost = await signingService.requestSignature(draftRepost, account);
await nostrPostAction(clientRelaysService.getWriteUrls(), repost);
const signed = await requestSignature(draftRepost);
const pub = new NostrPublishAction("Repost", clientRelaysService.getWriteUrls(), signed);
await pub.onComplete;
onClose();
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
@ -49,7 +48,6 @@ export function RepostButton({ event }: { event: NostrEvent }) {
onClick={onOpen}
aria-label="Repost Note"
title="Repost Note"
isDisabled={account?.readonly ?? true}
isLoading={loading}
/>
{isOpen && (

View File

@ -1,40 +0,0 @@
import dayjs from "dayjs";
import { Button, Card, CardBody, CardHeader, Spacer, useDisclosure } from "@chakra-ui/react";
import { NoteContents } from "./note-contents";
import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
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 { NoteLink } from "../note-link";
import { ArrowDownSIcon, ArrowUpSIcon } from "../icons";
export default function EmbeddedNote({ note }: { note: NostrEvent }) {
const { showSignatureVerification } = useSubject(appSettings);
const expand = useDisclosure();
return (
<TrustProvider event={note}>
<Card variant="outline">
<CardHeader padding="2" display="flex" gap="2" alignItems="center" flexWrap="wrap">
<UserAvatarLink pubkey={note.pubkey} size="sm" />
<UserLink pubkey={note.pubkey} fontWeight="bold" isTruncated fontSize="lg" />
<UserDnsIdentityIcon pubkey={note.pubkey} onlyIcon />
<Button size="sm" onClick={expand.onToggle} leftIcon={expand.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}>
Expand
</Button>
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={note} />}
<NoteLink noteId={note.id} color="current" whiteSpace="nowrap">
{dayjs.unix(note.created_at).fromNow()}
</NoteLink>
</CardHeader>
<CardBody p="0">{expand.isOpen && <NoteContents px="2" event={note} />}</CardBody>
</Card>
</TrustProvider>
);
}

View File

@ -1,5 +1,5 @@
import { useDisclosure } from "@chakra-ui/react";
import React, { PropsWithChildren, useContext, useMemo } from "react";
import React, { PropsWithChildren, useContext } from "react";
type ContextType = { expanded: boolean; onExpand: () => void; onCollapse: () => void; onToggle: () => void };

View File

@ -11,6 +11,8 @@ import {
Flex,
IconButton,
Link,
useBreakpointValue,
useDisclosure,
} from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
@ -19,34 +21,44 @@ import { NoteMenu } from "./note-menu";
import { EventRelays } from "./note-relays";
import { UserLink } from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import ReactionButton from "./buttons/reaction-button";
import NoteZapButton from "./note-zap-button";
import { ExpandProvider } from "./expanded";
import useSubject from "../../hooks/use-subject";
import appSettings from "../../services/settings/app-settings";
import EventVerificationIcon from "../event-verification-icon";
import { ReplyButton } from "./buttons/reply-button";
import { RepostButton } from "./buttons/repost-button";
import { QuoteRepostButton } from "./buttons/quote-repost-button";
import { ExternalLinkIcon } from "../icons";
import { RepostButton } from "./components/repost-button";
import { QuoteRepostButton } from "./components/quote-repost-button";
import { ExternalLinkIcon, ReplyIcon } from "../icons";
import NoteContentWithWarning from "./note-content-with-warning";
import { TrustProvider } from "../../providers/trust";
import { NoteLink } from "../note-link";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import BookmarkButton from "./components/bookmark-button";
import { useCurrentAccount } from "../../hooks/use-current-account";
import NoteReactions from "./components/note-reactions";
import ReplyForm from "../../views/note/components/reply-form";
import { getReferences } from "../../helpers/nostr/events";
export type NoteProps = {
event: NostrEvent;
variant?: CardProps["variant"];
showReplyButton?: boolean;
};
export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
export const Note = React.memo(({ event, variant = "outline", showReplyButton }: NoteProps) => {
const account = useCurrentAccount();
const { showReactions, showSignatureVerification } = useSubject(appSettings);
const replyForm = useDisclosure();
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
// find mostr external link
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr"), [event]);
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
const reactionButtons = showReactions && <NoteReactions event={event} flexWrap="wrap" variant="ghost" size="xs" />;
return (
<TrustProvider event={event}>
@ -67,31 +79,46 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
<CardBody p="0">
<NoteContentWithWarning event={event} />
</CardBody>
<CardFooter padding="2" display="flex" gap="2">
<ButtonGroup size="sm" variant="link">
<ReplyButton event={event} />
<RepostButton event={event} />
<QuoteRepostButton event={event} />
<NoteZapButton note={event} size="sm" />
{showReactions && <ReactionButton note={event} size="sm" />}
</ButtonGroup>
<Box flexGrow={1} />
{externalLink && (
<IconButton
as={Link}
icon={<ExternalLinkIcon />}
aria-label="Open External"
href={externalLink[1]}
size="sm"
variant="link"
target="_blank"
/>
)}
<EventRelays event={event} />
<NoteMenu event={event} size="sm" variant="link" aria-label="More Options" />
<CardFooter padding="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
{showReactionsOnNewLine && reactionButtons}
<Flex gap="2" w="full" alignItems="center">
<ButtonGroup size="xs" variant="ghost" isDisabled={account?.readonly ?? true}>
{showReplyButton && (
<IconButton icon={<ReplyIcon />} aria-label="Reply" title="Reply" onClick={replyForm.onOpen} />
)}
<RepostButton event={event} />
<QuoteRepostButton event={event} />
<NoteZapButton event={event} />
</ButtonGroup>
{!showReactionsOnNewLine && reactionButtons}
<Box flexGrow={1} />
{externalLink && (
<IconButton
as={Link}
icon={<ExternalLinkIcon />}
aria-label="Open External"
href={externalLink}
size="sm"
variant="ghost"
target="_blank"
/>
)}
<EventRelays event={event} />
<BookmarkButton event={event} aria-label="Bookmark note" size="xs" variant="ghost" />
<NoteMenu event={event} size="xs" variant="ghost" aria-label="More Options" />
</Flex>
</CardFooter>
</Card>
</ExpandProvider>
{replyForm.isOpen && (
<ReplyForm
item={{ event, replies: [], refs: getReferences(event) }}
onCancel={replyForm.onClose}
onSubmitted={replyForm.onClose}
/>
)}
</TrustProvider>
);
});
export default Note;

View File

@ -17,13 +17,18 @@ import {
renderVideoUrl,
embedEmoji,
renderOpenGraphUrl,
embedImageGallery,
renderGenericUrl,
} from "../embed-types";
import { ImageGalleryProvider } from "../image-gallery";
import { LightboxProvider } from "../lightbox-provider";
import { renderRedditUrl } from "../embed-types/reddit";
function buildContents(event: NostrEvent | DraftNostrEvent) {
function buildContents(event: NostrEvent | DraftNostrEvent, simpleLinks = false) {
let content: EmbedableContent = [event.content.trim()];
// image gallery
content = embedImageGallery(content, event as NostrEvent);
// common
content = embedUrls(content, [
renderYoutubeUrl,
@ -35,7 +40,7 @@ function buildContents(event: NostrEvent | DraftNostrEvent) {
renderTidalUrl,
renderImageUrl,
renderVideoUrl,
renderOpenGraphUrl,
simpleLinks ? renderGenericUrl : renderOpenGraphUrl,
]);
// bitcoin
@ -52,16 +57,19 @@ function buildContents(event: NostrEvent | DraftNostrEvent) {
export type NoteContentsProps = {
event: NostrEvent | DraftNostrEvent;
noOpenGraphLinks?: boolean;
};
export const NoteContents = React.memo(({ event, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
const content = buildContents(event);
export const NoteContents = React.memo(
({ event, noOpenGraphLinks, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
const content = buildContents(event, noOpenGraphLinks);
return (
<ImageGalleryProvider>
<Box whiteSpace="pre-wrap" {...props}>
{content}
</Box>
</ImageGalleryProvider>
);
});
return (
<LightboxProvider>
<Box whiteSpace="pre-wrap" {...props}>
{content}
</Box>
</LightboxProvider>
);
},
);

View File

@ -1,20 +1,9 @@
import {
Button,
Input,
MenuItem,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useCallback } from "react";
import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { nip19 } from "nostr-tools";
import { Bech32Prefix, getSharableNoteId, normalizeToBech32 } from "../../helpers/nip19";
import { getSharableEventAddress } from "../../helpers/nip19";
import { NostrEvent } from "../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
@ -22,68 +11,48 @@ import { ClipboardIcon, CodeIcon, ExternalLinkIcon, LikeIcon, RelayIcon, RepostI
import NoteReactionsModal from "./note-zaps-modal";
import NoteDebugModal from "../debug-modals/note-debug-modal";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useCallback, useState } from "react";
import QuoteNote from "./quote-note";
import { buildDeleteEvent } from "../../helpers/nostr/event";
import signingService from "../../services/signing";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { buildAppSelectUrl } from "../../helpers/nostr/apps";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import clientRelaysService from "../../services/client-relays";
import { handleEventFromRelay } from "../../services/event-relays";
import relayPoolService from "../../services/relay-pool";
import NostrPublishAction from "../../classes/nostr-publish-action";
export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => {
const account = useCurrentAccount();
const toast = useToast();
const infoModal = useDisclosure();
const reactionsModal = useDisclosure();
const deleteModal = useDisclosure();
const [reason, setReason] = useState("");
const [deleting, setDeleting] = useState(false);
const { deleteEvent } = useDeleteEventContext();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const noteId = normalizeToBech32(event.id, Bech32Prefix.Note);
const deleteNote = useCallback(async () => {
try {
if (!account) throw new Error("not logged in");
setDeleting(true);
const deleteEvent = buildDeleteEvent([event.id], reason);
const signed = await signingService.requestSignature(deleteEvent, account);
const results = nostrPostAction(clientRelaysService.getWriteUrls(), signed);
await results.onComplete;
deleteModal.onClose();
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
} finally {
setDeleting(false);
}
}, [event]);
const noteId = nip19.noteEncode(event.id);
const broadcast = useCallback(() => {
const missingRelays = clientRelaysService.getWriteUrls();
const { results, onComplete } = nostrPostAction(missingRelays, event, 5000);
const pub = new NostrPublishAction("Broadcast", missingRelays, event, 5000);
results.subscribe((result) => {
pub.onResult.subscribe((result) => {
if (result.status) {
handleEventFromRelay(relayPoolService.requestRelay(result.url, false), event);
handleEventFromRelay(result.relay, event);
}
});
}, []);
const address = getSharableEventAddress(event);
return (
<>
<MenuIconButton {...props}>
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
Zaps/Reactions
</MenuItem>
<MenuItem
onClick={() => window.open(`https://nostrapp.link/#${getSharableNoteId(event.id)}?select=true`, "_blank")}
icon={<ExternalLinkIcon />}
>
View in app...
</MenuItem>
<MenuItem onClick={() => copyToClipboard("nostr:" + getSharableNoteId(event.id))} icon={<RepostIcon />}>
{address && (
<MenuItem onClick={() => window.open(buildAppSelectUrl(address), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
)}
<MenuItem onClick={() => copyToClipboard("nostr:" + address)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
{noteId && (
@ -92,7 +61,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
</MenuItem>
)}
{account?.pubkey === event.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={deleteModal.onOpen}>
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(event)}>
Delete Note
</MenuItem>
)}
@ -111,37 +80,6 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
{reactionsModal.isOpen && (
<NoteReactionsModal noteId={event.id} isOpen={reactionsModal.isOpen} onClose={reactionsModal.onClose} />
)}
{deleteModal.isOpen && (
<Modal isOpen={deleteModal.isOpen} onClose={deleteModal.onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" py="2">
Delete Note?
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<QuoteNote noteId={event.id} />
<Input
name="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (optional)"
mt="2"
/>
</ModalBody>
<ModalFooter px="4" py="4">
<Button variant="ghost" size="sm" mr={2} onClick={deleteModal.onClose}>
Cancel
</Button>
<Button colorScheme="red" variant="solid" onClick={deleteNote} size="sm" isLoading={deleting}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</>
);
};

View File

@ -1,18 +1,21 @@
import { memo } from "react";
import { useBreakpointValue } from "@chakra-ui/react";
import { getEventRelays } from "../../services/event-relays";
import { NostrEvent } from "../../types/nostr-event";
import useSubject from "../../hooks/use-subject";
import { RelayIconStack } from "../relay-icon-stack";
import { getEventUID } from "../../helpers/nostr/event";
import { useBreakpointValue } from "@chakra-ui/react";
import { RelayIconStack, RelayIconStackProps } from "../relay-icon-stack";
import { getEventUID } from "../../helpers/nostr/events";
export type NoteRelaysProps = {
event: NostrEvent;
};
export const EventRelays = memo(({ event }: NoteRelaysProps) => {
const maxRelays = useBreakpointValue({ base: 3, md: undefined });
const eventRelays = useSubject(getEventRelays(getEventUID(event)));
export const EventRelays = memo(
({ event, ...props }: NoteRelaysProps & Omit<RelayIconStackProps, "relays" | "maxRelays">) => {
const maxRelays = useBreakpointValue({ base: 3, md: undefined });
const eventRelays = useSubject(getEventRelays(getEventUID(event)));
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={maxRelays} />;
});
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={maxRelays} {...props} />;
},
);

View File

@ -1,4 +1,5 @@
import { Button, ButtonProps, IconButton, useDisclosure } from "@chakra-ui/react";
import { readablizeSats } from "../../helpers/bolt11";
import { totalZaps } from "../../helpers/zaps";
import { useCurrentAccount } from "../../hooks/use-current-account";
@ -10,17 +11,19 @@ import { LightningIcon } from "../icons";
import ZapModal from "../zap-modal";
import { useInvoiceModalContext } from "../../providers/invoice-modal";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
import { getEventUID } from "../../helpers/nostr/events";
export default function NoteZapButton({
note,
allowComment,
showEventPreview,
...props
}: { note: NostrEvent; allowComment?: boolean; showEventPreview?: boolean } & Omit<ButtonProps, "children">) {
export type NoteZapButtonProps = Omit<ButtonProps, "children"> & {
event: NostrEvent;
allowComment?: boolean;
showEventPreview?: boolean;
};
export default function NoteZapButton({ event, allowComment, showEventPreview, ...props }: NoteZapButtonProps) {
const account = useCurrentAccount();
const { metadata } = useUserLNURLMetadata(note.pubkey);
const { metadata } = useUserLNURLMetadata(event.pubkey);
const { requestPay } = useInvoiceModalContext();
const zaps = useEventZaps(note.id);
const zaps = useEventZaps(event.id);
const { isOpen, onOpen, onClose } = useDisclosure();
const hasZapped = !!account && zaps.some((zap) => zap.request.pubkey === account.pubkey);
@ -28,7 +31,7 @@ export default function NoteZapButton({
const handleInvoice = async (invoice: string) => {
onClose();
await requestPay(invoice);
eventZapsService.requestZaps(note.id, clientRelaysService.getReadUrls(), true);
eventZapsService.requestZaps(getEventUID(event), clientRelaysService.getReadUrls(), true);
};
const total = totalZaps(zaps);
@ -62,9 +65,9 @@ export default function NoteZapButton({
<ZapModal
isOpen={isOpen}
onClose={onClose}
event={note}
event={event}
onInvoice={handleInvoice}
pubkey={note.pubkey}
pubkey={event.pubkey}
allowComment={allowComment}
showEventPreview={showEventPreview}
/>

View File

@ -78,6 +78,8 @@ export default function NoteReactionsModal({
const reactions = useEventReactions(noteId, [], true) ?? [];
const [selected, setSelected] = useState("zaps");
console.log(reactions);
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />

View File

@ -1,13 +1,14 @@
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSingleEvent from "../../hooks/use-single-event";
import EmbeddedNote from "./embedded-note";
import EmbeddedNote from "../embed-event/event-types/embedded-note";
import { NoteLink } from "../note-link";
/** @deprecated */
const QuoteNote = ({ noteId, relays }: { noteId: string; relays?: string[] }) => {
const readRelays = useReadRelayUrls(relays);
const { event, loading } = useSingleEvent(noteId, readRelays);
return event ? <EmbeddedNote note={event} /> : <NoteLink noteId={noteId} />;
return event ? <EmbeddedNote event={event} /> : <NoteLink noteId={noteId} />;
};
export default QuoteNote;

View File

@ -1,4 +1,17 @@
import { Box, CardProps, Heading, Image, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import {
Box,
Card,
CardBody,
CardProps,
Flex,
Heading,
Image,
Link,
LinkBox,
LinkOverlay,
Text,
useBreakpointValue,
} from "@chakra-ui/react";
import useOpenGraphData from "../hooks/use-open-graph-data";
export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<CardProps, "children">) {
@ -10,23 +23,40 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<Car
</Link>
);
const isVertical = useBreakpointValue({ base: true, md: false });
if (!data) return link;
return (
<LinkBox borderRadius="lg" borderWidth={1} overflow="hidden" {...props}>
{data.ogImage?.length === 1 && (
<Image key={data.ogImage[0].url} src={new URL(data.ogImage[0].url, url).toString()} mx="auto" maxH="3in" />
)}
<Box m="2" mt="4">
<Heading size="sm" my="2">
<LinkOverlay href={url.toString()} isExternal>
{data.ogTitle?.trim() ?? data.dcTitle?.trim()}
</LinkOverlay>
</Heading>
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>
{link}
</Box>
</LinkBox>
<Card {...props}>
<LinkBox
as={CardBody}
display="flex"
gap="2"
p="0"
overflow="hidden"
flexDirection={{ base: "column", md: "row" }}
>
{data.ogImage?.length === 1 && (
<Image
key={data.ogImage[0].url}
src={new URL(data.ogImage[0].url, url).toString()}
borderRadius="md"
maxH="2in"
maxW={isVertical ? "none" : "30%"}
mx={isVertical ? "auto" : 0}
/>
)}
<Box p="2">
<Heading size="sm">
<LinkOverlay href={url.toString()} isExternal>
{data.ogTitle?.trim() ?? data.dcTitle?.trim()}
</LinkOverlay>
</Heading>
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>
{link}
</Box>
</LinkBox>
</Card>
);
}

View File

@ -1,74 +0,0 @@
import { PropsWithChildren, createContext, useContext, useMemo, useState } from "react";
import { nip19 } from "nostr-tools";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { isPTag } from "../../types/nostr-event";
import replaceableEventLoaderService from "../../services/replaceable-event-requester";
import useSubject from "../../hooks/use-subject";
import clientFollowingService from "../../services/client-following";
export type ListIdentifier = "following" | "global" | string;
export function useParsedNaddr(naddr?: string) {
if (!naddr) return;
try {
const parsed = nip19.decode(naddr);
if (parsed.type === "naddr") {
return parsed.data;
}
} catch (e) {}
}
export function useList(naddr?: string) {
const parsed = useMemo(() => useParsedNaddr(naddr), [naddr]);
const readRelays = useReadRelayUrls(parsed?.relays ?? []);
const sub = useMemo(() => {
if (!parsed) return;
return replaceableEventLoaderService.requestEvent(readRelays, parsed.kind, parsed.pubkey, parsed.identifier);
}, [parsed]);
return useSubject(sub);
}
export function useListPeople(list: ListIdentifier) {
const contacts = useSubject(clientFollowingService.following);
const listEvent = useList(list);
if (list === "following") return contacts.map((t) => t[1]);
if (listEvent) {
return listEvent.tags.filter(isPTag).map((t) => t[1]);
}
return [];
}
export type PeopleListContextType = {
list: string;
people: string[];
setList: (list: string) => void;
};
const PeopleListContext = createContext<PeopleListContextType>({ list: "following", setList: () => {}, people: [] });
export function usePeopleListContext() {
return useContext(PeopleListContext);
}
export default function PeopleListProvider({ children }: PropsWithChildren) {
const account = useCurrentAccount();
const [list, setList] = useState(account ? "following" : "global");
const people = useListPeople(list);
const context = useMemo(
() => ({
people,
list,
setList,
}),
[list, setList]
);
return <PeopleListContext.Provider value={context}>{children}</PeopleListContext.Provider>;
}

View File

@ -1,25 +1,76 @@
import { Select, SelectProps, useDisclosure } from "@chakra-ui/react";
import { usePeopleListContext } from "./people-list-provider";
import {
Button,
ButtonProps,
Menu,
MenuButton,
MenuDivider,
MenuItemOption,
MenuList,
MenuOptionGroup,
} from "@chakra-ui/react";
import { usePeopleListContext } from "../../providers/people-list-provider";
import useUserLists from "../../hooks/use-user-lists";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { PEOPLE_LIST_KIND, getListName } from "../../helpers/nostr/lists";
import { getEventCoordinate } from "../../helpers/nostr/events";
import useFavoriteLists from "../../hooks/use-favorite-lists";
export default function PeopleListSelection({
hideGlobalOption = false,
...props
}: {
hideGlobalOption?: boolean;
} & Omit<SelectProps, "value" | "onChange" | "children">) {
const { people, list, setList } = usePeopleListContext();
const { isOpen, onOpen, onClose } = useDisclosure();
} & Omit<ButtonProps, "children">) {
const account = useCurrentAccount();
const lists = useUserLists(account?.pubkey);
const { lists: favoriteLists } = useFavoriteLists();
const { selected, setSelected, listEvent } = usePeopleListContext();
const handleSelect = (value: string | string[]) => {
if (typeof value === "string") {
setSelected(value);
}
};
return (
<Select
value={list}
onChange={(e) => {
setList(e.target.value);
}}
{...props}
>
<option value="following">Following</option>
{!hideGlobalOption && <option value="global">Global</option>}
</Select>
<Menu>
<MenuButton as={Button} {...props}>
{listEvent ? getListName(listEvent) : selected === "global" ? "Global" : "Loading..."}
</MenuButton>
<MenuList zIndex={100}>
<MenuOptionGroup value={selected} onChange={handleSelect} type="radio">
{account && <MenuItemOption value="following">Following</MenuItemOption>}
{!hideGlobalOption && <MenuItemOption value="global">Global</MenuItemOption>}
{lists.length > 0 && <MenuDivider />}
{lists
.filter((l) => l.kind === PEOPLE_LIST_KIND)
.map((list) => (
<MenuItemOption key={getEventCoordinate(list)} value={getEventCoordinate(list)} isTruncated maxW="90vw">
{getListName(list)}
</MenuItemOption>
))}
</MenuOptionGroup>
{favoriteLists.length > 0 && (
<>
<MenuDivider />
<MenuOptionGroup value={selected} onChange={handleSelect} type="radio" title="Favorites">
{favoriteLists
.filter((l) => l.kind === PEOPLE_LIST_KIND)
.map((list) => (
<MenuItemOption
key={getEventCoordinate(list)}
value={getEventCoordinate(list)}
isTruncated
maxW="90vw"
>
{getListName(list)}
</MenuItemOption>
))}
</MenuOptionGroup>
</>
)}
</MenuList>
</Menu>
);
}

View File

@ -0,0 +1,62 @@
import { useEffect, useMemo, useState } from "react";
import { Photo, PhotoAlbum, PhotoAlbumProps } from "react-photo-album";
type Size = { width: number; height: number };
const imageSizeCache = new Map<string, Size>();
function getImageSize(src: string): Promise<{ width: number; height: number }> {
const cached = imageSizeCache.get(src);
if (cached) return Promise.resolve(cached);
return new Promise((res, rej) => {
const image = new Image();
image.src = src;
image.onload = () => {
const size = { width: image.width, height: image.height };
imageSizeCache.set(src, size);
res(size);
};
image.onerror = (err) => rej(err);
});
}
export type PhotoWithoutSize = Omit<Photo, "width" | "height"> & { width?: number; height?: number };
export default function PhotoGallery<T extends PhotoWithoutSize>({
photos,
...props
}: Omit<PhotoAlbumProps<T & Size>, "photos"> & { photos: PhotoWithoutSize[] }) {
const [loadedSizes, setLoadedSizes] = useState<Record<string, Size>>({});
useEffect(() => {
for (const photo of photos) {
getImageSize(photo.src).then(
(size) => {
setLoadedSizes((dir) => ({ ...dir, [photo.src]: size }));
},
() => {},
);
}
}, [photos]);
const loadedPhotos = useMemo(() => {
const loaded: (T & Size)[] = [];
for (const photo of photos) {
if (photo.width && photo.height) {
loaded.push(photo as T & Size);
continue;
}
const loadedImage = loadedSizes[photo.src];
if (loadedImage) {
loaded.push({ ...photo, width: loadedImage.width, height: loadedImage.height } as T & Size);
continue;
}
}
return loaded;
}, [loadedSizes, photos]);
return <PhotoAlbum<T & Size> photos={loadedPhotos} {...props} />;
}

View File

@ -1,3 +1,4 @@
import React, { useMemo, useRef, useState } from "react";
import {
Modal,
ModalOverlay,
@ -5,28 +6,29 @@ import {
ModalBody,
Flex,
Button,
Textarea,
Text,
useDisclosure,
VisuallyHiddenInput,
IconButton,
useToast,
Box,
Heading,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import React, { useRef, useState } from "react";
import { useList } from "react-use";
import { nostrPostAction, PostResult } from "../../classes/nostr-post-action";
import { normalizeToHex } from "../../helpers/nip19";
import { getReferences } from "../../helpers/nostr/event";
import { matchHashtag, mentionNpubOrNote } from "../../helpers/regexp";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { getReferences } from "../../helpers/nostr/events";
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useSigningContext } from "../../providers/signing-provider";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { DraftNostrEvent } from "../../types/nostr-event";
import { ImageIcon } from "../icons";
import { NoteLink } from "../note-link";
import { NoteContents } from "../note/note-contents";
import { PostResults } from "./post-results";
import { PublishDetails } from "../publish-details";
import { TrustProvider } from "../../providers/trust";
import { createEmojiTags, ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../helpers/nostr/post";
import { UserAvatarStack } from "../compact-user-stack";
import MagicTextArea from "../magic-textarea";
import { useContextEmojis } from "../../providers/emoji-provider";
function emptyDraft(): DraftNostrEvent {
return {
@ -37,40 +39,6 @@ function emptyDraft(): DraftNostrEvent {
};
}
function finalizeNote(draft: DraftNostrEvent) {
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags), created_at: dayjs().unix() };
// replace all occurrences of @npub and @note
while (true) {
const match = mentionNpubOrNote.exec(updatedDraft.content);
if (!match || match.index === undefined) break;
const hex = normalizeToHex(match[1]);
if (!hex) continue;
const mentionType = match[2] === "npub1" ? "p" : "e";
// TODO: find the best relay for this user or note
const existingMention = updatedDraft.tags.find((t) => t[0] === mentionType && t[1] === hex);
const index = existingMention
? updatedDraft.tags.indexOf(existingMention)
: updatedDraft.tags.push([mentionType, hex, "", "mention"]) - 1;
// replace the npub1 or note1 with a mention tag #[0]
const c = updatedDraft.content;
updatedDraft.content = c.slice(0, match.index) + `#[${index}]` + c.slice(match.index + match[0].length);
}
// replace all uses of #hashtag
const matches = updatedDraft.content.matchAll(new RegExp(matchHashtag, "giu"));
for (const [_, space, hashtag] of matches) {
const lower = hashtag.toLocaleLowerCase();
if (!updatedDraft.tags.find((t) => t[0] === "t" && t[1] === lower)) {
updatedDraft.tags.push(["t", lower]);
}
}
return updatedDraft;
}
type PostModalProps = {
isOpen: boolean;
onClose: () => void;
@ -81,13 +49,12 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
const toast = useToast();
const { requestSignature } = useSigningContext();
const writeRelays = useWriteRelayUrls();
const [waiting, setWaiting] = useState(false);
const [signedEvent, setSignedEvent] = useState<NostrEvent | null>(null);
const [results, resultsActions] = useList<PostResult>();
const { isOpen: showPreview, onToggle: togglePreview } = useDisclosure();
const [signing, setSigning] = useState(false);
const [publishAction, setPublishAction] = useState<NostrPublishAction>();
const [draft, setDraft] = useState<DraftNostrEvent>(() => Object.assign(emptyDraft(), initialDraft));
const imageUploadRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const emojis = useContextEmojis();
const uploadImage = async (imageFile: File) => {
try {
@ -96,7 +63,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
const payload = new FormData();
payload.append("fileToUpload", imageFile);
const response = await fetch("https://nostr.build/upload.php", { body: payload, method: "POST" }).then((res) =>
res.text()
res.text(),
);
const imageUrl = response.match(/https:\/\/nostr\.build\/i\/[\w.]+/)?.[0];
if (imageUrl) {
@ -112,18 +79,25 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
setDraft((d) => ({ ...d, content: event.target.value }));
};
const handleSubmit = async () => {
setWaiting(true);
const updatedDraft = finalizeNote(draft);
const event = await requestSignature(updatedDraft);
setWaiting(false);
if (!event) return;
setSignedEvent(event);
const finalDraft = useMemo(() => {
let updatedDraft = finalizeNote(draft);
const contentMentions = getContentMentions(draft.content);
updatedDraft = createEmojiTags(updatedDraft, emojis);
updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions);
return updatedDraft;
}, [draft, emojis]);
const { results } = nostrPostAction(writeRelays, event);
results.subscribe((result) => {
resultsActions.push(result);
});
const handleSubmit = async () => {
try {
setSigning(true);
const signed = await requestSignature(finalDraft);
setSigning(false);
const pub = new NostrPublishAction("Post", writeRelays, signed);
setPublishAction(pub);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const refs = getReferences(draft);
@ -131,8 +105,15 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
const canSubmit = draft.content.length > 0;
const renderContent = () => {
if (signedEvent) {
return <PostResults event={signedEvent} results={results} onClose={onClose} />;
if (publishAction) {
return (
<>
<PublishDetails pub={publishAction} />
<Button onClick={onClose} mt="2" ml="auto">
Close
</Button>
</>
);
}
return (
<>
@ -141,22 +122,26 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
Replying to: <NoteLink noteId={refs.replyId} />
</Text>
)}
{showPreview ? (
<TrustProvider trust>
<NoteContents event={finalizeNote(draft)} />
</TrustProvider>
) : (
<Textarea
autoFocus
mb="2"
value={draft.content}
onChange={handleContentChange}
rows={5}
onPaste={(e) => {
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
if (imageFile) uploadImage(imageFile);
}}
/>
<MagicTextArea
autoFocus
mb="2"
value={draft.content}
onChange={handleContentChange}
rows={5}
onPaste={(e) => {
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
if (imageFile) uploadImage(imageFile);
}}
/>
{draft.content.length > 0 && (
<Box>
<Heading size="sm">Preview:</Heading>
<Box borderWidth={1} borderRadius="md" p="2">
<TrustProvider trust>
<NoteContents event={finalDraft} />
</TrustProvider>
</Box>
</Box>
)}
<Flex gap="2" alignItems="center" justifyContent="flex-end">
<Flex mr="auto" gap="2">
@ -177,9 +162,9 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
isLoading={uploading}
/>
</Flex>
{draft.content.length > 0 && <Button onClick={togglePreview}>Preview</Button>}
<UserAvatarStack label="Mentions" pubkeys={getContentMentions(draft.content)} />
<Button onClick={onClose}>Cancel</Button>
<Button colorScheme="blue" type="submit" isLoading={waiting} onClick={handleSubmit} isDisabled={!canSubmit}>
<Button colorScheme="blue" type="submit" isLoading={signing} onClick={handleSubmit} isDisabled={!canSubmit}>
Post
</Button>
</Flex>
@ -188,10 +173,12 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" closeOnOverlayClick={false}>
<Modal isOpen={isOpen} onClose={onClose} size="4xl" closeOnOverlayClick={!!publishAction}>
<ModalOverlay />
<ModalContent>
<ModalBody padding={["2", "2", "4"]}>{renderContent()}</ModalBody>
<ModalBody display="flex" flexDirection="column" padding={["2", "2", "4"]} gap="2">
{renderContent()}
</ModalBody>
</ModalContent>
</Modal>
);

View File

@ -1,40 +0,0 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Button, Flex, Heading } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { PostResult } from "../../classes/nostr-post-action";
import { NostrEvent } from "../../types/nostr-event";
export type PostResultsProps = {
event: NostrEvent;
results: PostResult[];
onClose: () => void;
};
export const PostResults = ({ event, results, onClose }: PostResultsProps) => {
const navigate = useNavigate();
const viewPost = () => {
onClose();
navigate(`/n/${event.id}`);
};
return (
<Flex direction="column" gap="2">
<Heading size="md">Posted to relays:</Heading>
{results.map((result) => (
<Alert key={result.url} status={result.status ? "success" : "warning"}>
<AlertIcon />
<Box>
<AlertTitle>{result.url}</AlertTitle>
{result.message && <AlertDescription>{result.message}</AlertDescription>}
</Box>
</Alert>
))}
<Flex gap="4" ml="auto">
<Button onClick={viewPost} variant="link">
View Post
</Button>
<Button onClick={onClose}>Done</Button>
</Flex>
</Flex>
);
};

View File

@ -0,0 +1,33 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Flex, FlexProps, Link, Progress } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import NostrPublishAction from "../classes/nostr-publish-action";
import useSubject from "../hooks/use-subject";
import { RelayPaidTag } from "../views/relays/components/relay-card";
export type PostResultsProps = {
pub: NostrPublishAction;
};
export const PublishDetails = ({ pub }: PostResultsProps & Omit<FlexProps, "children">) => {
const results = useSubject(pub.results);
return (
<Flex direction="column" gap="2">
<Progress value={(results.length / pub.relays.length) * 100} size="lg" hasStripe />
{results.map((result) => (
<Alert key={result.relay.url} status={result.status ? "success" : "warning"}>
<AlertIcon />
<Box>
<AlertTitle>
<Link as={RouterLink} to={`/r/${encodeURIComponent(result.relay.url)}`}>
{result.relay.url}
</Link>
<RelayPaidTag url={result.relay.url} />
</AlertTitle>
{result.message && <AlertDescription>{result.message}</AlertDescription>}
</Box>
</Alert>
))}
</Flex>
);
};

View File

@ -0,0 +1,93 @@
import {
Flex,
FlexProps,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spinner,
Tag,
TagLabel,
TagProps,
Text,
useDisclosure,
} from "@chakra-ui/react";
import NostrPublishAction from "../classes/nostr-publish-action";
import useSubject from "../hooks/use-subject";
import { CheckIcon, ErrorIcon } from "./icons";
import { publishLog } from "../services/publish-log";
import { PublishDetails } from "./publish-details";
export function PublishActionStatusTag({ pub, ...props }: { pub: NostrPublishAction } & Omit<TagProps, "children">) {
const results = useSubject(pub.results);
const successful = results.filter((result) => result.status);
const failedWithMessage = results.filter((result) => !result.status && result.message);
let statusIcon = <Spinner size="xs" />;
let statusColor: TagProps["colorScheme"] = "blue";
if (results.length !== pub.relays.length) {
statusColor = "blue";
statusIcon = <Spinner size="xs" />;
} else if (successful.length === 0) {
statusColor = "red";
statusIcon = <ErrorIcon />;
} else if (failedWithMessage.length > 0) {
statusColor = "orange";
statusIcon = <CheckIcon />;
} else {
statusColor = "green";
statusIcon = <CheckIcon />;
}
return (
<Tag colorScheme={statusColor} {...props}>
<TagLabel mr="1">
{successful.length}/{pub.relays.length}
</TagLabel>
{statusIcon}
</Tag>
);
}
function PublishAction({ pub }: { pub: NostrPublishAction }) {
const details = useDisclosure();
return (
<>
<Flex gap="2" alignItems="center" cursor="pointer" onClick={details.onOpen}>
<Text>{pub.label}</Text>
<PublishActionStatusTag ml="auto" pub={pub} />
</Flex>
{details.isOpen && (
<Modal isOpen onClose={details.onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader pt="4" px="4" pb="0">
{pub.label}
</ModalHeader>
<ModalCloseButton />
<ModalBody p="2">
<PublishDetails pub={pub} />
</ModalBody>
</ModalContent>
</Modal>
)}
</>
);
}
export default function PublishLog({ ...props }: Omit<FlexProps, "children">) {
const log = Array.from(useSubject(publishLog)).reverse();
return (
<Flex overflow="hidden" direction="column" gap="1" {...props}>
{log.length > 0 && <Text>Activity log:</Text>}
{log.map((pub) => (
<PublishAction key={pub.id} pub={pub} />
))}
</Flex>
);
}

View File

@ -0,0 +1,51 @@
import {
AvatarGroup,
Box,
Divider,
Heading,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ModalProps,
} from "@chakra-ui/react";
import { useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import { groupReactions } from "../helpers/nostr/reactions";
import { ReactionIcon } from "./event-reactions";
import { UserAvatarLink } from "./user-avatar-link";
export type ReactionDetailsModalProps = Omit<ModalProps, "children"> & {
reactions: NostrEvent[];
};
export default function ReactionDetailsModal({ reactions, onClose, ...props }: ReactionDetailsModalProps) {
const groups = useMemo(() => groupReactions(reactions), [reactions]);
return (
<Modal onClose={onClose} {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" pb="0">
Reactions
</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" gap="2" px="4" pt="0" flexWrap="wrap">
{groups.map((group) => (
<Box key={group.emoji}>
<ReactionIcon emoji={group.emoji} url={group.url} />
<AvatarGroup size="sm" flexWrap="wrap">
{group.pubkeys.map((pubkey) => (
<UserAvatarLink key={pubkey} pubkey={pubkey} />
))}
</AvatarGroup>
</Box>
))}
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,81 @@
import { Button, Divider, Flex, IconButton, Image, Input, Text } from "@chakra-ui/react";
import { DislikeIcon, LikeIcon } from "./icons";
import { useCurrentAccount } from "../hooks/use-current-account";
import useReplaceableEvent from "../hooks/use-replaceable-event";
import { getEmojisFromPack, getPackCordsFromFavorites, getPackName } from "../helpers/nostr/emoji-packs";
import useFavoriteEmojiPacks from "../hooks/use-favorite-emoji-packs";
export type ReactionPickerProps = {
onSelect: (emoji: string, url?: string) => void;
};
function EmojiPack({ cord, onSelect }: { cord: string; onSelect: ReactionPickerProps["onSelect"] }) {
const pack = useReplaceableEvent(cord);
if (!pack) return null;
return (
<>
<Flex gap="2" alignItems="center">
<Text whiteSpace="pre">{getPackName(pack)}</Text>
<Divider />
</Flex>
<Flex wrap="wrap" gap="2">
{getEmojisFromPack(pack).map((emoji) => (
<IconButton
key={emoji.name}
icon={<Image src={emoji.url} height="1.2rem" />}
aria-label={emoji.name}
title={emoji.name}
variant="outline"
size="sm"
onClick={() => onSelect(emoji.name, emoji.url)}
/>
))}
</Flex>
</>
);
}
export default function ReactionPicker({ onSelect }: ReactionPickerProps) {
const account = useCurrentAccount();
const favoritePacks = useFavoriteEmojiPacks(account?.pubkey);
return (
<Flex direction="column" gap="2">
<Flex wrap="wrap" gap="2">
<IconButton icon={<LikeIcon />} aria-label="Like" variant="outline" size="sm" onClick={() => onSelect("+")} />
<IconButton
icon={<DislikeIcon />}
aria-label="Dislike"
variant="outline"
size="sm"
onClick={() => onSelect("-")}
/>
<IconButton
icon={<span>🤙</span>}
aria-label="Shaka"
variant="outline"
size="sm"
onClick={() => onSelect("🤙")}
/>
<IconButton
icon={<span>🫂</span>}
aria-label="Hug"
variant="outline"
size="sm"
onClick={() => onSelect("🫂")}
/>
<Flex>
<Input placeholder="🔥" display="inline" size="sm" minW="2rem" w="5rem" />
<Button variant="solid" colorScheme="brand" size="sm">
Add
</Button>
</Flex>
</Flex>
{favoritePacks &&
getPackCordsFromFavorites(favoritePacks).map((cord) => (
<EmojiPack key={cord} cord={cord} onSelect={onSelect} />
))}
</Flex>
);
}

View File

@ -17,7 +17,9 @@ import { Link as RouterLink } from "react-router-dom";
import { RelayFavicon } from "./relay-favicon";
import relayScoreboardService from "../services/relay-scoreboard";
export function RelayIconStack({ relays, maxRelays, ...props }: { relays: string[]; maxRelays?: number } & FlexProps) {
export type RelayIconStackProps = { relays: string[]; maxRelays?: number } & Omit<FlexProps, "children">;
export function RelayIconStack({ relays, maxRelays, ...props }: RelayIconStackProps) {
const { isOpen, onOpen, onClose } = useDisclosure();
const topRelays = relayScoreboardService.getRankedRelays(relays);

View File

@ -1,15 +1,20 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react";
import { RelayIcon } from "../icons";
import { useRelaySelectionContext } from "../../providers/relay-selection-provider";
import RelaySelectionModal from "./relay-selection-modal";
export default function RelaySelectionButton({ ...props }: ButtonProps) {
const { openModal, relays } = useRelaySelectionContext();
const relaysModal = useDisclosure();
const { setSelected, relays } = useRelaySelectionContext();
return (
<>
<Button leftIcon={<RelayIcon />} onClick={openModal} {...props}>
<Button leftIcon={<RelayIcon />} onClick={relaysModal.onOpen} {...props}>
{relays.length} {relays.length === 1 ? "Relay" : "Relays"}
</Button>
{relaysModal.isOpen && (
<RelaySelectionModal selected={relays} onSubmit={setSelected} onClose={relaysModal.onClose} />
)}
</>
);
}

View File

@ -28,7 +28,7 @@ function RelayPickerModal({
}: { onSelect: (relay: string) => void } & Omit<ModalProps, "children">) {
const [search, setSearch] = useState("");
const { value: onlineRelays } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>),
);
const relayList = unique(onlineRelays ?? []);
@ -78,7 +78,7 @@ export const RelayUrlInput = forwardRef(
({ onChange, ...props }: Omit<RelayUrlInputProps, "onChange"> & { onChange: (url: string) => void }, ref) => {
const { isOpen, onClose, onOpen } = useDisclosure();
const { value: relaysJson } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>),
);
const relaySuggestions = unique(relaysJson ?? []);
@ -100,5 +100,5 @@ export const RelayUrlInput = forwardRef(
<RelayPickerModal onClose={onClose} isOpen={isOpen} onSelect={(url) => onChange(url)} size="2xl" />
</>
);
}
},
);

View File

@ -1,17 +1,45 @@
import { Flex, IconProps } from "@chakra-ui/react";
import { StarEmptyIcon, StarFullIcon, StarHalfIcon } from "./icons";
import styled from "@emotion/styled";
export default function StarRating({ quality, stars = 5, ...props }: { quality: number; stars?: number } & IconProps) {
const HiddenSlider = styled.input`
position: absolute;
left: -0.5em;
right: -0.5em;
bottom: 0;
top: 0;
padding: 0;
width: -moz-available;
opacity: 0;
cursor: pointer;
`;
export default function StarRating({
quality,
stars = 5,
color = "yellow.300",
onChange,
...props
}: { quality: number; stars?: number; onChange?: (quality: number) => void } & Omit<IconProps, "onChange">) {
const normalized = Math.round(quality * (stars * 2)) / 2;
const renderStar = (i: number) => {
if (normalized >= i + 1) return <StarFullIcon {...props} />;
if (normalized === i + 0.5) return <StarHalfIcon {...props} />;
return <StarEmptyIcon {...props} />;
if (normalized >= i + 1) return <StarFullIcon key={i} color={color} {...props} />;
if (normalized === i + 0.5) return <StarHalfIcon key={i} color={color} {...props} />;
return <StarEmptyIcon key={i} color={color} {...props} />;
};
return (
<Flex gap="1">
<Flex gap="1" position="relative">
{onChange && (
<HiddenSlider
type="range"
min={0}
max={1}
step={1 / 10}
onChange={(e) => onChange(parseFloat(e.target.value))}
/>
)}
{Array(stars)
.fill(0)
.map((_, i) => renderStar(i))}

View File

@ -15,7 +15,7 @@ import { safeRelayUrl } from "../../../helpers/url";
const RenderEvent = React.memo(({ event }: { event: NostrEvent }) => {
switch (event.kind) {
case Kind.Text:
return <Note event={event} />;
return <Note event={event} showReplyButton />;
case Kind.Repost:
return <RepostNote event={event} />;
case STREAM_KIND:

View File

@ -63,7 +63,7 @@ export default function RepostNote({ event }: { event: NostrEvent }) {
</Text>
<NoteMenu event={event} size="sm" variant="link" aria-label="note options" />
</Flex>
{loading ? <SkeletonText /> : note ? <Note event={note} /> : <ErrorFallback error={error} />}
{loading ? <SkeletonText /> : note ? <Note event={note} showReplyButton /> : <ErrorFallback error={error} />}
</Flex>
</TrustProvider>
);

View File

@ -25,13 +25,14 @@ import { UserLink } from "../../user-link";
import StreamStatusBadge from "../../../views/streams/components/status-badge";
import { EventRelays } from "../../note/note-relays";
import { useAsync } from "react-use";
import { getEventUID } from "../../../helpers/nostr/events";
export default function StreamNote({ event, ...props }: CardProps & { event: NostrEvent }) {
const { value: stream, error } = useAsync(async () => parseStreamEvent(event), [event]);
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
useRegisterIntersectionEntity(ref, getEventUID(event));
const naddr = useEventNaddr(event);

View File

@ -1,15 +1,16 @@
import { useCallback } from "react";
import { Flex, SimpleGrid } from "@chakra-ui/react";
import { Flex, FlexProps, SimpleGrid } from "@chakra-ui/react";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import GenericNoteTimeline from "./generic-note-timeline";
import { ImageGalleryProvider } from "../image-gallery";
import { LightboxProvider } from "../lightbox-provider";
import MediaTimeline from "./media-timeline";
import { TimelineLoader } from "../../classes/timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "./timeline-action-and-status";
import { useSearchParams } from "react-router-dom";
import { NostrEvent } from "../../types/nostr-event";
import { matchImageUrls } from "../../helpers/regexp";
import { getMatchLink } from "../../helpers/regexp";
import TimelineHealth from "./timeline-health";
export function useTimelinePageEventFilter() {
const [params, setParams] = useSearchParams();
@ -17,16 +18,20 @@ export function useTimelinePageEventFilter() {
return useCallback(
(event: NostrEvent) => {
if (view === "images" && !event.content.match(matchImageUrls)) return false;
if (view === "images" && !event.content.match(getMatchLink())) return false;
return true;
},
[view]
[view],
);
}
export type TimelineViewType = "timeline" | "images";
export type TimelineViewType = "timeline" | "images" | "health";
export default function TimelinePage({ timeline, header }: { timeline: TimelineLoader; header?: React.ReactNode }) {
export default function TimelinePage({
timeline,
header,
...props
}: { timeline: TimelineLoader; header?: React.ReactNode } & Omit<FlexProps, "children" | "direction" | "gap">) {
const callback = useTimelineCurserIntersectionCallback(timeline);
const [params, setParams] = useSearchParams();
@ -38,20 +43,17 @@ export default function TimelinePage({ timeline, header }: { timeline: TimelineL
return <GenericNoteTimeline timeline={timeline} />;
case "images":
return (
<ImageGalleryProvider>
<SimpleGrid columns={[1, 2, 2, 3, 4, 5]} gap="4">
<MediaTimeline timeline={timeline} />
</SimpleGrid>
</ImageGalleryProvider>
);
return <MediaTimeline timeline={timeline} />;
case "health":
return <TimelineHealth timeline={timeline} />;
default:
return null;
}
};
return (
<IntersectionObserverProvider<string> callback={callback}>
<Flex direction="column" gap="2" pt="4" pb="8">
<Flex direction="column" gap="2" {...props}>
{header}
{renderTimeline()}
<TimelineActionAndStatus timeline={timeline} />

View File

@ -1,57 +1,52 @@
import React, { useMemo, useRef } from "react";
import { useMemo, useRef } from "react";
import { useBreakpointValue } from "@chakra-ui/react";
import { TimelineLoader } from "../../../classes/timeline-loader";
import useSubject from "../../../hooks/use-subject";
import { matchImageUrls } from "../../../helpers/regexp";
import { ImageGalleryLink, ImageGalleryProvider } from "../../image-gallery";
import { Box, IconButton } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { getMatchLink } from "../../../helpers/regexp";
import { LightboxProvider } from "../../lightbox-provider";
import { isImageURL } from "../../../helpers/url";
import { EmbeddedImage, EmbeddedImageProps } from "../../embed-types";
import { TrustProvider } from "../../../providers/trust";
import PhotoGallery, { PhotoWithoutSize } from "../../photo-gallery";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getSharableNoteId } from "../../../helpers/nip19";
import { ExternalLinkIcon } from "../../icons";
import { Photo } from "react-photo-album";
import { NostrEvent } from "../../../types/nostr-event";
import { getEventUID } from "../../../helpers/nostr/events";
const matchAllImages = new RegExp(matchImageUrls, "ig");
function GalleryImage({ event, ...props }: EmbeddedImageProps & { event: NostrEvent }) {
const ref = useRef<HTMLImageElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
type ImagePreview = { eventId: string; src: string; index: number };
return <EmbeddedImage {...props} event={event} ref={ref} />;
}
const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
const navigate = useNavigate();
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, image.eventId);
type PhotoWithEvent = PhotoWithoutSize & { event: NostrEvent };
function ImageGallery({ images }: { images: PhotoWithEvent[] }) {
const rowMultiplier = useBreakpointValue({ base: 2, sm: 3, md: 3, lg: 4, xl: 5 }) ?? 2;
return (
<ImageGalleryLink href={image.src} position="relative" ref={ref}>
<Box aspectRatio={1} backgroundImage={`url(${image.src})`} backgroundSize="cover" backgroundPosition="center" />
<IconButton
icon={<ExternalLinkIcon />}
aria-label="Open note"
position="absolute"
right="2"
top="2"
size="sm"
colorScheme="brand"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/n/${getSharableNoteId(image.eventId)}`);
}}
/>
</ImageGalleryLink>
<PhotoGallery<Photo & { event: NostrEvent }>
layout="masonry"
photos={images}
renderPhoto={({ photo, imageProps }) => <GalleryImage event={photo.event} {...imageProps} />}
columns={rowMultiplier}
/>
);
});
}
export default function MediaTimeline({ timeline }: { timeline: TimelineLoader }) {
const events = useSubject(timeline.timeline);
const images = useMemo(() => {
var images: { eventId: string; src: string; index: number }[] = [];
var images: PhotoWithEvent[] = [];
for (const event of events) {
const urls = event.content.matchAll(matchAllImages);
const urls = event.content.matchAll(getMatchLink());
let i = 0;
for (const url of urls) {
images.push({ eventId: event.id, src: url[0], index: i++ });
for (const match of urls) {
if (isImageURL(match[0])) images.push({ event, src: match[0] });
}
}
@ -59,10 +54,10 @@ export default function MediaTimeline({ timeline }: { timeline: TimelineLoader }
}, [events]);
return (
<ImageGalleryProvider>
{images.map((image) => (
<ImagePreview key={image.eventId + "-" + image.index} image={image} />
))}
</ImageGalleryProvider>
<LightboxProvider>
<TrustProvider trust>
<ImageGallery images={images} />
</TrustProvider>
</LightboxProvider>
);
}

View File

@ -0,0 +1,125 @@
import { useMemo, useRef, useState } from "react";
import {
Box,
Spinner,
Table,
TableContainer,
TableRowProps,
Tbody,
Td,
Text,
Th,
Thead,
Tooltip,
Tr,
useColorMode,
} from "@chakra-ui/react";
import { TimelineLoader } from "../../../classes/timeline-loader";
import useSubject from "../../../hooks/use-subject";
import { getEventRelays, handleEventFromRelay } from "../../../services/event-relays";
import { NostrEvent } from "../../../types/nostr-event";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { RelayFavicon } from "../../relay-favicon";
import { NoteLink } from "../../note-link";
import dayjs from "dayjs";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import { RelayIcon } from "../../icons";
import { getEventUID } from "../../../helpers/nostr/events";
function EventRow({
event,
relays,
...props
}: { event: NostrEvent; relays: string[] } & Omit<TableRowProps, "children">) {
const sub = useMemo(() => getEventRelays(event.id), [event.id]);
const seenRelays = useSubject(sub);
const ref = useRef<HTMLTableRowElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
const { colorMode } = useColorMode();
const yes = colorMode === "light" ? "green.200" : "green.800";
const no = colorMode === "light" ? "red.200" : "red.800";
const [broadcasting, setBroadcasting] = useState(false);
const broadcast = () => {
setBroadcasting(true);
const missingRelays = relays.filter((r) => !seenRelays.includes(r));
if (missingRelays.length === 0) return;
const pub = new NostrPublishAction("Broadcast", missingRelays, event);
pub.onResult.subscribe((result) => {
if (result.status) {
handleEventFromRelay(result.relay, event);
}
});
pub.onComplete.then(() => {
setBroadcasting(false);
});
};
return (
<Tr ref={ref} {...props}>
<Td isTruncated p="2">
{dayjs.unix(event.created_at).fromNow()}
</Td>
<Td isTruncated p="2">
<NoteLink noteId={event.id} />
</Td>
<Td p="2" overflow="hidden">
<Text isTruncated w={["xs", "xs", "xs", "sm", "xl"]}>
{event.content}
</Text>
</Td>
<Td title="Broadcast" p="2" onClick={() => !broadcasting && broadcast()} cursor="pointer">
{broadcasting ? <Spinner size="xs" /> : <RelayIcon />}
</Td>
{relays.map((relay) => (
<Td key={relay} title={relay} p="2" backgroundColor={seenRelays.includes(relay) ? yes : no}>
<RelayFavicon relay={relay} size="2xs" />
</Td>
))}
</Tr>
);
}
export default function TimelineHealth({ timeline }: { timeline: TimelineLoader }) {
const events = useSubject(timeline.timeline);
return (
<>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th p="2" w="1">
Date
</Th>
<Th p="2" w="1">
Event
</Th>
<Th p="2">Content</Th>
<Th />
{timeline.relays.map((relay) => (
<Th key={relay} title={relay} w="0.1rem" p="0">
<Tooltip label={relay}>
<Box p="2">
<RelayFavicon relay={relay} size="2xs" />
</Box>
</Tooltip>
</Th>
))}
</Tr>
</Thead>
<Tbody>
{events.map((event) => (
<EventRow key={event.id} event={event} relays={timeline.relays} />
))}
</Tbody>
</Table>
</TableContainer>
</>
);
}

View File

@ -1,18 +1,30 @@
import { ButtonGroup, ButtonGroupProps, IconButton } from "@chakra-ui/react";
import { ImageGridTimelineIcon, TextTimelineIcon } from "../icons";
import { TimelineViewType } from "./index";
import { useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { ButtonGroup, ButtonGroupProps, IconButton } from "@chakra-ui/react";
import { ImageGridTimelineIcon, TextTimelineIcon, TimelineHealthIcon } from "../icons";
import { TimelineViewType } from "./index";
import { searchParamsToJson } from "../../helpers/url";
export default function TimelineViewTypeButtons(props: ButtonGroupProps) {
const [params, setParams] = useSearchParams();
const mode = (params.get("view") as TimelineViewType) ?? "timeline";
const onChange = (type: TimelineViewType) => {
setParams({ view: type }, { replace: true });
};
const onChange = useCallback(
(type: TimelineViewType) => {
setParams((p) => ({ ...searchParamsToJson(p), view: type }), { replace: true });
},
[setParams],
);
return (
<ButtonGroup>
<ButtonGroup {...props}>
<IconButton
aria-label="Health"
icon={<TimelineHealthIcon />}
variant={mode === "health" ? "solid" : "ghost"}
onClick={() => onChange("health")}
/>
<IconButton
aria-label="Timeline"
icon={<TextTimelineIcon />}

View File

@ -1,10 +1,11 @@
import React from "react";
import { Link } from "react-router-dom";
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip19";
import { nip19 } from "nostr-tools";
import { UserAvatar, UserAvatarProps } from "./user-avatar";
export const UserAvatarLink = React.memo(({ pubkey, ...props }: UserAvatarProps) => (
<Link to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<Link to={`/u/${nip19.npubEncode(pubkey)}`}>
<UserAvatar pubkey={pubkey} {...props} />
</Link>
));

View File

@ -6,6 +6,7 @@ import { getIdenticon } from "../helpers/identicon";
import { safeUrl } from "../helpers/parse";
import appSettings from "../services/settings/app-settings";
import useSubject from "../hooks/use-subject";
import { getUserDisplayName } from "../helpers/user-metadata";
export const UserIdenticon = React.memo(({ pubkey }: { pubkey: string }) => {
const { value: identicon } = useAsync(() => getIdenticon(pubkey), [pubkey]);
@ -15,11 +16,12 @@ export const UserIdenticon = React.memo(({ pubkey }: { pubkey: string }) => {
export type UserAvatarProps = Omit<AvatarProps, "src"> & {
pubkey: string;
relay?: string;
noProxy?: boolean;
};
export const UserAvatar = React.memo(({ pubkey, noProxy, ...props }: UserAvatarProps) => {
export const UserAvatar = React.memo(({ pubkey, noProxy, relay, ...props }: UserAvatarProps) => {
const { imageProxy, proxyUserMedia } = useSubject(appSettings);
const metadata = useUserMetadata(pubkey);
const metadata = useUserMetadata(pubkey, relay ? [relay] : undefined);
const picture = useMemo(() => {
if (metadata?.picture) {
const src = safeUrl(metadata?.picture);
@ -35,6 +37,14 @@ export const UserAvatar = React.memo(({ pubkey, noProxy, ...props }: UserAvatarP
}
}, [metadata?.picture, imageProxy]);
return <Avatar src={picture} icon={<UserIdenticon pubkey={pubkey} />} overflow="hidden" {...props} />;
return (
<Avatar
src={picture}
icon={<UserIdenticon pubkey={pubkey} />}
overflow="hidden"
title={getUserDisplayName(metadata, pubkey)}
{...props}
/>
);
});
UserAvatar.displayName = "UserAvatar";

View File

@ -1,44 +1,174 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { useCallback, useState } from "react";
import {
Button,
ButtonProps,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuItemOption,
MenuOptionGroup,
MenuDivider,
useToast,
useDisclosure,
} from "@chakra-ui/react";
import { useCurrentAccount } from "../hooks/use-current-account";
import useSubject from "../hooks/use-subject";
import clientFollowingService from "../services/client-following";
import { useUserContacts } from "../hooks/use-user-contacts";
import { useReadRelayUrls } from "../hooks/use-client-relays";
import { useAdditionalRelayContext } from "../providers/additional-relay-context";
import { ArrowDownSIcon, FollowIcon, PlusCircleIcon, UnfollowIcon } from "./icons";
import useUserLists from "../hooks/use-user-lists";
import {
PEOPLE_LIST_KIND,
createEmptyContactList,
draftAddPerson,
draftRemovePerson,
getListName,
getPubkeysFromList,
isPubkeyInList,
} from "../helpers/nostr/lists";
import { getEventCoordinate } from "../helpers/nostr/events";
import { useSigningContext } from "../providers/signing-provider";
import NostrPublishAction from "../classes/nostr-publish-action";
import clientRelaysService from "../services/client-relays";
import useUserContactList from "../hooks/use-user-contact-list";
import replaceableEventLoaderService from "../services/replaceable-event-requester";
import useAsyncErrorHandler from "../hooks/use-async-error-handler";
import NewListModal from "../views/lists/components/new-list-modal";
export const UserFollowButton = ({
pubkey,
...props
}: { pubkey: string } & Omit<ButtonProps, "onClick" | "isLoading" | "isDisabled">) => {
const account = useCurrentAccount();
const following = useSubject(clientFollowingService.following) ?? [];
const savingDraft = useSubject(clientFollowingService.savingDraft);
function UsersLists({ pubkey }: { pubkey: string }) {
const toast = useToast();
const account = useCurrentAccount()!;
const { requestSignature } = useSigningContext();
const [isLoading, setLoading] = useState(false);
const newListModal = useDisclosure();
const readRelays = useReadRelayUrls(useAdditionalRelayContext());
const userContacts = useUserContacts(pubkey, readRelays);
const lists = useUserLists(account.pubkey).filter((list) => list.kind === PEOPLE_LIST_KIND);
const isFollowing = following.some((t) => t[1] === pubkey);
const isFollowingMe = account && userContacts?.contacts.includes(account.pubkey);
const inLists = lists.filter((list) => getPubkeysFromList(list).some((p) => p.pubkey === pubkey));
const toggleFollow = async () => {
if (isFollowing) {
clientFollowingService.removeContact(pubkey);
} else {
clientFollowingService.addContact(pubkey);
}
const handleChange = useCallback(
async (cords: string | string[]) => {
if (!Array.isArray(cords)) return;
await clientFollowingService.savePending();
};
const writeRelays = clientRelaysService.getWriteUrls();
setLoading(true);
try {
const addToList = lists.find((list) => !inLists.includes(list) && cords.includes(getEventCoordinate(list)));
const removeFromList = lists.find(
(list) => inLists.includes(list) && !cords.includes(getEventCoordinate(list)),
);
if (addToList) {
const draft = draftAddPerson(addToList, pubkey);
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Add to list", writeRelays, signed);
} else if (removeFromList) {
const draft = draftRemovePerson(removeFromList, pubkey);
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Remove from list", writeRelays, signed);
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
},
[lists],
);
return (
<Button
colorScheme={isFollowing ? "orange" : "brand"}
{...props}
isLoading={savingDraft}
onClick={toggleFollow}
isDisabled={account?.readonly ?? true}
>
{isFollowing ? "Unfollow" : isFollowingMe ? "Follow Back" : "Follow"}
</Button>
<>
{lists.length > 0 && (
<MenuOptionGroup
title="Lists"
type="checkbox"
value={inLists.map((list) => getEventCoordinate(list))}
onChange={handleChange}
>
{lists.map((list) => (
<MenuItemOption
key={getEventCoordinate(list)}
value={getEventCoordinate(list)}
isDisabled={account.readonly && isLoading}
isTruncated
maxW="90vw"
>
{getListName(list)}
</MenuItemOption>
))}
</MenuOptionGroup>
)}
<MenuDivider />
<MenuItem icon={<PlusCircleIcon />} onClick={newListModal.onOpen}>
New list
</MenuItem>
{newListModal.isOpen && <NewListModal onClose={newListModal.onClose} isOpen onCreated={newListModal.onClose} />}
</>
);
}
export type UserFollowButtonProps = { pubkey: string; showLists?: boolean } & Omit<
ButtonProps,
"onClick" | "isLoading" | "isDisabled"
>;
export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButtonProps) => {
const account = useCurrentAccount()!;
const { requestSignature } = useSigningContext();
const contacts = useUserContactList(account?.pubkey, [], true);
const isFollowing = isPubkeyInList(contacts, pubkey);
const isDisabled = account?.readonly ?? true;
const handleFollow = useAsyncErrorHandler(async () => {
const draft = draftAddPerson(contacts || createEmptyContactList(), pubkey);
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Follow", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed);
});
const handleUnfollow = useAsyncErrorHandler(async () => {
const draft = draftRemovePerson(contacts || createEmptyContactList(), pubkey);
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Unfollow", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed);
});
if (showLists) {
return (
<Menu closeOnSelect={false}>
<MenuButton as={Button} colorScheme="brand" {...props} rightIcon={<ArrowDownSIcon />} isDisabled={isDisabled}>
{isFollowing ? "Unfollow" : "Follow"}
</MenuButton>
<MenuList>
{isFollowing ? (
<MenuItem onClick={handleUnfollow} icon={<UnfollowIcon />} isDisabled={isDisabled}>
Unfollow
</MenuItem>
) : (
<MenuItem onClick={handleFollow} icon={<FollowIcon />} isDisabled={isDisabled}>
Follow
</MenuItem>
)}
{account && (
<>
<MenuDivider />
<UsersLists pubkey={pubkey} />
</>
)}
</MenuList>
</Menu>
);
} else if (isFollowing) {
return (
<Button onClick={handleUnfollow} colorScheme="brand" icon={<UnfollowIcon />} isDisabled={isDisabled} {...props}>
Unfollow
</Button>
);
} else {
return (
<Button onClick={handleFollow} colorScheme="brand" icon={<FollowIcon />} isDisabled={isDisabled} {...props}>
Follow
</Button>
);
}
};

View File

@ -1,6 +1,7 @@
import { Link, LinkProps } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip19";
import { nip19 } from "nostr-tools";
import { getUserDisplayName } from "../helpers/user-metadata";
import { useUserMetadata } from "../hooks/use-user-metadata";
@ -11,10 +12,9 @@ export type UserLinkProps = LinkProps & {
export const UserLink = ({ pubkey, showAt, ...props }: UserLinkProps) => {
const metadata = useUserMetadata(pubkey);
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
return (
<Link as={RouterLink} to={`/u/${npub}`} whiteSpace="nowrap" {...props}>
<Link as={RouterLink} to={`/u/${nip19.npubEncode(pubkey)}`} whiteSpace="nowrap" {...props}>
{showAt && "@"}
{getUserDisplayName(metadata, pubkey)}
</Link>

View File

@ -1,6 +1,7 @@
import {
Box,
Button,
ButtonGroup,
Flex,
Heading,
Image,
@ -14,13 +15,15 @@ import {
Text,
useToast,
} from "@chakra-ui/react";
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useForm } from "react-hook-form";
import { DraftNostrEvent, NostrEvent, isDTag } from "../types/nostr-event";
import { UserAvatar } from "./user-avatar";
import { UserLink } from "./user-link";
import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11";
import { LightningIcon } from "./icons";
import { Kind } from "nostr-tools";
import clientRelaysService from "../services/client-relays";
import { getEventRelays } from "../services/event-relays";
import { useSigningContext } from "../providers/signing-provider";
@ -29,13 +32,14 @@ import useSubject from "../hooks/use-subject";
import useUserLNURLMetadata from "../hooks/use-user-lnurl-metadata";
import { requestZapInvoice } from "../helpers/zaps";
import { ParsedStream, getATag } from "../helpers/nostr/stream";
import EmbeddedNote from "./note/embedded-note";
import dayjs from "dayjs";
import EmbeddedNote from "./embed-event/event-types/embedded-note";
import { unique } from "../helpers/array";
import { useUserRelays } from "../hooks/use-user-relays";
import { RelayMode } from "../classes/relay";
import relayScoreboardService from "../services/relay-scoreboard";
import { useAdditionalRelayContext } from "../providers/additional-relay-context";
import { getEventCoordinate, isReplaceable } from "../helpers/nostr/events";
import { EmbedEvent } from "./embed-event";
type FormValues = {
amount: number;
@ -45,7 +49,7 @@ type FormValues = {
export type ZapModalProps = Omit<ModalProps, "children"> & {
pubkey: string;
event?: NostrEvent;
stream?: ParsedStream;
relays?: string[];
initialComment?: string;
initialAmount?: number;
onInvoice: (invoice: string) => void;
@ -57,7 +61,7 @@ export type ZapModalProps = Omit<ModalProps, "children"> & {
export default function ZapModal({
event,
pubkey,
stream,
relays,
onClose,
initialComment,
initialAmount,
@ -130,10 +134,11 @@ export default function ZapModal({
],
};
console.log(zapRequest);
if (event) zapRequest.tags.push(["e", event.id]);
if (stream) zapRequest.tags.push(["a", getATag(stream)]);
if (event) {
if (isReplaceable(event.kind) && event.tags.some(isDTag)) {
zapRequest.tags.push(["a", getEventCoordinate(event)]);
} else zapRequest.tags.push(["e", event.id]);
}
const signed = await requestSignature(zapRequest);
if (signed) {
@ -175,26 +180,17 @@ export default function ZapModal({
</Box>
</Flex>
{showEventPreview && stream && (
<Box>
<Heading size="sm" mb="2">
Stream: {stream.title}
</Heading>
{stream.image && <Image src={stream.image} />}
</Box>
)}
{showEventPreview && event && <EmbeddedNote note={event} />}
{showEventPreview && event && <EmbedEvent event={event} />}
{allowComment && (canZap || lnurlMetadata?.commentAllowed) && (
<Input
placeholder="Comment"
{...register("comment", { maxLength: lnurlMetadata?.commentAllowed ?? 150 })}
autoComplete="off"
autoFocus={!initialComment}
/>
)}
<Flex gap="2" alignItems="center" flexWrap="wrap">
<Flex gap="2" alignItems="center" wrap="wrap">
{customZapAmounts
.split(",")
.map((v) => parseInt(v))
@ -206,6 +202,7 @@ export default function ZapModal({
}}
leftIcon={<LightningIcon color="yellow.400" />}
variant="solid"
size="sm"
>
{amount}
</Button>

View File

@ -3,15 +3,27 @@ import { convertToUrl } from "./url";
const corsFailedHosts = new Set();
export function createCorsUrl(url: URL | string, corsProxy = appSettings.value.corsProxy) {
if (!corsProxy) return url;
if (corsProxy.includes("<url>")) {
return corsProxy.replace("<url>", "" + url);
} else if (corsProxy.includes("<encoded_url>")) {
return corsProxy.replace("<encoded_url>", encodeURIComponent("" + url));
} else {
return corsProxy.endsWith("/") ? corsProxy + url : corsProxy + "/" + url;
}
}
export function fetchWithCorsFallback(url: URL | string, opts?: RequestInit) {
if (!appSettings.value.corsProxy) return fetch(url, opts);
if (corsFailedHosts.has(convertToUrl(url).host)) {
return fetch(appSettings.value.corsProxy + url, opts);
return fetch(createCorsUrl(url), opts);
}
return fetch(url, opts).catch((e) => {
corsFailedHosts.add(convertToUrl(url).host);
return fetch(appSettings.value.corsProxy + url, opts);
return fetch(createCorsUrl(url), opts);
});
}

View File

@ -1,4 +1,5 @@
import { cloneElement } from "react";
import { getMatchLink } from "./regexp";
export type EmbedableContent = (string | JSX.Element)[];
export type EmbedType = {
@ -8,7 +9,7 @@ export type EmbedType = {
getLocation?: (match: RegExpMatchArray) => { start: number; end: number };
};
function defaultGetLocation(match: RegExpMatchArray) {
export function defaultGetLocation(match: RegExpMatchArray) {
if (match.index === undefined) throw new Error("match dose not have index");
return {
start: match.index,
@ -20,24 +21,43 @@ export function embedJSX(content: EmbedableContent, embed: EmbedType): Embedable
return content
.map((subContent, i) => {
if (typeof subContent === "string") {
const match = subContent.match(embed.regexp);
const matches = subContent.matchAll(embed.regexp);
if (match && match.index !== undefined) {
const { start, end } = (embed.getLocation || defaultGetLocation)(match);
const before = subContent.slice(0, start);
const after = subContent.slice(end, subContent.length);
let render = embed.render(match);
if (matches) {
const newContent: EmbedableContent = [];
let cursor = 0;
let str = subContent;
for (const match of matches) {
if (match.index !== undefined) {
const { start, end } = (embed.getLocation || defaultGetLocation)(match);
if (render === null) return subContent;
if (start < cursor) continue;
if (typeof render !== "string" && !render.props.key) {
render = cloneElement(render, { key: match[0] });
const before = str.slice(0, start - cursor);
const after = str.slice(end - cursor, str.length);
let render = embed.render(match);
if (render === null) continue;
if (typeof render !== "string" && !render.props.key) {
render = cloneElement(render, { key: embed.name + match[0] });
}
newContent.push(before, render);
cursor = end;
str = after;
}
}
const newContent: EmbedableContent = [];
if (before.length > 0) newContent.push(...embedJSX([before], embed));
newContent.push(render);
if (after.length > 0) newContent.push(...embedJSX([after], embed));
// if all matches failed just return the existing content
if (newContent.length === 0) {
return subContent;
}
// add the remaining string to the content
if (str.length > 0) {
newContent.push(str);
}
return newContent;
}
@ -53,7 +73,7 @@ export type LinkEmbedHandler = (link: URL) => JSX.Element | string | null;
export function embedUrls(content: EmbedableContent, handlers: LinkEmbedHandler[]) {
return embedJSX(content, {
name: "embedUrls",
regexp: /https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,12})(\/[\+~%\/\.\w\-_@]*)?([\?#][^\s]+)?/i,
regexp: getMatchLink(),
render: (match) => {
try {
const url = new URL(match[0]);

View File

@ -25,5 +25,7 @@ export function getLudEndpoint(addressOrLNURL: string) {
if (addressOrLNURL.includes("@")) {
return parseLub16Address(addressOrLNURL);
}
return parseLNURL(addressOrLNURL);
try {
return parseLNURL(addressOrLNURL);
} catch (e) {}
}

View File

@ -1,31 +1,29 @@
import { bech32 } from "bech32";
import { nip19 } from "nostr-tools";
import { getPublicKey, nip19 } from "nostr-tools";
import { getEventRelays } from "../services/event-relays";
import relayScoreboardService from "../services/relay-scoreboard";
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
import { getEventUID, isReplaceable } from "./nostr/events";
import { DecodeResult } from "nostr-tools/lib/nip19";
export function isHex(key?: string) {
export function isHexKey(key?: string) {
if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true;
return false;
}
export enum Bech32Prefix {
Pubkey = "npub",
SecKey = "nsec",
Note = "note",
Profile = "nprofile",
}
/** @deprecated */
export function isBech32Key(bech32String: string) {
try {
const { prefix } = bech32.decode(bech32String.toLowerCase());
if (!prefix) return false;
if (!isHex(bech32ToHex(bech32String))) return false;
if (!isHexKey(bech32ToHex(bech32String))) return false;
} catch (error) {
return false;
}
return true;
}
/** @deprecated */
export function bech32ToHex(bech32String: string) {
try {
const { words } = bech32.decode(bech32String);
@ -34,16 +32,7 @@ export function bech32ToHex(bech32String: string) {
return "";
}
export function hexToBech32(hex: string, prefix: Bech32Prefix) {
try {
const hexArray = hexStringToUint8(hex);
return hexArray && bech32.encode(prefix, bech32.toWords(hexArray));
} catch (error) {
// continue
}
return null;
}
/** @deprecated */
export function toHexString(buffer: Uint8Array) {
return buffer.reduce((s, byte) => {
let hex = byte.toString(16);
@ -52,34 +41,32 @@ export function toHexString(buffer: Uint8Array) {
}, "");
}
export function hexStringToUint8(str: string) {
if (str.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(str)) {
return null;
}
let buffer = new Uint8Array(str.length / 2);
for (let i = 0; i < buffer.length; i++) {
buffer[i] = parseInt(str.substr(2 * i, 2), 16);
}
return buffer;
}
export function safeDecode(str: string) {
try {
return nip19.decode(str);
} catch (e) {}
}
export function normalizeToBech32(key: string, prefix: Bech32Prefix = Bech32Prefix.Pubkey) {
if (isHex(key)) return hexToBech32(key, prefix);
if (isBech32Key(key)) return key;
return null;
export function getPubkey(result: nip19.DecodeResult) {
switch (result.type) {
case "naddr":
case "nprofile":
return result.data.pubkey;
case "npub":
return result.data;
case "nsec":
return getPublicKey(result.data);
}
}
/** @deprecated */
export function normalizeToHex(hex: string) {
if (isHex(hex)) return hex;
if (isHexKey(hex)) return hex;
if (isBech32Key(hex)) return bech32ToHex(hex);
return null;
}
/** @deprecated */
export function getSharableNoteId(eventId: string) {
const relays = getEventRelays(eventId).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
@ -89,3 +76,76 @@ export function getSharableNoteId(eventId: string) {
return nip19.neventEncode({ id: eventId, relays: onlyTwo });
} else return nip19.noteEncode(eventId);
}
export function getSharableEventAddress(event: NostrEvent) {
const relays = getEventRelays(getEventUID(event)).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
if (isReplaceable(event.kind)) {
const d = event.tags.find(isDTag)?.[1];
if (!d) return null;
return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays: onlyTwo });
} else {
if (onlyTwo.length > 0) {
return nip19.neventEncode({ id: event.id, relays: onlyTwo });
} else return nip19.noteEncode(event.id);
}
}
export function encodePointer(pointer: DecodeResult) {
switch (pointer.type) {
case "naddr":
return nip19.naddrEncode(pointer.data);
case "nprofile":
return nip19.nprofileEncode(pointer.data);
case "nevent":
return nip19.neventEncode(pointer.data);
case "nrelay":
return nip19.nrelayEncode(pointer.data);
case "nsec":
return nip19.nsecEncode(pointer.data);
case "npub":
return nip19.npubEncode(pointer.data);
case "note":
return nip19.noteEncode(pointer.data);
}
}
export function getPointerFromTag(tag: Tag): DecodeResult | null {
if (isETag(tag)) {
if (!tag[1]) return null;
return {
type: "nevent",
data: {
id: tag[1],
relays: tag[2] ? [tag[2]] : undefined,
},
};
} else if (isATag(tag)) {
const [_, coordinate, relay] = tag;
const parts = coordinate.split(":") as (string | undefined)[];
const kind = parts[0] && parseInt(parts[0]);
const pubkey = parts[1];
const d = parts[2];
if (!kind) return null;
if (!pubkey) return null;
if (!d) return null;
return {
type: "naddr",
data: {
kind,
pubkey,
identifier: d,
relays: relay ? [relay] : undefined,
},
};
} else if (isPTag(tag)) {
const [_, pubkey, relay] = tag;
if (!pubkey) return null;
return { type: "nprofile", data: { pubkey, relays: relay ? [relay] : undefined } };
}
return null;
}

View File

@ -0,0 +1,3 @@
export function buildAppSelectUrl(identifier: string) {
return `https://nostrapp.link/#${identifier}?select=true`;
}

View File

@ -0,0 +1,18 @@
import { NostrEvent, isATag } from "../../types/nostr-event";
export const EMOJI_PACK_KIND = 30030;
export const USER_EMOJI_LIST_KIND = 10030;
export function getPackName(event: NostrEvent) {
return event.tags.find((t) => t[0] === "d")?.[1];
}
export function getEmojisFromPack(pack: NostrEvent) {
return pack.tags
.filter((t) => t[0] === "emoji" && t[1] && t[2])
.map((t) => ({ name: t[1] as string, url: t[2] as string }));
}
export function getPackCordsFromFavorites(event: NostrEvent) {
return event.tags.filter(isATag).map((t) => t[1]);
}

View File

@ -1,36 +1,40 @@
import dayjs from "dayjs";
import { getEventRelays } from "../../services/event-relays";
import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event";
import { RelayConfig, RelayMode } from "../../classes/relay";
import accountService from "../../services/account";
import { Kind, nip19 } from "nostr-tools";
import { matchNostrLink } from "../regexp";
import { getSharableNoteId } from "../nip19";
import { getEventRelays } from "../../services/event-relays";
import { ATag, DraftNostrEvent, ETag, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event";
import { RelayConfig, RelayMode } from "../../classes/relay";
import { getMatchNostrLink } from "../regexp";
import relayScoreboardService from "../../services/relay-scoreboard";
import { getAddr } from "../../services/replaceable-event-requester";
export function isReply(event: NostrEvent | DraftNostrEvent) {
return event.kind === 1 && !!getReferences(event).replyId;
}
export function isRepost(event: NostrEvent | DraftNostrEvent) {
const match = event.content.match(matchNostrLink);
return event.kind === 6 || (match && match[0].length === event.content.length);
}
import type { AddressPointer, EventPointer } from "nostr-tools/lib/nip19";
export function truncatedId(str: string, keep = 6) {
if (str.length < keep * 2 + 3) return str;
return str.substring(0, keep) + "..." + str.substring(str.length - keep);
}
// based on replaceable kinds from https://github.com/nostr-protocol/nips/blob/master/01.md#kinds
export function isReplaceable(kind: number) {
return (kind >= 30000 && kind < 40000) || kind === 0 || kind === 3 || (kind >= 10000 && kind < 20000);
}
// used to get a unique Id for each event, should take into account replaceable events
export function getEventUID(event: NostrEvent) {
if (event.kind >= 30000 && event.kind < 40000) {
return getAddr(event.kind, event.pubkey, event.tags.find((t) => t[0] === "d" && t[1])?.[1]);
if (isReplaceable(event.kind)) {
return getEventCoordinate(event);
}
return event.id;
}
export function isReply(event: NostrEvent | DraftNostrEvent) {
return event.kind === 1 && !!getReferences(event).replyId;
}
export function isRepost(event: NostrEvent | DraftNostrEvent) {
const match = event.content.match(getMatchNostrLink());
return event.kind === 6 || (match && match[0].length === event.content.length);
}
/**
* returns an array of tag indexes that are referenced in the content
* either with the legacy #[0] syntax or nostr:xxxxx links
@ -39,7 +43,7 @@ export function getContentTagRefs(content: string, tags: Tag[]) {
const indexes = new Set();
Array.from(content.matchAll(/#\[(\d+)\]/gi)).forEach((m) => indexes.add(parseInt(m[1])));
const linkMatches = Array.from(content.matchAll(new RegExp(matchNostrLink, "gi")));
const linkMatches = Array.from(content.matchAll(getMatchNostrLink()));
for (const [_, _prefix, link] of linkMatches) {
try {
const decoded = nip19.decode(link);
@ -134,37 +138,6 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
};
}
export function buildReply(event: NostrEvent, account = accountService.current.value): DraftNostrEvent {
const refs = getReferences(event);
const relay = getEventRelays(event.id).value?.[0] ?? "";
const tags: NostrEvent["tags"] = [];
const rootId = refs.rootId ?? event.id;
const replyId = event.id;
tags.push(["e", rootId, relay, "root"]);
if (replyId !== rootId) {
tags.push(["e", replyId, relay, "reply"]);
}
// add all ptags
// TODO: omit my own pubkey
const ptags = event.tags.filter(isPTag).filter((t) => !account || t[1] !== account.pubkey);
tags.push(...ptags);
// add the original authors pubkey if its not already there
if (!ptags.some((t) => t[1] === event.pubkey)) {
tags.push(["p", event.pubkey]);
}
return {
kind: Kind.Text,
// TODO: be smarter about picking relay
tags,
content: "",
created_at: dayjs().unix(),
};
}
export function buildRepost(event: NostrEvent): DraftNostrEvent {
const relays = getEventRelays(event.id).value;
const topRelay = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
@ -180,26 +153,6 @@ export function buildRepost(event: NostrEvent): DraftNostrEvent {
};
}
export function buildQuoteRepost(event: NostrEvent): DraftNostrEvent {
const nevent = getSharableNoteId(event.id);
return {
kind: Kind.Text,
tags: [],
content: "nostr:" + nevent,
created_at: dayjs().unix(),
};
}
export function buildDeleteEvent(eventIds: string[], reason = ""): DraftNostrEvent {
return {
kind: Kind.EventDeletion,
tags: eventIds.map((id) => ["e", id]),
content: reason,
created_at: dayjs().unix(),
};
}
export function parseRTag(tag: RTag): RelayConfig {
switch (tag[2]) {
case "write":
@ -210,3 +163,51 @@ export function parseRTag(tag: RTag): RelayConfig {
return { url: tag[1], mode: RelayMode.ALL };
}
}
export function getEventCoordinate(event: NostrEvent) {
const d = event.tags.find((t) => t[0] === "d")?.[1];
return d ? `${event.kind}:${event.pubkey}:${d}` : `${event.kind}:${event.pubkey}`;
}
export type CustomEventPointer = Omit<AddressPointer, "identifier"> & {
identifier?: string;
};
export function parseCoordinate(a: string): CustomEventPointer | null {
const parts = a.split(":") as (string | undefined)[];
const kind = parts[0] && parseInt(parts[0]);
const pubkey = parts[1];
const d = parts[2];
if (!kind) return null;
if (!pubkey) return null;
return {
kind,
pubkey,
identifier: d,
};
}
export function draftAddCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string, relay?: string) {
if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("event already in list");
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: [...list.tags, relay ? ["a", coordinate, relay] : ["a", coordinate]],
};
return draft;
}
export function draftRemoveCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string) {
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: list.tags.filter((t) => !(t[0] === "a" && t[1] === coordinate)),
};
return draft;
}

79
src/helpers/nostr/goal.ts Normal file
View File

@ -0,0 +1,79 @@
import dayjs from "dayjs";
import { NostrEvent, isRTag } from "../../types/nostr-event";
import { DecodeResult } from "nostr-tools/lib/nip19";
import { getPointerFromTag } from "../nip19";
export const GOAL_KIND = 9041;
export type ParsedGoal = {
event: NostrEvent;
author: string;
amount: number;
relays: string[];
};
export function getGoalPointerFromEvent(event: NostrEvent) {
const tag = event.tags.find((t) => t[0] === "goal");
const id = tag?.[1];
const relay = tag?.[2];
return id ? { id, relay } : undefined;
}
export function getGoalName(goal: NostrEvent) {
return goal.content;
}
export function getGoalRelays(goal: NostrEvent) {
const relays = goal.tags.find((t) => t[0] === "relays");
return relays ? relays.slice(1) : [];
}
export function getGoalAmount(goal: NostrEvent) {
const amount = goal.tags.find((t) => t[0] === "amount")?.[1];
if (amount === undefined) throw new Error("Missing amount");
const int = parseInt(amount);
if (!Number.isFinite(int)) throw new Error("Amount not a number");
if (int <= 0) throw new Error("Amount less than or equal to zero");
return int;
}
export function getGoalClosedDate(goal: NostrEvent) {
const value = goal.tags.find((t) => t[0] === "closed_at")?.[1];
if (value === undefined) return;
const date = dayjs.unix(parseInt(value));
if (!date.isValid) throw new Error("Invalid date");
return date.unix();
}
export function getGoalLinks(goal: NostrEvent) {
return goal.tags.filter(isRTag).map((t) => t[1]);
}
export function getGoalEventPointers(goal: NostrEvent) {
const pointers: DecodeResult[] = [];
for (const tag of goal.tags) {
const decoded = getPointerFromTag(tag);
if (decoded?.type === "naddr" || decoded?.type === "nevent") {
pointers.push(decoded);
}
}
return pointers;
}
export function validateGoal(goal: NostrEvent) {
const amount = getGoalAmount(goal);
const relays = getGoalRelays(goal);
if (relays.length) throw new Error("zero relays");
return true;
}
export function safeValidateGoal(goal: NostrEvent) {
try {
return validateGoal(goal);
} catch (e) {}
return false;
}
export function getGoalTag(goal: NostrEvent, relay?: string) {
const id = goal.id;
return ["goal", id, relay].filter(Boolean);
}

View File

@ -0,0 +1,99 @@
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { DraftNostrEvent, NostrEvent, isATag, isDTag, isETag, isPTag } from "../../types/nostr-event";
export const PEOPLE_LIST_KIND = 30000;
export const NOTE_LIST_KIND = 30001;
export const PIN_LIST_KIND = 10001;
export const MUTE_LIST_KIND = 10000;
export function getListName(event: NostrEvent) {
if (event.kind === Kind.Contacts) return "Following";
if (event.kind === PIN_LIST_KIND) return "Pins";
if (event.kind === MUTE_LIST_KIND) return "Mute";
return event.tags.find((t) => t[0] === "title")?.[1] || event.tags.find(isDTag)?.[1];
}
export function isSpecialListKind(kind: number) {
return kind === Kind.Contacts || kind === PIN_LIST_KIND || kind === MUTE_LIST_KIND;
}
export function getPubkeysFromList(event: NostrEvent) {
return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2] }));
}
export function getEventsFromList(event: NostrEvent) {
return event.tags.filter(isETag).map((t) => ({ id: t[1], relay: t[2] }));
}
export function getCoordinatesFromList(event: NostrEvent) {
return event.tags.filter(isATag).map((t) => ({ coordinate: t[1], relay: t[2] }));
}
export function isPubkeyInList(event?: NostrEvent, pubkey?: string) {
if (!pubkey || !event) return false;
return event.tags.some((t) => t[0] === "p" && t[1] === pubkey);
}
export function createEmptyContactList(): DraftNostrEvent {
return {
created_at: dayjs().unix(),
content: "",
tags: [],
kind: Kind.Contacts,
};
}
export function createEmptyMuteList(): DraftNostrEvent {
return {
created_at: dayjs().unix(),
content: "",
tags: [],
kind: MUTE_LIST_KIND,
};
}
export function draftAddPerson(list: NostrEvent | DraftNostrEvent, pubkey: string, relay?: string) {
if (list.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("person already in list");
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: [...list.tags, relay ? ["p", pubkey, relay] : ["p", pubkey]],
};
return draft;
}
export function draftRemovePerson(list: NostrEvent | DraftNostrEvent, pubkey: string) {
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: list.tags.filter((t) => !(t[0] === "p" && t[1] === pubkey)),
};
return draft;
}
export function draftAddEvent(list: NostrEvent | DraftNostrEvent, event: string, relay?: string) {
if (list.tags.some((t) => t[0] === "e" && t[1] === event)) throw new Error("event already in list");
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: [...list.tags, relay ? ["e", event, relay] : ["e", event]],
};
return draft;
}
export function draftRemoveEvent(list: NostrEvent | DraftNostrEvent, event: string) {
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: list.tags.filter((t) => !(t[0] === "e" && t[1] === event)),
};
return draft;
}

Some files were not shown because too many files have changed in this diff Show More