mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-01 00:19:45 +02:00
Merge branch 'next' into files
This commit is contained in:
commit
9c93a3d9bf
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
cypress/videos
|
||||
stats.html
|
||||
|
61
CHANGELOG.md
61
CHANGELOG.md
@ -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
|
||||
|
@ -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");
|
||||
|
@ -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();
|
||||
|
@ -1,7 +1,7 @@
|
||||
describe("Profile view", () => {
|
||||
it("should load a rss feed profile", () => {
|
||||
cy.visit(
|
||||
"#/u/nprofile1qqsp6hxqjatvxtesgszs8aee0fcjccxa3ef3mzjva4uv2yr5lucp6jcpzemhxue69uhhyumnd3shjtnwdaehgu3wd4hk2s8c5un"
|
||||
"#/u/nprofile1qqsp6hxqjatvxtesgszs8aee0fcjccxa3ef3mzjva4uv2yr5lucp6jcpzemhxue69uhhyumnd3shjtnwdaehgu3wd4hk2s8c5un",
|
||||
);
|
||||
|
||||
cy.contains("fjsmu");
|
||||
|
@ -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")
|
||||
|
@ -1,7 +1,7 @@
|
||||
describe("Thread", () => {
|
||||
it("should handle quote notes with e tags correctly", () => {
|
||||
cy.visit(
|
||||
"#/n/nevent1qqsx2lnyuke6vmsrz9fdrd6uwjy0g0e9l6menfgdj5truugkh9qmkkgpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqgc9md6"
|
||||
"#/n/nevent1qqsx2lnyuke6vmsrz9fdrd6uwjy0g0e9l6menfgdj5truugkh9qmkkgpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqgc9md6",
|
||||
);
|
||||
|
||||
// find first note
|
||||
|
@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
51
package.json
51
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
89
src/app.tsx
89
src/app.tsx
@ -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>
|
||||
|
@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
71
src/classes/nostr-publish-action.ts
Normal file
71
src/classes/nostr-publish-action.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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() {
|
||||
|
76
src/components/compact-user-stack.tsx
Normal file
76
src/components/compact-user-stack.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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)} />
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
33
src/components/embed-event/event-types/embedded-goal.tsx
Normal file
33
src/components/embed-event/event-types/embedded-goal.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
src/components/embed-event/event-types/embedded-note.tsx
Normal file
40
src/components/embed-event/event-types/embedded-note.tsx
Normal 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>
|
||||
);
|
||||
}
|
68
src/components/embed-event/event-types/embedded-stream.tsx
Normal file
68
src/components/embed-event/event-types/embedded-stream.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
src/components/embed-event/event-types/embedded-unknown.tsx
Normal file
36
src/components/embed-event/event-types/embedded-unknown.tsx
Normal 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>
|
||||
);
|
||||
}
|
61
src/components/embed-event/index.tsx
Normal file
61
src/components/embed-event/index.tsx
Normal 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}</>;
|
||||
}
|
@ -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} />;
|
||||
}
|
||||
|
@ -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;
|
||||
|
168
src/components/embed-types/image.tsx
Normal file
168
src/components/embed-types/image.tsx
Normal 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"]} />;
|
||||
}
|
@ -5,3 +5,4 @@ export * from "./common";
|
||||
export * from "./youtube";
|
||||
export * from "./nostr";
|
||||
export * from "./emoji";
|
||||
export * from "./image";
|
||||
|
@ -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]} />,
|
||||
});
|
||||
}
|
||||
|
@ -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");
|
||||
|
||||
|
76
src/components/event-reactions.tsx
Normal file
76
src/components/event-reactions.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
) : (
|
||||
|
75
src/components/layout/nav-items.tsx
Normal file
75
src/components/layout/nav-items.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
213
src/components/lightbox-provider.tsx
Normal file
213
src/components/lightbox-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
129
src/components/magic-textarea.tsx
Normal file
129
src/components/magic-textarea.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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}>
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
120
src/components/note/components/bookmark-button.tsx
Normal file
120
src/components/note/components/bookmark-button.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
23
src/components/note/components/note-reactions.tsx
Normal file
23
src/components/note/components/note-reactions.tsx
Normal 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>
|
||||
);
|
||||
}
|
37
src/components/note/components/quote-repost-button.tsx
Normal file
37
src/components/note/components/quote-repost-button.tsx
Normal 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} />
|
||||
);
|
||||
}
|
54
src/components/note/components/reaction-button.tsx
Normal file
54
src/components/note/components/reaction-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 && (
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 };
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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} />;
|
||||
},
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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 />
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
62
src/components/photo-gallery.tsx
Normal file
62
src/components/photo-gallery.tsx
Normal 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} />;
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
33
src/components/publish-details.tsx
Normal file
33
src/components/publish-details.tsx
Normal 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>
|
||||
);
|
||||
};
|
93
src/components/publish-log.tsx
Normal file
93
src/components/publish-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
51
src/components/reaction-details-modal.tsx
Normal file
51
src/components/reaction-details-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
81
src/components/reaction-picker.tsx
Normal file
81
src/components/reaction-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
@ -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))}
|
||||
|
@ -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:
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
125
src/components/timeline-page/timeline-health/index.tsx
Normal file
125
src/components/timeline-page/timeline-health/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 />}
|
||||
|
@ -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>
|
||||
));
|
||||
|
@ -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";
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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]);
|
||||
|
@ -25,5 +25,7 @@ export function getLudEndpoint(addressOrLNURL: string) {
|
||||
if (addressOrLNURL.includes("@")) {
|
||||
return parseLub16Address(addressOrLNURL);
|
||||
}
|
||||
return parseLNURL(addressOrLNURL);
|
||||
try {
|
||||
return parseLNURL(addressOrLNURL);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
3
src/helpers/nostr/apps.ts
Normal file
3
src/helpers/nostr/apps.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function buildAppSelectUrl(identifier: string) {
|
||||
return `https://nostrapp.link/#${identifier}?select=true`;
|
||||
}
|
18
src/helpers/nostr/emoji-packs.ts
Normal file
18
src/helpers/nostr/emoji-packs.ts
Normal 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]);
|
||||
}
|
@ -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
79
src/helpers/nostr/goal.ts
Normal 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);
|
||||
}
|
99
src/helpers/nostr/lists.ts
Normal file
99
src/helpers/nostr/lists.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user