Merge branch 'next'

This commit is contained in:
hzrd149 2023-08-23 07:50:11 -05:00
commit 9ef589bcd4
146 changed files with 3068 additions and 1845 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show notes in relay view

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix non-english characters breaking links

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Improve layout of image galleries

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show all images in lightbox

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild event publish details

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Render multiple images as image gallery

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add relay review form

View File

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

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Clean up navigation menu

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add mobile friendly lightbox

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show label for paid relays

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add inline reply form

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add <url> and <encoded_url> options to CORS proxy url

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Use corsproxy.io as default service for CORS proxy

View File

@ -1,6 +1,6 @@
# noStrudel
> NOTE: This client is still in development and is buggy
> NOTE: This client is still in development and will have bugs
## noStrudel is my personal nostr client.
@ -10,7 +10,7 @@ There are many features missing from this client and I wont get around to implem
Live Instance: [nostrudel.ninja](https://nostrudel.ninja)
You can find better clients with more features in the [awesome-nostr](https://github.com/aljazceru/awesome-nostr) repo.
You can find better clients with more features on [nostrapps.com](https://www.nostrapps.com/) or in the [awesome-nostr](https://github.com/aljazceru/awesome-nostr) repo.
## Please don't trust my app with your nsec
@ -30,7 +30,7 @@ docker run --rm -p 8080:80 ghcr.io/hzrd149/nostrudel
git clone git@github.com:hzrd149/nostrudel.git
cd nostrudel
yarn install
yarn start
yarn dev
```
## Contributing

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,22 +5,23 @@
"license": "MIT",
"scripts": {
"start": "vite serve",
"dev": "vite serve",
"build": "tsc --project tsconfig.json && vite build",
"format": "prettier --ignore-path .prettierignore -w .",
"e2e": "cypress open",
"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",
@ -28,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",
@ -47,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"
}
}

View File

@ -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";
@ -116,10 +115,7 @@ const router = createHashRouter([
{ path: "profile", element: <ProfileView /> },
{
path: "tools",
children: [
{ path: "", element: <ToolsHomeView /> },
{ path: "nip19", element: <Nip19ToolsView /> },
],
children: [{ path: "", element: <ToolsHomeView /> }],
},
{
path: "streams",

View File

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

View File

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

View File

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

View File

@ -8,20 +8,22 @@ interface ConnectableApi<T> {
connect(connectable: Connectable<T>): this;
disconnect(connectable: Connectable<T>): this;
}
type Connection<From, To = From, Prev = To> = (value: From, next: (value: To) => any, prevValue: Prev) => void;
export type Connection<From, To = From, Prev = To> = (value: From, next: (value: To) => any, prevValue: Prev) => void;
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;
}
}

View File

@ -21,7 +21,6 @@ import {
import relayPoolService from "../services/relay-pool";
import { useInterval } from "react-use";
import { RelayStatus } from "./relay-status";
import { useIsMobile } from "../hooks/use-is-mobile";
import { RelayIcon } from "./icons";
import { Relay } from "../classes/relay";
import { RelayFavicon } from "./relay-favicon";
@ -29,7 +28,6 @@ import relayScoreboardService from "../services/relay-scoreboard";
import { RelayScoreBreakdown } from "./relay-score-breakdown";
export const ConnectedRelays = () => {
const isMobile = useIsMobile();
const { isOpen, onOpen, onClose } = useDisclosure();
const [relays, setRelays] = useState<Relay[]>(relayPoolService.getRelays());
const sortedRelays = useMemo(() => relayScoreboardService.getRankedRelays(relays.map((r) => r.url)), [relays]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 } 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];
@ -64,7 +63,7 @@ export function embedNostrHashtags(content: EmbedableContent, event: NostrEvent
return embedJSX(content, {
name: "nostr-hashtag",
regexp: matchHashtag,
regexp: getMatchHashtag(),
getLocation: (match) => {
if (match.index === undefined) throw new Error("match dose not have index");

View File

@ -273,11 +273,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",
@ -301,3 +307,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,
});

View File

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

View File

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

View File

@ -1,90 +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,
} 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("/relays")} leftIcon={<RelayIcon />}>
Relays
</Button>
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />}>
Settings
</Button>
{account && (
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />}>
Logout
</Button>
)}
{account?.readonly && (
<Text color="red.200" textAlign="center">
Readonly Mode
</Text>
)}
<ConnectedRelays />
<Flex justifyContent="flex-end" py="8">
<IconButton
icon={<EditIcon />}
aria-label="New post"
w="4rem"
h="4rem"
fontSize="1.5rem"
borderRadius="50%"
colorScheme="brand"
onClick={() => openModal()}
/>
<Flex
{...props}
gap="2"
direction="column"
width="15rem"
pt="2"
alignItems="stretch"
flexShrink={0}
h="100vh"
overflowY="auto"
overflowX="hidden"
>
<Flex direction="column" flexShrink={0} gap="2">
<Flex gap="2" alignItems="center" position="relative">
<LinkOverlay as={RouterLink} to="/" />
<Avatar src="/apple-touch-icon.png" size="sm" />
<Heading size="md">noStrudel</Heading>
</Flex>
<ProfileLink />
<AccountSwitcher />
<NavItems />
{account && (
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />} justifyContent="flex-start">
Logout
</Button>
)}
{account?.readonly && (
<Text color="red.200" textAlign="center">
Readonly Mode
</Text>
)}
<ConnectedRelays />
<Flex justifyContent="flex-end" py="8">
<IconButton
icon={<EditIcon />}
aria-label="New post"
w="4rem"
h="4rem"
fontSize="1.5rem"
borderRadius="50%"
colorScheme="brand"
onClick={() => openModal()}
/>
</Flex>
</Flex>
<PublishLog overflowY="auto" minH="15rem" />
</Flex>
);
}

View File

@ -7,25 +7,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"

View File

@ -11,17 +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 } 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 (
@ -29,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 />
@ -45,26 +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("/relays")} leftIcon={<RelayIcon />}>
Relays
</Button>
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />}>
Settings
</Button>
<NavItems isInDrawer />
{account ? (
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />}>
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />} justifyContent="flex-start">
Logout
</Button>
) : (

View File

@ -0,0 +1,60 @@
import { AbsoluteCenter, Box, Button, Divider } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import {
ChatIcon,
FeedIcon,
LiveStreamIcon,
MapIcon,
NotificationIcon,
ProfileIcon,
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("/map")} leftIcon={<MapIcon />} justifyContent="flex-start">
Map
</Button>
<Button onClick={() => navigate("/tools")} leftIcon={<ToolsIcon />} justifyContent="flex-start">
Tools
</Button>
<Divider my="2" />
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />} justifyContent="flex-start">
Settings
</Button>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,14 +45,14 @@ 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}>
<ExpandProvider>
<Card variant={variant} ref={ref} data-event-id={event.id}>
<CardHeader padding="2">
<Flex flex="1" gap="2" alignItems="center" wrap="wrap">
<Flex flex="1" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
@ -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"

View File

@ -17,13 +17,17 @@ import {
renderVideoUrl,
embedEmoji,
renderOpenGraphUrl,
embedImageGallery,
} from "../embed-types";
import { ImageGalleryProvider } from "../image-gallery";
import { LightboxProvider } from "../lightbox-provider";
import { renderRedditUrl } from "../embed-types/reddit";
function buildContents(event: NostrEvent | DraftNostrEvent) {
let content: EmbedableContent = [event.content.trim()];
// image gallery
content = embedImageGallery(content, event as NostrEvent);
// common
content = embedUrls(content, [
renderYoutubeUrl,
@ -58,10 +62,10 @@ export const NoteContents = React.memo(({ event, ...props }: NoteContentsProps &
const content = buildContents(event);
return (
<ImageGalleryProvider>
<LightboxProvider>
<Box whiteSpace="pre-wrap" {...props}>
{content}
</Box>
</ImageGalleryProvider>
</LightboxProvider>
);
});

View File

@ -26,10 +26,10 @@ import { useCallback, useState } from "react";
import QuoteNote from "./quote-note";
import { buildDeleteEvent } from "../../helpers/nostr/event";
import signingService from "../../services/signing";
import { nostrPostAction } from "../../classes/nostr-post-action";
import 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();
@ -49,8 +49,8 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
setDeleting(true);
const deleteEvent = buildDeleteEvent([event.id], reason);
const signed = await signingService.requestSignature(deleteEvent, account);
const results = nostrPostAction(clientRelaysService.getWriteUrls(), signed);
await results.onComplete;
const pub = new NostrPublishAction("Delete", clientRelaysService.getWriteUrls(), signed);
await pub.onComplete;
deleteModal.onClose();
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
@ -62,11 +62,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);
}
});
}, []);

View File

@ -3,16 +3,16 @@ import { getEventRelays } from "../../services/event-relays";
import { NostrEvent } from "../../types/nostr-event";
import useSubject from "../../hooks/use-subject";
import { RelayIconStack } from "../relay-icon-stack";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { getEventUID } from "../../helpers/nostr/event";
import { useBreakpointValue } from "@chakra-ui/react";
export type NoteRelaysProps = {
event: NostrEvent;
};
export const EventRelays = memo(({ event }: NoteRelaysProps) => {
const isMobile = useIsMobile();
const maxRelays = useBreakpointValue({ base: 3, md: undefined });
const eventRelays = useSubject(getEventRelays(getEventUID(event)));
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={isMobile ? 4 : undefined} />;
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={maxRelays} />;
});

View File

@ -17,11 +17,10 @@ import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
import dayjs from "dayjs";
import { DislikeIcon, LightningIcon, LikeIcon } from "../icons";
import { ParsedZap, parseZapEvent } from "../../helpers/zaps";
import { ParsedZap } from "../../helpers/zaps";
import { readablizeSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps";
import { useIsMobile } from "../../hooks/use-is-mobile";
function getReactionIcon(content: string) {
switch (content) {
@ -48,12 +47,10 @@ const ReactionEvent = React.memo(({ event }: { event: NostrEvent }) => (
));
const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => {
const isMobile = useIsMobile();
if (!zap.payment.amount) return null;
return (
<Box borderWidth="1px" borderRadius="lg" py="2" px={isMobile ? "2" : "4"}>
<Box borderWidth="1px" borderRadius="lg" py="2" px={["2", "4"]}>
<Flex gap="2" justifyContent="space-between">
<Box>
<UserAvatarLink pubkey={zap.request.pubkey} size="xs" mr="2" />
@ -80,14 +77,13 @@ export default function NoteReactionsModal({
const zaps = useEventZaps(noteId, [], true) ?? [];
const reactions = useEventReactions(noteId, [], true) ?? [];
const [selected, setSelected] = useState("zaps");
const isMobile = useIsMobile();
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody p={isMobile ? "2" : "4"}>
<ModalBody p={["2", "4"]}>
<Flex direction="column" gap="2">
<ButtonGroup>
<Button size="sm" variant={selected === "zaps" ? "solid" : "outline"} onClick={() => setSelected("zaps")}>

View File

@ -67,7 +67,7 @@ export default function PeopleListProvider({ children }: PropsWithChildren) {
list,
setList,
}),
[list, setList]
[list, setList],
);
return <PeopleListContext.Provider value={context}>{children}</PeopleListContext.Provider>;

View File

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

View File

@ -1,3 +1,4 @@
import React, { useRef, useState } from "react";
import {
Modal,
ModalOverlay,
@ -13,21 +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 { useIsMobile } from "../../hooks/use-is-mobile";
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 {
@ -38,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;
@ -79,13 +42,11 @@ type PostModalProps = {
};
export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) => {
const isMobile = useIsMobile();
const toast = useToast();
const { requestSignature } = useSigningContext();
const writeRelays = useWriteRelayUrls();
const [waiting, setWaiting] = useState(false);
const [signedEvent, setSignedEvent] = useState<NostrEvent | null>(null);
const [results, resultsActions] = useList<PostResult>();
const [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);
@ -98,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) {
@ -117,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);
@ -133,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 (
<>
@ -190,10 +155,10 @@ 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={isMobile ? "2" : "4"}>{renderContent()}</ModalBody>
<ModalBody padding={["2", "2", "4"]}>{renderContent()}</ModalBody>
</ModalContent>
</Modal>
);

View File

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

View File

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

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

View File

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

View File

@ -1,12 +1,21 @@
import { Alert, AlertDescription, AlertIcon, AlertProps, AlertTitle, Button, Spacer, useModal } from "@chakra-ui/react";
import { useIsMobile } from "../hooks/use-is-mobile";
import {
Alert,
AlertDescription,
AlertIcon,
AlertProps,
AlertTitle,
Button,
Spacer,
useBreakpointValue,
useModal,
} from "@chakra-ui/react";
import { useExpand } from "./note/expanded";
export default function SensitiveContentWarning({ description }: { description: string } & AlertProps) {
const isMobile = useIsMobile();
const expand = useExpand();
const smallScreen = useBreakpointValue({ base: true, md: false });
if (isMobile) {
if (smallScreen) {
return (
<Alert
status="warning"

View File

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

View File

@ -1,16 +1,16 @@
import { useCallback, useRef } from "react";
import { Flex, Grid, SimpleGrid } from "@chakra-ui/react";
import { useCallback } from "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 { useIsMobile } from "../../hooks/use-is-mobile";
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();
@ -18,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();
@ -39,20 +43,17 @@ export default function TimelinePage({ timeline, header }: { timeline: TimelineL
return <GenericNoteTimeline timeline={timeline} />;
case "images":
return (
<ImageGalleryProvider>
<SimpleGrid minChildWidth={["full", "15rem"]} gap="4">
<MediaTimeline timeline={timeline} />
</SimpleGrid>
</ImageGalleryProvider>
);
return <MediaTimeline timeline={timeline} />;
case "health":
return <TimelineHealth timeline={timeline} />;
default:
return null;
}
};
return (
<IntersectionObserverProvider<string> callback={callback}>
<Flex direction="column" gap="2" pt="4" pb="8">
<Flex direction="column" gap="2" {...props}>
{header}
{renderTimeline()}
<TimelineActionAndStatus timeline={timeline} />

View File

@ -1,59 +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 { useIsMobile } from "../../../hooks/use-is-mobile";
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 isMobile = useIsMobile();
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] });
}
}
@ -61,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>
);
}

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

View File

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

View File

@ -0,0 +1,76 @@
import {
Flex,
FlexProps,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Tag,
TagProps,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { UserAvatar } from "./user-avatar";
import { getUserDisplayName } from "../helpers/user-metadata";
import { useUserMetadata } from "../hooks/use-user-metadata";
function UserTag({ pubkey, ...props }: { pubkey: string } & Omit<TagProps, "children">) {
const metadata = useUserMetadata(pubkey);
const npub = nip19.npubEncode(pubkey);
const displayName = getUserDisplayName(metadata, pubkey);
return (
<Tag as={RouterLink} to={`/u/${npub}`} {...props}>
<UserAvatar pubkey={pubkey} size="xs" mr="2" title={displayName} />
{displayName}
</Tag>
);
}
export function UserAvatarStack({
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>
</>
);
}

View File

@ -6,6 +6,7 @@ import { getIdenticon } from "../helpers/identicon";
import { safeUrl } from "../helpers/parse";
import appSettings from "../services/settings/app-settings";
import useSubject from "../hooks/use-subject";
import { getUserDisplayName } from "../helpers/user-metadata";
export const UserIdenticon = React.memo(({ pubkey }: { pubkey: string }) => {
const { value: identicon } = useAsync(() => getIdenticon(pubkey), [pubkey]);
@ -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";

View File

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

View File

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

View File

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

109
src/helpers/nostr/post.ts Normal file
View 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;
}

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

View File

@ -52,7 +52,7 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
}
// if the stream has not been updated in a day consider it ended
if (stream.created_at < dayjs().subtract(2, "day").unix()) {
if (stream.created_at < dayjs().subtract(1, "week").unix()) {
status = "ended";
}

View File

@ -1,6 +1,7 @@
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 matchNostrLink = /(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/i;
export const matchHashtag = /(^|[^\p{L}])#([\p{L}\p{N}]+)/iu;
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;

View File

@ -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[]> = {};

View File

@ -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);
@ -23,6 +38,10 @@ export function safeRelayUrl(relayUrl: string) {
return null;
}
export function safeRelayUrls(urls: string[]): string[] {
return urls.map(safeRelayUrl).filter(Boolean) as string[];
}
export function replaceDomain(url: string | URL, replacementUrl: string | URL) {
const newUrl = new URL(url);
replacementUrl = convertToUrl(replacementUrl);

View File

@ -16,7 +16,7 @@ export default function useAppSettings() {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
},
[settings]
[settings],
);
return {

View File

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

View File

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

View File

@ -1,14 +0,0 @@
import { useEffect, useMemo, useState } from "react";
export function useIsMobile() {
const match = useMemo(() => window.matchMedia("(max-width: 1000px)"), []);
const [matches, setMatches] = useState(match.matches);
useEffect(() => {
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
match.addEventListener("change", listener);
return () => match.removeEventListener("change", listener);
}, [match]);
return matches;
}

View File

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

View File

@ -22,6 +22,6 @@ export function useTimelineCurserIntersectionCallback(timeline: TimelineLoader)
}
}
},
[timeline]
[timeline],
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -23,5 +23,5 @@ const root = createRoot(element);
root.render(
<GlobalProviders>
<App />
</GlobalProviders>
</GlobalProviders>,
);

View File

@ -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 !== "")

View File

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

View File

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

View File

@ -20,7 +20,7 @@ export default function PostModalProvider({ children }: PropsWithChildren) {
setDraft(draft);
onOpen();
},
[setDraft, onOpen]
[setDraft, onOpen],
);
const context = useMemo(() => ({ openModal }), [openModal]);

View File

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

View File

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

View File

@ -1,12 +1,13 @@
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";
import { RelayConfig, RelayMode } from "../classes/relay";
import userRelaysService, { ParsedUserRelays } from "./user-relays";
import { PersistentSubject, Subject } from "../classes/subject";
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 }>;
@ -18,21 +19,30 @@ const DEFAULT_RELAYS = [
{ url: "wss://nos.lol", mode: RelayMode.READ },
];
const userRelaysToRelayConfig: Connection<ParsedUserRelays, RelayConfig[], RelayConfig[] | undefined> = (
userRelays,
next,
) => next(userRelays.relays);
class ClientRelayService {
bootstrapRelays = new Set<string>();
relays = new PersistentSubject<RelayConfig[]>([]);
writeRelays = new PersistentSubject<RelayConfig[]>([]);
readRelays = new PersistentSubject<RelayConfig[]>([]);
log = logger.extend("ClientRelays");
constructor() {
let lastSubject: Subject<ParsedUserRelays> | undefined;
accountService.current.subscribe((account) => {
if (!account) {
this.log("No account, using default relays");
this.relays.next(DEFAULT_RELAYS);
return;
} else this.relays.next([]);
if (account.relays) {
this.log("Found bootstrap relays");
this.bootstrapRelays.clear();
for (const relay of account.relays) {
this.bootstrapRelays.add(relay);
@ -40,41 +50,47 @@ class ClientRelayService {
}
if (lastSubject) {
lastSubject.unsubscribe(this.handleRelayChanged, this);
this.log("Disconnecting from previous user relays");
this.relays.disconnect(lastSubject);
lastSubject = undefined;
}
// load the relays from cache or bootstrap relays
this.log("Load users relays from cache or bootstrap relays");
lastSubject = userRelaysService.requestRelays(account.pubkey, Array.from(this.bootstrapRelays));
setTimeout(() => {
// double check for new relay notes
this.log("Requesting latest relays from the write relays");
userRelaysService.requestRelays(account.pubkey, this.getWriteUrls(), true);
}, 1000);
lastSubject.subscribe(this.handleRelayChanged, this);
this.relays.connectWithHandler(lastSubject, userRelaysToRelayConfig);
});
this.relays.subscribe((relays) => this.writeRelays.next(relays.filter((r) => r.mode & RelayMode.WRITE)));
this.relays.subscribe((relays) => this.readRelays.next(relays.filter((r) => r.mode & RelayMode.READ)));
}
private handleRelayChanged(relays: ParsedUserRelays) {
this.relays.next(relays.relays);
// set the read and write relays
this.relays.subscribe((relays) => {
this.log("Got new relay list");
this.writeRelays.next(relays.filter((r) => r.mode & RelayMode.WRITE));
this.readRelays.next(relays.filter((r) => r.mode & RelayMode.READ));
});
}
async addRelay(url: string, mode: RelayMode) {
this.log(`Adding ${url} relay`);
if (!this.relays.value.some((r) => r.url === url)) {
const newRelays = [...this.relays.value, { url, mode }];
await this.postUpdatedRelays(newRelays);
}
}
async updateRelay(url: string, mode: RelayMode) {
this.log(`Updating ${url} relay`);
if (this.relays.value.some((r) => r.url === url)) {
const newRelays = this.relays.value.map((r) => (r.url === url ? { url, mode } : r));
await this.postUpdatedRelays(newRelays);
}
}
async removeRelay(url: string) {
this.log(`Removing ${url} relay`);
if (this.relays.value.some((r) => r.url === url)) {
const newRelays = this.relays.value.filter((r) => r.url !== url);
await this.postUpdatedRelays(newRelays);
@ -109,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() {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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