mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-13 06:09:42 +02:00
Merge branch 'next' into lists
This commit is contained in:
commit
0e702e4e97
5
.changeset/beige-waves-wash.md
Normal file
5
.changeset/beige-waves-wash.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Show streamer cards in stream view on desktop
|
22
CHANGELOG.md
22
CHANGELOG.md
@ -1,5 +1,27 @@
|
||||
# nostrudel
|
||||
|
||||
## 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" />
|
||||
|
40
package.json
40
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nostrudel",
|
||||
"version": "0.23.0",
|
||||
"version": "0.24.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@ -12,16 +12,16 @@
|
||||
"test": "cypress run --e2e --browser=chrome"
|
||||
},
|
||||
"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",
|
||||
"bech32": "^2.0.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"dayjs": "^1.11.9",
|
||||
"debug": "^4.3.4",
|
||||
"framer-motion": "^7.10.3",
|
||||
"hls.js": "^1.4.7",
|
||||
"framer-motion": "^10.16.0",
|
||||
"hls.js": "^1.4.10",
|
||||
"idb": "^7.1.1",
|
||||
"identicon.js": "^2.3.3",
|
||||
"leaflet": "^1.9.4",
|
||||
@ -29,16 +29,18 @@
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"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,17 +50,17 @@
|
||||
"@types/leaflet": "^1.9.3",
|
||||
"@types/leaflet.locatecontrol": "^0.74.1",
|
||||
"@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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,6 @@ 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 useSetColorMode from "./hooks/use-set-color-mode";
|
||||
@ -118,10 +117,7 @@ const router = createHashRouter([
|
||||
{ path: "profile", element: <ProfileView /> },
|
||||
{
|
||||
path: "tools",
|
||||
children: [
|
||||
{ path: "", element: <ToolsHomeView /> },
|
||||
{ path: "nip19", element: <Nip19ToolsView /> },
|
||||
],
|
||||
children: [{ path: "", element: <ToolsHomeView /> }],
|
||||
},
|
||||
{
|
||||
path: "lists",
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
65
src/classes/nostr-publish-action.ts
Normal file
65
src/classes/nostr-publish-action.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { addToLog } from "../services/publish-log";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import createDefer from "./deferred";
|
||||
import { IncomingCommandResult, Relay } from "./relay";
|
||||
import Subject, { PersistentSubject } from "./subject";
|
||||
|
||||
let lastId = 0;
|
||||
|
||||
export default class NostrPublishAction {
|
||||
id = lastId++;
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
@ -99,7 +101,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,56 +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 { isVideoURL } from "../../helpers/url";
|
||||
|
||||
const BlurredImage = (props: ImageProps) => {
|
||||
const { isOpen, onOpen } = useDisclosure();
|
||||
return (
|
||||
<Box overflow="hidden">
|
||||
<Image
|
||||
onClick={
|
||||
!isOpen
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cursor="pointer"
|
||||
filter={isOpen ? "" : "blur(1.5rem)"}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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%" }} />;
|
||||
}
|
||||
|
@ -4,10 +4,10 @@ import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
|
||||
export function embedEmoji(content: EmbedableContent, note: NostrEvent | DraftNostrEvent) {
|
||||
return embedJSX(content, {
|
||||
regexp: /:([a-zA-Z0-9_]+):/i,
|
||||
regexp: /:([a-zA-Z0-9_]+):/gi,
|
||||
render: (match) => {
|
||||
const emojiTag = note.tags.find(
|
||||
(tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2]
|
||||
(tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2],
|
||||
);
|
||||
if (emojiTag) {
|
||||
return (
|
||||
|
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]} />,
|
||||
});
|
||||
}
|
||||
|
@ -3,17 +3,16 @@ 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";
|
||||
|
||||
// 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]);
|
||||
@ -40,7 +39,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 +59,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");
|
||||
|
||||
|
@ -297,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",
|
||||
@ -325,3 +331,9 @@ export const StarHalfIcon = createIcon({
|
||||
d: "M12.0006 15.968L16.2473 18.3451L15.2988 13.5717L18.8719 10.2674L14.039 9.69434L12.0006 5.27502V15.968ZM12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z",
|
||||
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,
|
||||
});
|
||||
|
@ -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,
|
||||
LiveStreamIcon,
|
||||
LogoutIcon,
|
||||
MapIcon,
|
||||
NotificationIcon,
|
||||
ProfileIcon,
|
||||
RelayIcon,
|
||||
SearchIcon,
|
||||
ListIcon,
|
||||
} 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("/map")} leftIcon={<MapIcon />}>
|
||||
Map
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}>
|
||||
Profile
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/lists")} leftIcon={<ListIcon />}>
|
||||
Lists
|
||||
</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,25 @@ 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" />}
|
||||
{!isMobile && <DesktopSideNav position="sticky" top="0" />}
|
||||
<Flex
|
||||
flexGrow={1}
|
||||
direction="column"
|
||||
w="full"
|
||||
overflowX="hidden"
|
||||
overflowY="visible"
|
||||
pb={showSideNav ? "14" : 0}
|
||||
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 {
|
||||
HomeIcon,
|
||||
LiveStreamIcon,
|
||||
LogoutIcon,
|
||||
ProfileIcon,
|
||||
RelayIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
ListIcon,
|
||||
} 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,29 +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("/lists")} leftIcon={<ListIcon />}>
|
||||
Lists
|
||||
</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>
|
||||
) : (
|
||||
|
63
src/components/layout/nav-items.tsx
Normal file
63
src/components/layout/nav-items.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { AbsoluteCenter, Box, Button, Divider } from "@chakra-ui/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ChatIcon,
|
||||
FeedIcon,
|
||||
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("/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>
|
||||
);
|
||||
}
|
@ -2,7 +2,6 @@ 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";
|
||||
@ -12,6 +11,7 @@ import eventReactionsService from "../../../services/event-reactions";
|
||||
import { getEventRelays } from "../../../services/event-relays";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
|
||||
import { LikeIcon } from "../../icons";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
|
||||
export default function ReactionButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
|
||||
const { requestSignature } = useSigningContext();
|
||||
@ -34,7 +34,7 @@ export default function ReactionButton({ note, ...props }: { note: NostrEvent }
|
||||
const signed = await requestSignature(event);
|
||||
if (signed) {
|
||||
const writeRelays = clientRelaysService.getWriteUrls();
|
||||
nostrPostAction(writeRelays, signed);
|
||||
new NostrPublishAction("Reaction", writeRelays, signed);
|
||||
eventReactionsService.handleEvent(signed);
|
||||
}
|
||||
setLoading(false);
|
||||
|
@ -9,7 +9,6 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
@ -17,10 +16,10 @@ 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 clientRelaysService from "../../../services/client-relays";
|
||||
import signingService from "../../../services/signing";
|
||||
import QuoteNote from "../quote-note";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
|
||||
export function RepostButton({ event }: { event: NostrEvent }) {
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
@ -33,8 +32,9 @@ export function RepostButton({ event }: { event: NostrEvent }) {
|
||||
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 signingService.requestSignature(draftRepost, account);
|
||||
const pub = new NostrPublishAction("Repost", clientRelaysService.getWriteUrls(), signed);
|
||||
await pub.onComplete;
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
|
@ -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 };
|
||||
|
||||
|
@ -25,7 +25,6 @@ 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";
|
||||
@ -46,7 +45,7 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
|
||||
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];
|
||||
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
@ -69,7 +68,6 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
|
||||
</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" />
|
||||
@ -81,7 +79,7 @@ export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
|
||||
as={Link}
|
||||
icon={<ExternalLinkIcon />}
|
||||
aria-label="Open External"
|
||||
href={externalLink[1]}
|
||||
href={externalLink}
|
||||
size="sm"
|
||||
variant="link"
|
||||
target="_blank"
|
||||
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -10,12 +10,17 @@ 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 { buildAppSelectUrl } from "../../helpers/nostr-apps";
|
||||
import { useDeleteEventContext } from "../../providers/delete-event-provider";
|
||||
import { nostrPostAction } from "../../classes/nostr-post-action";
|
||||
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();
|
||||
@ -30,11 +35,11 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
|
||||
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);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
@ -67,7 +67,7 @@ export default function PeopleListProvider({ children }: PropsWithChildren) {
|
||||
list,
|
||||
setList,
|
||||
}),
|
||||
[list, setList]
|
||||
[list, setList],
|
||||
);
|
||||
|
||||
return <PeopleListContext.Provider value={context}>{children}</PeopleListContext.Provider>;
|
||||
|
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, { useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@ -13,20 +14,17 @@ import {
|
||||
useToast,
|
||||
} 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 NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import { getReferences } from "../../helpers/nostr/event";
|
||||
import { matchHashtag, mentionNpubOrNote } from "../../helpers/regexp";
|
||||
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 { finalizeNote } from "../../helpers/nostr/post";
|
||||
|
||||
function emptyDraft(): DraftNostrEvent {
|
||||
return {
|
||||
@ -37,40 +35,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;
|
||||
@ -82,8 +46,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
||||
const { requestSignature } = useSigningContext();
|
||||
const writeRelays = useWriteRelayUrls();
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [signedEvent, setSignedEvent] = useState<NostrEvent | null>(null);
|
||||
const [results, resultsActions] = useList<PostResult>();
|
||||
const [publishAction, setPublishAction] = useState<NostrPublishAction>();
|
||||
const { isOpen: showPreview, onToggle: togglePreview } = useDisclosure();
|
||||
const [draft, setDraft] = useState<DraftNostrEvent>(() => Object.assign(emptyDraft(), initialDraft));
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
@ -96,7 +59,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) {
|
||||
@ -115,15 +78,12 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
||||
const handleSubmit = async () => {
|
||||
setWaiting(true);
|
||||
const updatedDraft = finalizeNote(draft);
|
||||
const event = await requestSignature(updatedDraft);
|
||||
const signed = await requestSignature(updatedDraft);
|
||||
setWaiting(false);
|
||||
if (!event) return;
|
||||
setSignedEvent(event);
|
||||
if (!signed) return;
|
||||
|
||||
const { results } = nostrPostAction(writeRelays, event);
|
||||
results.subscribe((result) => {
|
||||
resultsActions.push(result);
|
||||
});
|
||||
const pub = new NostrPublishAction("Post", writeRelays, signed);
|
||||
setPublishAction(pub);
|
||||
};
|
||||
|
||||
const refs = getReferences(draft);
|
||||
@ -131,8 +91,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 (
|
||||
<>
|
||||
@ -188,7 +155,7 @@ 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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
26
src/components/publish-details.tsx
Normal file
26
src/components/publish-details.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Flex, FlexProps, Progress } from "@chakra-ui/react";
|
||||
import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
import useSubject from "../hooks/use-subject";
|
||||
|
||||
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>{result.relay.url}</AlertTitle>
|
||||
{result.message && <AlertDescription>{result.message}</AlertDescription>}
|
||||
</Box>
|
||||
</Alert>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
92
src/components/publish-log.tsx
Normal file
92
src/components/publish-log.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
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.map((pub) => (
|
||||
<PublishAction key={pub.id} pub={pub} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -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))}
|
||||
|
@ -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,51 @@
|
||||
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";
|
||||
|
||||
const matchAllImages = new RegExp(matchImageUrls, "ig");
|
||||
function GalleryImage({ event, ...props }: EmbeddedImageProps & { event: NostrEvent }) {
|
||||
const ref = useRef<HTMLImageElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
|
||||
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 +53,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>
|
||||
);
|
||||
}
|
||||
|
121
src/components/timeline-page/timeline-health/index.tsx
Normal file
121
src/components/timeline-page/timeline-health/index.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
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";
|
||||
|
||||
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, event.id);
|
||||
|
||||
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 pub = new NostrPublishAction("Broadcast", relays, 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,5 +1,5 @@
|
||||
import { ButtonGroup, ButtonGroupProps, IconButton } from "@chakra-ui/react";
|
||||
import { ImageGridTimelineIcon, TextTimelineIcon } from "../icons";
|
||||
import { ImageGridTimelineIcon, TextTimelineIcon, TimelineHealthIcon } from "../icons";
|
||||
import { TimelineViewType } from "./index";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
@ -13,6 +13,12 @@ export default function TimelineViewTypeButtons(props: ButtonGroupProps) {
|
||||
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<IconButton
|
||||
aria-label="Health"
|
||||
icon={<TimelineHealthIcon />}
|
||||
variant={mode === "health" ? "solid" : "ghost"}
|
||||
onClick={() => onChange("health")}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Timeline"
|
||||
icon={<TextTimelineIcon />}
|
||||
|
76
src/components/user-avatar-stack.tsx
Normal file
76
src/components/user-avatar-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({
|
||||
users,
|
||||
maxUsers,
|
||||
label = "Users",
|
||||
...props
|
||||
}: { users: string[]; maxUsers?: number; label?: string } & FlexProps) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const clamped = maxUsers ? users.slice(0, maxUsers) : users;
|
||||
|
||||
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 !== users.length && (
|
||||
<Text mx="1" fontSize="sm" lineHeight={0}>
|
||||
+{users.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">
|
||||
{users.map((pubkey) => (
|
||||
<UserTag key={pubkey} pubkey={pubkey} p="2" fontWeight="bold" fontSize="md" />
|
||||
))}
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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]);
|
||||
@ -35,6 +36,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";
|
||||
|
@ -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]);
|
||||
|
@ -4,7 +4,7 @@ import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag, Tag } from "../../ty
|
||||
import { RelayConfig, RelayMode } from "../../classes/relay";
|
||||
import accountService from "../../services/account";
|
||||
import { Kind, nip19 } from "nostr-tools";
|
||||
import { matchNostrLink } from "../regexp";
|
||||
import { getMatchNostrLink } from "../regexp";
|
||||
import { getSharableNoteId } from "../nip19";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
import { getAddr } from "../../services/replaceable-event-requester";
|
||||
@ -14,7 +14,7 @@ export function isReply(event: NostrEvent | DraftNostrEvent) {
|
||||
}
|
||||
|
||||
export function isRepost(event: NostrEvent | DraftNostrEvent) {
|
||||
const match = event.content.match(matchNostrLink);
|
||||
const match = event.content.match(getMatchNostrLink());
|
||||
return event.kind === 6 || (match && match[0].length === event.content.length);
|
||||
}
|
||||
|
||||
@ -39,7 +39,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);
|
||||
@ -210,3 +210,19 @@ export function parseRTag(tag: RTag): RelayConfig {
|
||||
return { url: tag[1], mode: RelayMode.ALL };
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCoordinate(a: string) {
|
||||
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,
|
||||
d,
|
||||
};
|
||||
}
|
||||
|
109
src/helpers/nostr/post.ts
Normal file
109
src/helpers/nostr/post.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { DraftNostrEvent, NostrEvent, PTag, Tag } from "../../types/nostr-event";
|
||||
import { getMatchHashtag, getMentionNpubOrNote } from "../regexp";
|
||||
import { normalizeToHex } from "../nip19";
|
||||
import { getReferences } from "./event";
|
||||
import { getEventRelays } from "../../services/event-relays";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
|
||||
function addTag(tags: Tag[], tag: Tag, overwrite = false) {
|
||||
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
|
||||
if (overwrite) {
|
||||
return tags.map((t) => {
|
||||
if (t[0] === tag[0] && t[1] === tag[1]) return tag;
|
||||
return t;
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
return [...tags, tag];
|
||||
}
|
||||
function AddEtag(tags: Tag[], eventId: string, type?: string, overwrite = false) {
|
||||
const relays = getEventRelays(eventId).value ?? [];
|
||||
const top = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
|
||||
|
||||
const tag = type ? ["e", eventId, top, type] : ["e", eventId, top];
|
||||
|
||||
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1] && t[3] === tag[3])) {
|
||||
if (overwrite) {
|
||||
return tags.map((t) => {
|
||||
if (t[0] === tag[0] && t[1] === tag[1]) return tag;
|
||||
return t;
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
return [...tags, tag];
|
||||
}
|
||||
|
||||
/** adds the "root" and "reply" E tags */
|
||||
export function addReplyTags(draft: DraftNostrEvent, replyTo: NostrEvent) {
|
||||
const updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
const refs = getReferences(replyTo);
|
||||
|
||||
const rootId = refs.rootId ?? replyTo.id;
|
||||
const replyId = replyTo.id;
|
||||
|
||||
updated.tags = AddEtag(updated.tags, rootId, "root", true);
|
||||
updated.tags = AddEtag(updated.tags, replyId, "reply", true);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** ensure a list of pubkeys are present on an event */
|
||||
export function ensureNotifyUsers(draft: DraftNostrEvent, pubkeys: string[]) {
|
||||
const updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
|
||||
for (const pubkey of pubkeys) {
|
||||
updated.tags = addTag(updated.tags, ["p", pubkey], false);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function replaceAtMentions(draft: DraftNostrEvent) {
|
||||
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
|
||||
// replace all occurrences of @npub and @note
|
||||
while (true) {
|
||||
const match = getMentionNpubOrNote().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);
|
||||
}
|
||||
|
||||
return updatedDraft;
|
||||
}
|
||||
|
||||
export function createHashtagTags(draft: DraftNostrEvent) {
|
||||
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
|
||||
// create tags for all occurrences of #hashtag
|
||||
const matches = updatedDraft.content.matchAll(getMatchHashtag());
|
||||
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;
|
||||
}
|
||||
|
||||
export function finalizeNote(draft: DraftNostrEvent) {
|
||||
let updated = draft;
|
||||
updated = replaceAtMentions(updated);
|
||||
updated = createHashtagTags(updated);
|
||||
return updated;
|
||||
}
|
3
src/helpers/nostr/reviews.ts
Normal file
3
src/helpers/nostr/reviews.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const REVIEW_KIND = 1985;
|
||||
export const RELAY_REVIEW_LABEL = "review/relay";
|
||||
export const RELAY_REVIEW_LABEL_NAMESPACE = "social.coracle.ontology";
|
@ -1,6 +1,12 @@
|
||||
export const mentionNpubOrNote = /(?:\s|^)(@|nostr:)?((npub1|note1)[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})(?:\s|$)/gi;
|
||||
export const matchImageUrls =
|
||||
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,12})((?:\/[\+~%\/\.\w\-_]*)?\.(?:svg|gif|png|jpg|jpeg|webp|avif))(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i;
|
||||
export const getMentionNpubOrNote = () =>
|
||||
/(?:\s|^)(@|nostr:)?((npub1|note1)[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})(?:\s|$)/gi;
|
||||
export const getMatchNostrLink = () =>
|
||||
/(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi;
|
||||
export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}]+)/gu;
|
||||
export const getMatchLink = () =>
|
||||
/https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/gu;
|
||||
|
||||
export const matchNostrLink = /(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/i;
|
||||
export const matchHashtag = /(^|[^\p{L}])#([\p{L}\p{N}]+)/iu;
|
||||
// read more https://www.regular-expressions.info/unicode.html#category
|
||||
export function stripInvisibleChar(str?: string) {
|
||||
return str && str.replaceAll(/[\p{Cf}\p{Zs}]/gu, "");
|
||||
}
|
||||
|
@ -6,13 +6,32 @@ export function countReplies(thread: ThreadItem): number {
|
||||
}
|
||||
|
||||
export type ThreadItem = {
|
||||
/** underlying nostr event */
|
||||
event: NostrEvent;
|
||||
/** the thread root, according to this event */
|
||||
root?: ThreadItem;
|
||||
/** the parent event this is replying to */
|
||||
reply?: ThreadItem;
|
||||
/** refs from nostr event */
|
||||
refs: EventReferences;
|
||||
/** direct child replies */
|
||||
replies: ThreadItem[];
|
||||
};
|
||||
|
||||
/** Returns an array of all pubkeys participating in the thread */
|
||||
export function getThreadMembers(item: ThreadItem, omit?: string) {
|
||||
const pubkeys = new Set<string>();
|
||||
|
||||
let i = item;
|
||||
while (true) {
|
||||
if (i.event.pubkey !== omit) pubkeys.add(i.event.pubkey);
|
||||
if (!i.reply) break;
|
||||
else i = i.reply;
|
||||
}
|
||||
|
||||
return Array.from(pubkeys);
|
||||
}
|
||||
|
||||
export function linkEvents(events: NostrEvent[]) {
|
||||
const idToChildren: Record<string, NostrEvent[]> = {};
|
||||
|
||||
|
@ -1,5 +1,20 @@
|
||||
export const convertToUrl = (url: string | URL) => (url instanceof URL ? url : new URL(url));
|
||||
|
||||
export const IMAGE_EXT = [".svg", ".gif", ".png", ".jpg", ".jpeg", ".webp", ".avif"];
|
||||
export const VIDEO_EXT = [".mp4", ".mkv", ".webm", ".mov"];
|
||||
|
||||
export function isMediaURL(url: string | URL) {
|
||||
return isImageURL(url) || isVideoURL(url);
|
||||
}
|
||||
export function isImageURL(url: string | URL) {
|
||||
const u = new URL(url);
|
||||
return IMAGE_EXT.some((ext) => u.pathname.endsWith(ext));
|
||||
}
|
||||
export function isVideoURL(url: string | URL) {
|
||||
const u = new URL(url);
|
||||
return VIDEO_EXT.some((ext) => u.pathname.endsWith(ext));
|
||||
}
|
||||
|
||||
export function normalizeRelayUrl(relayUrl: string) {
|
||||
const url = new URL(relayUrl);
|
||||
|
||||
|
@ -16,7 +16,7 @@ export default function useAppSettings() {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
},
|
||||
[settings]
|
||||
[settings],
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -8,7 +8,7 @@ export default function useEventReactions(eventId: string, additionalRelays: str
|
||||
|
||||
const subject = useMemo(
|
||||
() => eventReactionsService.requestReactions(eventId, relays, alwaysFetch),
|
||||
[eventId, relays.join("|"), alwaysFetch]
|
||||
[eventId, relays.join("|"), alwaysFetch],
|
||||
);
|
||||
|
||||
return useSubject(subject);
|
||||
|
@ -9,7 +9,7 @@ export default function useEventZaps(eventId: string, additionalRelays: string[]
|
||||
|
||||
const subject = useMemo(
|
||||
() => eventZapsService.requestZaps(eventId, relays, alwaysFetch),
|
||||
[eventId, relays.join("|"), alwaysFetch]
|
||||
[eventId, relays.join("|"), alwaysFetch],
|
||||
);
|
||||
|
||||
const events = useSubject(subject) || [];
|
||||
|
@ -12,7 +12,7 @@ export function usePaginatedList<T extends unknown>(list: T[], opts?: Options) {
|
||||
const previous = useCallback(() => setPage((v) => Math.max(v - 1, 0)), [setPage]);
|
||||
const pageItems = useMemo(
|
||||
() => list.slice(pageSize * currentPage, pageSize * currentPage + pageSize),
|
||||
[list, currentPage, pageSize]
|
||||
[list, currentPage, pageSize],
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -22,6 +22,6 @@ export function useTimelineCurserIntersectionCallback(timeline: TimelineLoader)
|
||||
}
|
||||
}
|
||||
},
|
||||
[timeline]
|
||||
[timeline],
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import useSubject from "./use-subject";
|
||||
export function useUserContacts(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
const observable = useMemo(
|
||||
() => userContactsService.requestContacts(pubkey, relays, alwaysRequest),
|
||||
[pubkey, relays.join("|"), alwaysRequest]
|
||||
[pubkey, relays.join("|"), alwaysRequest],
|
||||
);
|
||||
const contacts = useSubject(observable);
|
||||
|
||||
|
@ -7,7 +7,7 @@ export default function useUserLNURLMetadata(pubkey: string) {
|
||||
const address = userMetadata?.lud16 || userMetadata?.lud06;
|
||||
const { value: metadata } = useAsync(
|
||||
async () => (address ? lnurlMetadataService.requestMetadata(address) : undefined),
|
||||
[address]
|
||||
[address],
|
||||
);
|
||||
|
||||
return { metadata, address };
|
||||
|
@ -8,7 +8,7 @@ export function useUserMetadata(pubkey: string, additionalRelays: string[] = [],
|
||||
|
||||
const subject = useMemo(
|
||||
() => userMetadataService.requestMetadata(pubkey, relays, alwaysRequest),
|
||||
[pubkey, relays, alwaysRequest]
|
||||
[pubkey, relays, alwaysRequest],
|
||||
);
|
||||
const metadata = useSubject(subject);
|
||||
|
||||
|
@ -7,7 +7,7 @@ export function useUserRelays(pubkey: string, additionalRelays: string[] = [], a
|
||||
const relays = useReadRelayUrls(additionalRelays);
|
||||
const subject = useMemo(
|
||||
() => userRelaysService.requestRelays(pubkey, relays, alwaysRequest),
|
||||
[pubkey, relays.join("|"), alwaysRequest]
|
||||
[pubkey, relays.join("|"), alwaysRequest],
|
||||
);
|
||||
const userRelays = useSubject(subject);
|
||||
|
||||
|
@ -23,5 +23,5 @@ const root = createRoot(element);
|
||||
root.render(
|
||||
<GlobalProviders>
|
||||
<App />
|
||||
</GlobalProviders>
|
||||
</GlobalProviders>,
|
||||
);
|
||||
|
@ -38,7 +38,7 @@ const mediaMapper = (item: ImageObject[] | VideoObject[]) => ({
|
||||
|
||||
const mediaSorter = (
|
||||
a: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject,
|
||||
b: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject
|
||||
b: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject,
|
||||
) => {
|
||||
if (!(a.url && b.url)) {
|
||||
return 0;
|
||||
@ -114,7 +114,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
|
||||
ogObject.ogImageProperty,
|
||||
ogObject.ogImageWidth,
|
||||
ogObject.ogImageHeight,
|
||||
ogObject.ogImageType
|
||||
ogObject.ogImageType,
|
||||
)
|
||||
.map(mediaMapper)
|
||||
.filter((value: ImageObject) => value.url !== undefined && value.url !== "")
|
||||
@ -134,7 +134,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
|
||||
ogObject.ogVideoProperty,
|
||||
ogObject.ogVideoWidth,
|
||||
ogObject.ogVideoHeight,
|
||||
ogObject.ogVideoType
|
||||
ogObject.ogVideoType,
|
||||
)
|
||||
.map(mediaMapper)
|
||||
.filter((value: VideoObject) => value.url !== undefined && value.url !== "")
|
||||
@ -164,7 +164,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
|
||||
ogObject.twitterImageProperty,
|
||||
ogObject.twitterImageWidth,
|
||||
ogObject.twitterImageHeight,
|
||||
ogObject.twitterImageAlt
|
||||
ogObject.twitterImageAlt,
|
||||
)
|
||||
.map(mediaMapperTwitterImage)
|
||||
.filter((value: TwitterImageObject) => value.url !== undefined && value.url !== "")
|
||||
@ -189,7 +189,7 @@ export function mediaSetup(ogObject: OgObjectInteral) {
|
||||
ogObject.twitterPlayerProperty,
|
||||
ogObject.twitterPlayerWidth,
|
||||
ogObject.twitterPlayerHeight,
|
||||
ogObject.twitterPlayerStream
|
||||
ogObject.twitterPlayerStream,
|
||||
)
|
||||
.map(mediaMapperTwitterPlayer)
|
||||
.filter((value: TwitterPlayerObject) => value.url !== undefined && value.url !== "")
|
||||
|
@ -83,7 +83,7 @@ export class QrCode {
|
||||
minVersion: int = 1,
|
||||
maxVersion: int = 40,
|
||||
mask: int = -1,
|
||||
boostEcl: boolean = true
|
||||
boostEcl: boolean = true,
|
||||
): QrCode {
|
||||
if (
|
||||
!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) ||
|
||||
@ -175,7 +175,7 @@ export class QrCode {
|
||||
|
||||
dataCodewords: Readonly<Array<byte>>,
|
||||
|
||||
msk: int
|
||||
msk: int,
|
||||
) {
|
||||
// Check scalar arguments
|
||||
if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
|
||||
@ -822,7 +822,7 @@ export class QrSegment {
|
||||
public readonly numChars: int,
|
||||
|
||||
// The data bits of this segment. Accessed through getData().
|
||||
private readonly bitData: Array<bit>
|
||||
private readonly bitData: Array<bit>,
|
||||
) {
|
||||
if (numChars < 0) throw new RangeError("Invalid argument");
|
||||
this.bitData = bitData.slice(); // Make defensive copy
|
||||
@ -897,7 +897,7 @@ export class Ecc {
|
||||
// In the range 0 to 3 (unsigned 2-bit integer).
|
||||
public readonly ordinal: int,
|
||||
// (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
|
||||
public readonly formatBits: int
|
||||
public readonly formatBits: int,
|
||||
) {}
|
||||
}
|
||||
// }
|
||||
@ -925,7 +925,7 @@ export class Mode {
|
||||
// The mode indicator bits, which is a uint4 value (range 0 to 15).
|
||||
public readonly modeBits: int,
|
||||
// Number of character count bits for three different version ranges.
|
||||
private readonly numBitsCharCount: [int, int, int]
|
||||
private readonly numBitsCharCount: [int, int, int],
|
||||
) {}
|
||||
|
||||
/*-- Method --*/
|
||||
|
@ -19,7 +19,7 @@ const IntersectionObserverContext = createContext<{
|
||||
export type ExtendedIntersectionObserverEntry<T> = { entry: IntersectionObserverEntry; id: T | undefined };
|
||||
export type ExtendedIntersectionObserverCallback<T> = (
|
||||
entries: ExtendedIntersectionObserverEntry<T>[],
|
||||
observer: IntersectionObserver
|
||||
observer: IntersectionObserver,
|
||||
) => void;
|
||||
|
||||
export function useIntersectionObserver() {
|
||||
@ -42,7 +42,7 @@ export function useRegisterIntersectionEntity<T>(ref: MutableRefObject<Element |
|
||||
|
||||
export function useIntersectionMapCallback<T>(
|
||||
callback: (map: Map<T, IntersectionObserverEntry>) => void,
|
||||
watch: DependencyList
|
||||
watch: DependencyList,
|
||||
) {
|
||||
const map = useMemo(() => new Map<T, IntersectionObserverEntry>(), []);
|
||||
return useCallback<ExtendedIntersectionObserverCallback<T>>(
|
||||
@ -53,7 +53,7 @@ export function useIntersectionMapCallback<T>(
|
||||
|
||||
callback(map);
|
||||
},
|
||||
[callback, ...watch]
|
||||
[callback, ...watch],
|
||||
);
|
||||
}
|
||||
|
||||
@ -76,11 +76,11 @@ export default function IntersectionObserverProvider<T = undefined>({
|
||||
entries.map((entry) => {
|
||||
return { entry, id: elementIds.get(entry.target) };
|
||||
}),
|
||||
observer
|
||||
observer,
|
||||
);
|
||||
}, []);
|
||||
const [observer, setObserver] = useState<IntersectionObserver>(
|
||||
() => new IntersectionObserver(handleIntersection, { rootMargin, threshold })
|
||||
() => new IntersectionObserver(handleIntersection, { rootMargin, threshold }),
|
||||
);
|
||||
|
||||
useMount(() => {
|
||||
@ -97,7 +97,7 @@ export default function IntersectionObserverProvider<T = undefined>({
|
||||
(element: Element, id: T) => {
|
||||
elementIds.set(element, id);
|
||||
},
|
||||
[elementIds]
|
||||
[elementIds],
|
||||
);
|
||||
|
||||
const context = useMemo(
|
||||
@ -105,7 +105,7 @@ export default function IntersectionObserverProvider<T = undefined>({
|
||||
observer,
|
||||
setElementId,
|
||||
}),
|
||||
[observer, setElementId]
|
||||
[observer, setElementId],
|
||||
);
|
||||
|
||||
return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>;
|
||||
|
@ -20,7 +20,7 @@ export default function PostModalProvider({ children }: PropsWithChildren) {
|
||||
setDraft(draft);
|
||||
onOpen();
|
||||
},
|
||||
[setDraft, onOpen]
|
||||
[setDraft, onOpen],
|
||||
);
|
||||
const context = useMemo(() => ({ openModal }), [openModal]);
|
||||
|
||||
|
@ -40,7 +40,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
},
|
||||
[toast, current]
|
||||
[toast, current],
|
||||
);
|
||||
const requestDecrypt = useCallback(
|
||||
async (data: string, pubkey: string) => {
|
||||
@ -51,7 +51,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
},
|
||||
[toast, current]
|
||||
[toast, current],
|
||||
);
|
||||
const requestEncrypt = useCallback(
|
||||
async (data: string, pubkey: string) => {
|
||||
@ -62,12 +62,12 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
},
|
||||
[toast, current]
|
||||
[toast, current],
|
||||
);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({ requestSignature, requestDecrypt, requestEncrypt }),
|
||||
[requestSignature, requestDecrypt, requestEncrypt]
|
||||
[requestSignature, requestDecrypt, requestEncrypt],
|
||||
);
|
||||
|
||||
return <SigningContext.Provider value={context}>{children}</SigningContext.Provider>;
|
||||
|
@ -1,11 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import { nostrPostAction } from "../classes/nostr-post-action";
|
||||
import { PersistentSubject, Subject } from "../classes/subject";
|
||||
import { DraftNostrEvent, PTag } from "../types/nostr-event";
|
||||
import clientRelaysService from "./client-relays";
|
||||
import accountService from "./account";
|
||||
import userContactsService, { UserContacts } from "./user-contacts";
|
||||
import signingService from "./signing";
|
||||
import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
|
||||
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
@ -21,7 +21,7 @@ function handleNewContacts(contacts: UserContacts | undefined) {
|
||||
const relay = contacts.contactRelay[key];
|
||||
if (relay) return ["p", key, relay];
|
||||
else return ["p", key];
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// reset the pending list since we just got a new contacts list
|
||||
@ -79,8 +79,8 @@ async function savePending() {
|
||||
if (!current) throw new Error("no account");
|
||||
const event = await signingService.requestSignature(draft, current);
|
||||
|
||||
const results = nostrPostAction(clientRelaysService.getWriteUrls(), event);
|
||||
await results.onComplete;
|
||||
const pub = new NostrPublishAction("Update Following", clientRelaysService.getWriteUrls(), event);
|
||||
await pub.onComplete;
|
||||
|
||||
savingDraft.next(false);
|
||||
|
||||
@ -98,7 +98,7 @@ function addContact(pubkey: string, relay?: string) {
|
||||
return newTag;
|
||||
}
|
||||
return t;
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
following.next([...pTags, newTag]);
|
||||
|
@ -1,5 +1,4 @@
|
||||
import dayjs from "dayjs";
|
||||
import { nostrPostAction } from "../classes/nostr-post-action";
|
||||
import { unique } from "../helpers/array";
|
||||
import { DraftNostrEvent, RTag } from "../types/nostr-event";
|
||||
import accountService from "./account";
|
||||
@ -8,6 +7,7 @@ import userRelaysService, { ParsedUserRelays } from "./user-relays";
|
||||
import { Connection, PersistentSubject, Subject } from "../classes/subject";
|
||||
import signingService from "./signing";
|
||||
import { logger } from "../helpers/debug";
|
||||
import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
|
||||
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
@ -21,7 +21,7 @@ const DEFAULT_RELAYS = [
|
||||
|
||||
const userRelaysToRelayConfig: Connection<ParsedUserRelays, RelayConfig[], RelayConfig[] | undefined> = (
|
||||
userRelays,
|
||||
next
|
||||
next,
|
||||
) => next(userRelays.relays);
|
||||
|
||||
class ClientRelayService {
|
||||
@ -125,12 +125,12 @@ class ClientRelayService {
|
||||
if (!current) throw new Error("no account");
|
||||
const event = await signingService.requestSignature(draft, current);
|
||||
|
||||
const results = nostrPostAction(writeUrls, event);
|
||||
const pub = new NostrPublishAction("Update Relays", writeUrls, event);
|
||||
|
||||
// pass new event to the user relay service
|
||||
userRelaysService.receiveEvent(event);
|
||||
|
||||
await results.onComplete;
|
||||
await pub.onComplete;
|
||||
}
|
||||
|
||||
getWriteUrls() {
|
||||
|
@ -23,14 +23,14 @@ class DirectMessagesService {
|
||||
this.incomingSub = new NostrMultiSubscription(
|
||||
clientRelaysService.getReadUrls(),
|
||||
undefined,
|
||||
"incoming-direct-messages"
|
||||
"incoming-direct-messages",
|
||||
);
|
||||
this.incomingSub.onEvent.subscribe(this.receiveEvent, this);
|
||||
|
||||
this.outgoingSub = new NostrMultiSubscription(
|
||||
clientRelaysService.getReadUrls(),
|
||||
undefined,
|
||||
"outgoing-direct-messages"
|
||||
"outgoing-direct-messages",
|
||||
);
|
||||
this.outgoingSub.onEvent.subscribe(this.receiveEvent, this);
|
||||
|
||||
|
@ -2,7 +2,7 @@ import dayjs from "dayjs";
|
||||
import db from "./db";
|
||||
import { fetchWithCorsFallback } from "../helpers/cors";
|
||||
|
||||
function parseAddress(address: string): { name?: string; domain?: string } {
|
||||
export function parseAddress(address: string): { name?: string; domain?: string } {
|
||||
const parts = address.trim().toLowerCase().split("@");
|
||||
return { name: parts[0], domain: parts[1] };
|
||||
}
|
||||
@ -28,7 +28,7 @@ function getIdentityFromJson(name: string, domain: string, json: IdentityJson):
|
||||
|
||||
async function fetchAllIdentities(domain: string) {
|
||||
const json = await fetchWithCorsFallback(`//${domain}/.well-known/nostr.json`).then(
|
||||
(res) => res.json() as Promise<IdentityJson>
|
||||
(res) => res.json() as Promise<IdentityJson>,
|
||||
);
|
||||
|
||||
await addToCache(domain, json);
|
||||
@ -101,7 +101,7 @@ async function pruneCache() {
|
||||
const keys = await db.getAllKeysFromIndex(
|
||||
"dnsIdentifiers",
|
||||
"updated",
|
||||
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix())
|
||||
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix()),
|
||||
);
|
||||
|
||||
for (const pubkey of keys) {
|
||||
|
@ -86,7 +86,7 @@ class PubkeyRelayAssignmentService {
|
||||
if (userRelays.length === 0) userRelays = Array.from(readRelays);
|
||||
|
||||
const rankedOptions = Array.from(userRelays).sort(
|
||||
(a, b) => (relayScores.get(b) ?? 0) - (relayScores.get(a) ?? 0)
|
||||
(a, b) => (relayScores.get(b) ?? 0) - (relayScores.get(a) ?? 0),
|
||||
);
|
||||
|
||||
assignments[pubkey] = rankedOptions.slice(0, 3);
|
||||
|
15
src/services/publish-log.ts
Normal file
15
src/services/publish-log.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type NostrPublishAction from "../classes/nostr-publish-action";
|
||||
import { PersistentSubject } from "../classes/subject";
|
||||
|
||||
export function addToLog(pub: NostrPublishAction) {
|
||||
publishLog.next([...publishLog.value, pub]);
|
||||
}
|
||||
|
||||
export function pruneLog() {}
|
||||
|
||||
export const publishLog = new PersistentSubject<NostrPublishAction[]>([]);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
//@ts-ignore
|
||||
window.publishLog = publishLog;
|
||||
}
|
@ -10,6 +10,7 @@ export type RelayInformationDocument = {
|
||||
supported_nips?: number[];
|
||||
software: string;
|
||||
version: string;
|
||||
payments_url?: string;
|
||||
};
|
||||
|
||||
async function fetchInfo(relay: string) {
|
||||
@ -17,7 +18,7 @@ async function fetchInfo(relay: string) {
|
||||
url.protocol = url.protocol === "ws:" ? "http" : "https";
|
||||
|
||||
const infoDoc = await fetchWithCorsFallback(url, { headers: { Accept: "application/nostr+json" } }).then(
|
||||
(res) => res.json() as Promise<RelayInformationDocument>
|
||||
(res) => res.json() as Promise<RelayInformationDocument>,
|
||||
);
|
||||
|
||||
memoryCache.set(relay, infoDoc);
|
||||
|
@ -115,7 +115,7 @@ class ReplaceableEventRelayLoader {
|
||||
`Updating query`,
|
||||
Array.from(Object.keys(filters))
|
||||
.map((kind: string) => `kind ${kind}: ${filters[parseInt(kind)].authors?.length}`)
|
||||
.join(", ")
|
||||
.join(", "),
|
||||
);
|
||||
this.subscription.setQuery(query);
|
||||
|
||||
@ -133,7 +133,7 @@ class ReplaceableEventLoaderService {
|
||||
private events = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||
|
||||
private loaders = new SuperMap<Relay, ReplaceableEventRelayLoader>(
|
||||
(relay) => new ReplaceableEventRelayLoader(relay, this.log.extend(relay))
|
||||
(relay) => new ReplaceableEventRelayLoader(relay, this.log.extend(relay)),
|
||||
);
|
||||
|
||||
log = logger.extend("ReplaceableEventLoader");
|
||||
@ -180,7 +180,7 @@ class ReplaceableEventLoaderService {
|
||||
const keys = await db.getAllKeysFromIndex(
|
||||
"replaceableEvents",
|
||||
"created",
|
||||
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix())
|
||||
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix()),
|
||||
);
|
||||
|
||||
this.log(`Pruning ${keys.length} events`);
|
||||
@ -243,9 +243,12 @@ replaceableEventLoaderService.pruneCache();
|
||||
setInterval(() => {
|
||||
replaceableEventLoaderService.update();
|
||||
}, 1000 * 2);
|
||||
setInterval(() => {
|
||||
replaceableEventLoaderService.pruneCache();
|
||||
}, 1000 * 60 * 60);
|
||||
setInterval(
|
||||
() => {
|
||||
replaceableEventLoaderService.pruneCache();
|
||||
},
|
||||
1000 * 60 * 60,
|
||||
);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
//@ts-ignore
|
||||
|
@ -3,9 +3,9 @@ import accountService from "../account";
|
||||
import userAppSettings from "./user-app-settings";
|
||||
import clientRelaysService from "../client-relays";
|
||||
import signingService from "../signing";
|
||||
import { nostrPostAction } from "../../classes/nostr-post-action";
|
||||
import { AppSettings, defaultSettings } from "./migrations";
|
||||
import { logger } from "../../helpers/debug";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
|
||||
const log = logger.extend("AppSettings");
|
||||
|
||||
@ -25,7 +25,8 @@ export async function replaceSettings(newSettings: AppSettings) {
|
||||
const draft = userAppSettings.buildAppSettingsEvent(newSettings);
|
||||
const event = await signingService.requestSignature(draft, account);
|
||||
userAppSettings.receiveEvent(event);
|
||||
await nostrPostAction(clientRelaysService.getWriteUrls(), event).onComplete;
|
||||
const pub = new NostrPublishAction("Update Settings", clientRelaysService.getWriteUrls(), event);
|
||||
await pub.onComplete;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,7 +42,7 @@ export const defaultSettings: AppSettings = {
|
||||
|
||||
primaryColor: "#8DB600",
|
||||
imageProxy: "",
|
||||
corsProxy: "",
|
||||
corsProxy: "https://corsproxy.io/?<encoded_url>",
|
||||
showContentWarning: true,
|
||||
twitterRedirect: undefined,
|
||||
redditRedirect: undefined,
|
||||
|
@ -11,7 +11,7 @@ const DTAG = "nostrudel-settings";
|
||||
|
||||
class UserAppSettings {
|
||||
private parsedSubjects = new SuperMap<string, PersistentSubject<AppSettings>>(
|
||||
() => new PersistentSubject<AppSettings>(defaultSettings)
|
||||
() => new PersistentSubject<AppSettings>(defaultSettings),
|
||||
);
|
||||
getSubject(pubkey: string) {
|
||||
return this.parsedSubjects.get(pubkey);
|
||||
|
@ -19,7 +19,7 @@ class SigningService {
|
||||
|
||||
private async getKeyMaterial() {
|
||||
const password = window.prompt(
|
||||
"Enter local encryption password. This password is used to keep your secret key save."
|
||||
"Enter local encryption password. This password is used to keep your secret key save.",
|
||||
);
|
||||
if (!password) throw new Error("Password required");
|
||||
const enc = new TextEncoder();
|
||||
@ -38,7 +38,7 @@ class SigningService {
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -31,12 +31,15 @@ function parseContacts(event: NostrEvent): UserContacts {
|
||||
const relays = normalizeRelayConfigs(relayJsonToRelayConfig(relayJson));
|
||||
|
||||
const pubkeys = event.tags.filter(isPTag).map((tag) => tag[1]);
|
||||
const contactRelay = event.tags.filter(isPTag).reduce((dir, tag) => {
|
||||
if (tag[2]) {
|
||||
dir[tag[1]] = tag[2];
|
||||
}
|
||||
return dir;
|
||||
}, {} as Record<string, string>);
|
||||
const contactRelay = event.tags.filter(isPTag).reduce(
|
||||
(dir, tag) => {
|
||||
if (tag[2]) {
|
||||
dir[tag[1]] = tag[2];
|
||||
}
|
||||
return dir;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
@ -60,7 +63,7 @@ class UserContactsService {
|
||||
Kind.Contacts,
|
||||
pubkey,
|
||||
undefined,
|
||||
alwaysRequest
|
||||
alwaysRequest,
|
||||
);
|
||||
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseContacts(event)));
|
||||
|
@ -32,7 +32,7 @@ class UserMetadataService {
|
||||
Kind.Metadata,
|
||||
pubkey,
|
||||
undefined,
|
||||
alwaysRequest
|
||||
alwaysRequest,
|
||||
);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseKind0Event(event)));
|
||||
return sub;
|
||||
|
@ -34,7 +34,7 @@ class UserRelaysService {
|
||||
Kind.RelayList,
|
||||
pubkey,
|
||||
undefined,
|
||||
alwaysRequest
|
||||
alwaysRequest,
|
||||
);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseRelaysEvent(event)));
|
||||
|
||||
|
@ -63,7 +63,7 @@ class UserTrustedStatsService {
|
||||
async fetchUserStats(pubkey: string) {
|
||||
try {
|
||||
const stats = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`).then(
|
||||
(res) => res.json() as Promise<{ stats: Record<string, NostrBandProfileStats> }>
|
||||
(res) => res.json() as Promise<{ stats: Record<string, NostrBandProfileStats> }>,
|
||||
);
|
||||
|
||||
if (stats?.stats[pubkey]) {
|
||||
|
@ -1,8 +1,9 @@
|
||||
export type ETag = ["e", string] | ["e", string, string] | ["e", string, string, string];
|
||||
export type ATag = ["a", string] | ["a", string, string];
|
||||
export type PTag = ["p", string] | ["p", string, string] | ["p", string, string, string];
|
||||
export type RTag = ["r", string] | ["r", string, string];
|
||||
export type DTag = ["d"] | ["d", string];
|
||||
export type Tag = string[] | ETag | PTag | RTag | DTag;
|
||||
export type Tag = string[] | ETag | PTag | RTag | DTag | ATag;
|
||||
|
||||
export type NostrEvent = {
|
||||
id: string;
|
||||
@ -34,3 +35,6 @@ export function isRTag(tag: Tag): tag is RTag {
|
||||
export function isDTag(tag: Tag): tag is DTag {
|
||||
return tag[0] === "d";
|
||||
}
|
||||
export function isATag(tag: Tag): tag is ATag {
|
||||
return tag[0] === "a" && tag[1] !== undefined;
|
||||
}
|
||||
|
@ -57,13 +57,13 @@ function HashTagPage() {
|
||||
if (!showReplies && isReply(event)) return false;
|
||||
return timelinePageEventFilter(event);
|
||||
},
|
||||
[showReplies]
|
||||
[showReplies],
|
||||
);
|
||||
const timeline = useTimelineLoader(
|
||||
`${hashtag}-hashtag`,
|
||||
readRelays,
|
||||
{ kinds: [1], "#t": [hashtag] },
|
||||
{ eventFilter }
|
||||
{ eventFilter },
|
||||
);
|
||||
|
||||
useRelaysChanged(readRelays, () => timeline.reset());
|
||||
@ -100,7 +100,7 @@ function HashTagPage() {
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return <TimelinePage timeline={timeline} header={header} />;
|
||||
return <TimelinePage timeline={timeline} header={header} pt="4" pb="8" />;
|
||||
}
|
||||
|
||||
export default function HashTagView() {
|
||||
|
@ -29,7 +29,7 @@ function FollowingTabBody() {
|
||||
if (!showReplies && isReply(event)) return false;
|
||||
return timelinePageEventFilter(event);
|
||||
},
|
||||
[showReplies, timelinePageEventFilter]
|
||||
[showReplies, timelinePageEventFilter],
|
||||
);
|
||||
|
||||
const following = contacts?.contacts || [];
|
||||
@ -37,7 +37,7 @@ function FollowingTabBody() {
|
||||
`${truncatedId(account.pubkey)}-following`,
|
||||
readRelays,
|
||||
{ authors: following, kinds: [Kind.Text, Kind.Repost, 2] },
|
||||
{ enabled: following.length > 0, eventFilter }
|
||||
{ enabled: following.length > 0, eventFilter },
|
||||
);
|
||||
|
||||
const header = (
|
||||
@ -52,7 +52,7 @@ function FollowingTabBody() {
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return <TimelinePage timeline={timeline} header={header} />;
|
||||
return <TimelinePage timeline={timeline} header={header} pt="4" pb="8" />;
|
||||
}
|
||||
|
||||
export default function FollowingTab() {
|
||||
|
@ -24,7 +24,7 @@ function GlobalPage() {
|
||||
if (!showReplies && isReply(event)) return false;
|
||||
return timelineEventFilter(event);
|
||||
},
|
||||
[showReplies, timelineEventFilter]
|
||||
[showReplies, timelineEventFilter],
|
||||
);
|
||||
const timeline = useTimelineLoader(`global`, readRelays, { kinds: [1] }, { eventFilter });
|
||||
useRelaysChanged(readRelays, () => timeline.reset());
|
||||
@ -42,7 +42,7 @@ function GlobalPage() {
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return <TimelinePage timeline={timeline} header={header} />;
|
||||
return <TimelinePage timeline={timeline} header={header} pt="4" pb="8" />;
|
||||
}
|
||||
|
||||
export default function GlobalTab() {
|
||||
|
@ -51,7 +51,7 @@ export default function LoginNip05View() {
|
||||
setLoading(false);
|
||||
},
|
||||
1000,
|
||||
[nip05, setPubkey, setRelays, setLoading]
|
||||
[nip05, setPubkey, setRelays, setLoading],
|
||||
);
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLDivElement> = (e) => {
|
||||
|
@ -62,7 +62,7 @@ export default function LoginNsecView() {
|
||||
setError(true);
|
||||
}
|
||||
},
|
||||
[setInputValue, setHexKey, setNpub, setError]
|
||||
[setInputValue, setHexKey, setNpub, setError],
|
||||
);
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => {
|
||||
|
@ -104,7 +104,7 @@ export default function MapView() {
|
||||
const hash = ngeohash.encode(center.lat, center.lng, 5);
|
||||
|
||||
setSearchParams({ hash }, { replace: true });
|
||||
}, 1000)
|
||||
}, 1000),
|
||||
);
|
||||
|
||||
setMap(map);
|
||||
@ -122,7 +122,7 @@ export default function MapView() {
|
||||
"geo-events",
|
||||
readRelays,
|
||||
{ "#g": cells, kinds: [Kind.Text] },
|
||||
{ enabled: cells.length > 0 }
|
||||
{ enabled: cells.length > 0 },
|
||||
);
|
||||
|
||||
const setCellsFromMap = useCallback(() => {
|
||||
@ -133,7 +133,7 @@ export default function MapView() {
|
||||
bbox.getWest(),
|
||||
bbox.getNorth(),
|
||||
bbox.getEast(),
|
||||
getPrecision(map.getZoom())
|
||||
getPrecision(map.getZoom()),
|
||||
);
|
||||
|
||||
setCells(hashes);
|
||||
|
@ -3,7 +3,7 @@ import { Button, Card, CardBody, Flex, IconButton, Textarea } from "@chakra-ui/r
|
||||
import dayjs from "dayjs";
|
||||
import { Kind } from "nostr-tools";
|
||||
import { Link, Navigate, useParams } from "react-router-dom";
|
||||
import { nostrPostAction } from "../../classes/nostr-post-action";
|
||||
|
||||
import { ArrowLeftSIcon } from "../../components/icons";
|
||||
import { UserAvatar } from "../../components/user-avatar";
|
||||
import { UserLink } from "../../components/user-link";
|
||||
@ -21,6 +21,7 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
|
||||
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const account = useCurrentAccount()!;
|
||||
@ -57,7 +58,7 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const signed = await requestSignature(event);
|
||||
if (!signed) return;
|
||||
const writeRelays = clientRelaysService.getWriteUrls();
|
||||
nostrPostAction(writeRelays, signed);
|
||||
const pub = new NostrPublishAction("Send DM", writeRelays, signed);
|
||||
setContent("");
|
||||
};
|
||||
|
||||
|
90
src/views/note/components/reply-form.tsx
Normal file
90
src/views/note/components/reply-form.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Button, ButtonGroup, Flex, Textarea, useDisclosure, useToast } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Kind } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { UserAvatarStack } from "../../../components/user-avatar-stack";
|
||||
import { ThreadItem, getThreadMembers } from "../../../helpers/thread";
|
||||
import { NoteContents } from "../../../components/note/note-contents";
|
||||
import { addReplyTags, ensureNotifyUsers, finalizeNote } from "../../../helpers/nostr/post";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
|
||||
function NoteContentPreview({ content }: { content: string }) {
|
||||
const draft = useMemo(
|
||||
() => finalizeNote({ kind: Kind.Text, content, created_at: dayjs().unix(), tags: [] }),
|
||||
[content],
|
||||
);
|
||||
|
||||
return <NoteContents event={draft} />;
|
||||
}
|
||||
|
||||
export type ReplyFormProps = {
|
||||
item: ThreadItem;
|
||||
onCancel: () => void;
|
||||
onSubmitted?: (event: NostrEvent) => void;
|
||||
};
|
||||
|
||||
export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProps) {
|
||||
const toast = useToast();
|
||||
const account = useCurrentAccount();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const writeRelays = useWriteRelayUrls();
|
||||
|
||||
const threadMembers = useMemo(() => getThreadMembers(item, account?.pubkey), [item, account?.pubkey]);
|
||||
const { register, getValues, watch, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
content: "",
|
||||
},
|
||||
});
|
||||
|
||||
const showPreview = useDisclosure();
|
||||
|
||||
watch("content");
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
let draft = finalizeNote({ kind: Kind.Text, content: values.content, created_at: dayjs().unix(), tags: [] });
|
||||
draft = addReplyTags(draft, item.event);
|
||||
draft = ensureNotifyUsers(draft, threadMembers);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
if (!signed) return;
|
||||
// TODO: write to other users inbox relays
|
||||
const pub = new NostrPublishAction("Reply", writeRelays, signed);
|
||||
await pub.onComplete;
|
||||
|
||||
if (onSubmitted) onSubmitted(signed);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{showPreview.isOpen ? (
|
||||
<Box p="2" borderWidth={1} borderRadius="md" mb="2">
|
||||
<NoteContentPreview content={getValues().content} />
|
||||
</Box>
|
||||
) : (
|
||||
<Textarea placeholder="Reply" {...register("content")} autoFocus mb="2" rows={5} required />
|
||||
)}
|
||||
<Flex gap="2" alignItems="center">
|
||||
<ButtonGroup size="sm">
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit">Submit</Button>
|
||||
</ButtonGroup>
|
||||
<UserAvatarStack label="Notify" users={threadMembers} />
|
||||
{getValues().content.length > 0 && (
|
||||
<Button size="sm" ml="auto" onClick={showPreview.onToggle}>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
}
|
53
src/views/note/components/thread-post.tsx
Normal file
53
src/views/note/components/thread-post.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Button, ButtonGroup, Flex, useDisclosure } from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon, ReplyIcon } from "../../../components/icons";
|
||||
import { Note } from "../../../components/note";
|
||||
import { countReplies, ThreadItem } from "../../../helpers/thread";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import ReplyForm from "./reply-form";
|
||||
|
||||
export type ThreadItemProps = {
|
||||
post: ThreadItem;
|
||||
initShowReplies?: boolean;
|
||||
focusId?: string;
|
||||
};
|
||||
|
||||
export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps) => {
|
||||
const [showReplies, setShowReplies] = useState(initShowReplies ?? post.replies.length === 1);
|
||||
const toggle = () => setShowReplies((v) => !v);
|
||||
const showReplyForm = useDisclosure();
|
||||
|
||||
const numberOfReplies = countReplies(post);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
<TrustProvider trust={focusId === post.event.id ? true : undefined}>
|
||||
<Note event={post.event} variant={focusId === post.event.id ? "filled" : "outline"} />
|
||||
</TrustProvider>
|
||||
{showReplyForm.isOpen && (
|
||||
<ReplyForm item={post} onCancel={showReplyForm.onClose} onSubmitted={showReplyForm.onClose} />
|
||||
)}
|
||||
<ButtonGroup variant="link" size="sm" alignSelf="flex-start">
|
||||
{!showReplyForm.isOpen && (
|
||||
<Button onClick={showReplyForm.onOpen} leftIcon={<ReplyIcon />}>
|
||||
Write relay
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{post.replies.length > 0 && (
|
||||
<Button onClick={toggle}>
|
||||
{numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"}
|
||||
{showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
{post.replies.length > 0 && showReplies && (
|
||||
<Flex direction="column" gap="2" pl={[2, 2, 4]} borderLeftColor="gray.500" borderLeftWidth="1px">
|
||||
{post.replies.map((child) => (
|
||||
<ThreadPost key={child.event.id} post={child} focusId={focusId} />
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
@ -4,7 +4,7 @@ import { useParams } from "react-router-dom";
|
||||
import { Note } from "../../components/note";
|
||||
import { isHex } from "../../helpers/nip19";
|
||||
import { useThreadLoader } from "../../hooks/use-thread-loader";
|
||||
import { ThreadPost } from "./thread-post";
|
||||
import { ThreadPost } from "./components/thread-post";
|
||||
|
||||
function useNotePointer() {
|
||||
const { id } = useParams() as { id: string };
|
||||
@ -61,7 +61,7 @@ export default function NoteView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="4" flex={1} pb="4" pt="4" pl="1" pr="1">
|
||||
<Flex direction="column" gap="4" flex={1} pb="12" pt="4" pl="1" pr="1">
|
||||
{pageContent}
|
||||
</Flex>
|
||||
);
|
||||
|
@ -1,42 +0,0 @@
|
||||
import { Button, Flex, useDisclosure } from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon } from "../../components/icons";
|
||||
import { Note } from "../../components/note";
|
||||
import { countReplies, ThreadItem as ThreadItemData } from "../../helpers/thread";
|
||||
import { TrustProvider } from "../../providers/trust";
|
||||
|
||||
export type ThreadItemProps = {
|
||||
post: ThreadItemData;
|
||||
initShowReplies?: boolean;
|
||||
focusId?: string;
|
||||
};
|
||||
|
||||
export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps) => {
|
||||
const [showReplies, setShowReplies] = useState(initShowReplies ?? post.replies.length === 1);
|
||||
const toggle = () => setShowReplies((v) => !v);
|
||||
|
||||
const numberOfReplies = countReplies(post);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
<TrustProvider trust={focusId === post.event.id ? true : undefined}>
|
||||
<Note event={post.event} variant={focusId === post.event.id ? "filled" : "outline"} />
|
||||
</TrustProvider>
|
||||
{post.replies.length > 0 && (
|
||||
<>
|
||||
<Button variant="link" size="sm" alignSelf="flex-start" onClick={toggle}>
|
||||
{numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"}
|
||||
{showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />}
|
||||
</Button>
|
||||
{showReplies && (
|
||||
<Flex direction="column" gap="2" pl={[2, 2, 4]} borderLeftColor="gray.500" borderLeftWidth="1px">
|
||||
{post.replies.map((child) => (
|
||||
<ThreadPost key={child.event.id} post={child} focusId={focusId} />
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
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