Compare commits
359 Commits
1894e9a2f1
...
5a7657a95d
Author | SHA1 | Date | |
---|---|---|---|
5a7657a95d | |||
edf64e4125 | |||
a089ae2ec6 | |||
4384c638b9 | |||
ecd74fbb12 | |||
0ab896923e | |||
3c3e35ad4f | |||
e5c8634c59 | |||
e62bb58362 | |||
64703cf05d | |||
6a96afc82c | |||
0babc928fa | |||
77178bc728 | |||
b4bcc4d371 | |||
8d306ce466 | |||
5292b8880e | |||
3e32bc7789 | |||
19d72722db | |||
f601e88b8f | |||
2f76dd9b10 | |||
eea2fdfc12 | |||
e98b7fa17e | |||
81827dec96 | |||
9a88b52b25 | |||
5c5c31aadf | |||
12cf7380a5 | |||
06e8f1fd73 | |||
6394e65400 | |||
0720d40cc1 | |||
93ea3e8e80 | |||
27a111466a | |||
8137317bfe | |||
d1095847d8 | |||
746a4177cf | |||
95b160dd04 | |||
a938e466d7 | |||
a88fda2a22 | |||
ee31726961 | |||
ad2b6dbcf7 | |||
d0427040b9 | |||
8c9cc7931a | |||
a3f1393999 | |||
382f9b6e1b | |||
c8b8daeb29 | |||
f20db30062 | |||
ead73d06a8 | |||
4f7b9f1b99 | |||
4a7073b231 | |||
2b98f0fc4a | |||
f937c0edfa | |||
a2fe2255c8 | |||
583aff433c | |||
3153f632c7 | |||
dbf2346176 | |||
782feedae4 | |||
098251fee3 | |||
99b4d01ff7 | |||
6785ef72e1 | |||
226618ac77 | |||
780b3ebe1c | |||
23584205aa | |||
1f4427641e | |||
da099ca214 | |||
2f1f2a5e97 | |||
65552604dc | |||
1fbff0d04a | |||
a0aa86a0b3 | |||
ed7929db50 | |||
be48c9cc37 | |||
5796892e54 | |||
95dc979b8d | |||
04e7d0b54f | |||
bf4e9c9776 | |||
8fef783cf8 | |||
a9c7edb09d | |||
a5532b23f3 | |||
ce5fbf0819 | |||
5e1af603b7 | |||
670898c016 | |||
7558e91d28 | |||
3f0bd88db8 | |||
8b9acd3109 | |||
e5f8bebb53 | |||
86906682f9 | |||
73753c2764 | |||
1be9254221 | |||
eedbe90197 | |||
c19e7ad20c | |||
0d9d5a0a4c | |||
512307f42d | |||
96947fad2e | |||
e2164800a9 | |||
ff5d2ee32d | |||
48ba5cecbd | |||
6022b6007a | |||
b0f230e70d | |||
a66361049d | |||
875996347b | |||
c43d0c7a86 | |||
ac4028f191 | |||
18c366502c | |||
3e43300077 | |||
2d74f55e06 | |||
248b35d3ca | |||
2f0b4f8d96 | |||
d1129b3b15 | |||
b07961802c | |||
df16384f07 | |||
edbfa02c52 | |||
0a05cf864c | |||
47e0c5a8cc | |||
280a7eaac2 | |||
aefe8a8210 | |||
a97e895cb8 | |||
7ceab04cbc | |||
5bc3c10d36 | |||
351a249a32 | |||
e0e0a857b0 | |||
c5e534a730 | |||
2099eddebc | |||
d42d26fc20 | |||
006cad49bb | |||
e652cc7703 | |||
c23856daf4 | |||
3c017f89be | |||
e2ab1b4e3f | |||
d0bc8df6a1 | |||
e746109f5c | |||
0716fb4752 | |||
2ab7e63e55 | |||
4dff677809 | |||
ce2218bc93 | |||
f4ab402e34 | |||
19a396c7d3 | |||
35ec58377c | |||
2ab2001014 | |||
5ea195a341 | |||
523fd1a0ba | |||
1fd37a42d2 | |||
cdd814cf73 | |||
8c9381fc6c | |||
6feac60a4a | |||
f3272bed57 | |||
3fa4dbf100 | |||
a9c8fd9ba5 | |||
4b335faa36 | |||
cfb9c4adfd | |||
6bc5387afc | |||
cd5cc07857 | |||
13a773a1ad | |||
eb9f23b73b | |||
e88cc64cb2 | |||
0de93a0a53 | |||
579589f635 | |||
7c1f2c539f | |||
ce4d99dc88 | |||
74d6cc9932 | |||
07510d92ca | |||
ad8d0af976 | |||
2ef1b591e2 | |||
a7c0cf7397 | |||
a14a5fa96b | |||
8c19f4de68 | |||
5fc844b911 | |||
14c8c9a080 | |||
470e5b31ce | |||
82d5b9fb64 | |||
dc99d2a653 | |||
e343c5cb9b | |||
b07f9abe16 | |||
404a07f45a | |||
3fb7b7adc4 | |||
28f7133236 | |||
c18f8eddbb | |||
d55c9ad122 | |||
fea7a9a63a | |||
5d9b306d41 | |||
8061410333 | |||
52b52deb72 | |||
68583e24b8 | |||
88766c6c08 | |||
c8c0cc2ac5 | |||
3355822bcd | |||
0fd8cf3f49 | |||
1aaee2a2cb | |||
dae96109b8 | |||
f9a0516718 | |||
d3e6ddc64c | |||
7a6637a86f | |||
22863a289d | |||
f10ad6dd53 | |||
d3873ea281 | |||
4f4649da2c | |||
9a220fafd5 | |||
e72f779ab7 | |||
9a3207bfa3 | |||
d7460651c8 | |||
9a0bbb8b74 | |||
f9d08267a6 | |||
e9d9bf34d8 | |||
c968fa43a6 | |||
982f4df0a3 | |||
5cea096067 | |||
d6c578fafc | |||
e9cf2e141b | |||
02ec637266 | |||
e7f9b5e2ea | |||
4aa00405ee | |||
65a96eb77b | |||
6fd02cffbb | |||
ef8a5c29bf | |||
381a849a11 | |||
45fbd06bff | |||
d1ebd49d56 | |||
6354472d05 | |||
6a1a990e57 | |||
d1972542b7 | |||
9ceb3c705f | |||
9be57a6e84 | |||
6722ad5f8e | |||
29cb9a61b4 | |||
a66f7f5fd8 | |||
2033137ae2 | |||
3d2f11f206 | |||
08bfd38563 | |||
8fb127b347 | |||
5ddc5ee8df | |||
53c8ccbd0f | |||
9654f70c22 | |||
bf66f273e3 | |||
da6fa415dd | |||
72b98a4ab5 | |||
7e88d96ddb | |||
cb0b75c652 | |||
8e33d10319 | |||
0b307ae691 | |||
2b1cf34424 | |||
0307bacd30 | |||
aa9d5d72be | |||
ba3e901e9b | |||
6eef8c7fef | |||
084558b3e7 | |||
32a6d56cf5 | |||
c2f78dad1e | |||
0239db393f | |||
f147edd03c | |||
e3f8d48ddb | |||
d019544053 | |||
712848a129 | |||
3ff651ec37 | |||
f20cd8a119 | |||
2d4c323cf7 | |||
6d8c0325e4 | |||
2ea516e636 | |||
b7e61ebde5 | |||
2e27c1b41a | |||
34b2d9b743 | |||
d990e9ffad | |||
62ff3df30d | |||
f043a9ee96 | |||
adb9fe5c2e | |||
aa58ec4185 | |||
3c808688f8 | |||
aa430de168 | |||
fe46959424 | |||
9ae097907a | |||
a7ac246a43 | |||
6899e46622 | |||
e45d6ffa52 | |||
3b6e194ded | |||
21d7df0eac | |||
ad79091356 | |||
148acc764c | |||
1f90b2fe90 | |||
bc22ee7d56 | |||
eb2601448c | |||
773db5dea6 | |||
4fe2554d9d | |||
d4233a818e | |||
13b7a16dc7 | |||
736c2577db | |||
7935d3d86a | |||
57d4d6b2c6 | |||
47fc8e1414 | |||
a98bbd65b5 | |||
ef673c2a05 | |||
ffa4a192f6 | |||
5ab39aafe8 | |||
43ed484bcc | |||
2cadab20b4 | |||
1cef1e0187 | |||
e0c4b64865 | |||
a9405388c0 | |||
775ee6423f | |||
1aaff4f553 | |||
6657161a32 | |||
7ee210da16 | |||
26e12d1c0b | |||
703da5389a | |||
ad2029d1d7 | |||
20d3fdaa6e | |||
69d6dfd5d6 | |||
c8dae9fae6 | |||
649bab228b | |||
edca8a9636 | |||
9bdf60a24f | |||
dffb33bfda | |||
de6685ade3 | |||
536f8ddc5b | |||
d45d601712 | |||
c75ab861b5 | |||
3fe6ed952c | |||
8090bb1718 | |||
501ad41fff | |||
ee01623bf1 | |||
eb9cf7f361 | |||
45f66fd139 | |||
8043f1034f | |||
76d3c78c0a | |||
21e1202b97 | |||
ab8121c4b2 | |||
d3c9fef9af | |||
8c8a7c7e88 | |||
cb233f4ccb | |||
326ce2ba68 | |||
8cca297d6d | |||
a3fc25f64c | |||
a1f61e2d13 | |||
51758eaf5e | |||
5baffd00b9 | |||
e6a42db658 | |||
8e37e0fbed | |||
7220435d15 | |||
53488a9c59 | |||
1278867ad0 | |||
be4ee620ad | |||
87386c9950 | |||
baf6cc34ee | |||
071eed0d8c | |||
6f9a1fd706 | |||
90342325fd | |||
b686b8ff26 | |||
835385836f | |||
93608f817f | |||
9e2582ac81 | |||
35d7ec4685 | |||
062212f311 | |||
52adf6fb1f | |||
90b15ee668 | |||
7be4b0bd18 | |||
b8cdb4bf58 | |||
cf6b431d73 | |||
91f0afdb89 | |||
8a5a089b4d | |||
80fa5a132b | |||
1a4a76d7fa | |||
4455651d47 | |||
3c97d73536 | |||
77925e6647 |
10
.dockerignore
Normal file
@ -0,0 +1,10 @@
|
||||
**/node_modules
|
||||
**/.pnp.*
|
||||
**/.yarn/*
|
||||
!**/.yarn/patches
|
||||
!**/.yarn/plugins
|
||||
!**/.yarn/releases
|
||||
!**/.yarn/sdks
|
||||
!**/.yarn/versions
|
||||
**/.idea
|
||||
**/target
|
36
.drone.yml
@ -17,17 +17,19 @@ steps:
|
||||
commands:
|
||||
- git fetch --tags
|
||||
- name: Build site
|
||||
image: node:current-bullseye
|
||||
image: node:current
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-docker
|
||||
NODE_CONFIG_ENV: default
|
||||
commands:
|
||||
- apt update && apt install -y git
|
||||
- yarn install
|
||||
- yarn build
|
||||
- name: build docker image
|
||||
image: r.j3ss.co/img
|
||||
image: docker
|
||||
privileged: true
|
||||
volumes:
|
||||
- name: cache
|
||||
@ -36,9 +38,11 @@ steps:
|
||||
TOKEN:
|
||||
from_secret: docker_hub
|
||||
commands:
|
||||
- img login -u voidic -p $TOKEN
|
||||
- img build -t voidic/snort:latest --platform linux/amd64,linux/arm64 -f Dockerfile.prebuilt .
|
||||
- img push voidic/snort:latest
|
||||
- dockerd &
|
||||
- docker login -u voidic -p $TOKEN
|
||||
- docker buildx create --name mybuilder --bootstrap --use
|
||||
- docker buildx build -t voidic/snort:latest --platform linux/amd64,linux/arm64 --push -f Dockerfile.prebuilt .
|
||||
- kill $(cat /var/run/docker.pid)
|
||||
volumes:
|
||||
- name: cache
|
||||
claim:
|
||||
@ -53,12 +57,13 @@ metadata:
|
||||
namespace: git
|
||||
steps:
|
||||
- name: Test/Lint
|
||||
image: node:current-bullseye
|
||||
image: node:current
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-test
|
||||
NODE_CONFIG_ENV: default
|
||||
commands:
|
||||
- yarn install
|
||||
- yarn build
|
||||
@ -84,12 +89,13 @@ metadata:
|
||||
namespace: git
|
||||
steps:
|
||||
- name: Push/Pull translations
|
||||
image: node:current-bullseye
|
||||
image: node:current
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-translations
|
||||
NODE_CONFIG_ENV: default
|
||||
TOKEN:
|
||||
from_secret: gitea
|
||||
CTOKEN:
|
||||
@ -129,17 +135,19 @@ steps:
|
||||
commands:
|
||||
- git fetch --tags
|
||||
- name: Build site
|
||||
image: node:current-bullseye
|
||||
image: node:current
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-docker-release
|
||||
YARN_CACHE_FOLDER: /cache/.yarn-docker-
|
||||
NODE_CONFIG_ENV: default
|
||||
commands:
|
||||
- apt update && apt install -y git
|
||||
- yarn install
|
||||
- yarn build
|
||||
- name: build docker image
|
||||
image: r.j3ss.co/img
|
||||
image: docker
|
||||
privileged: true
|
||||
volumes:
|
||||
- name: cache
|
||||
@ -148,9 +156,11 @@ steps:
|
||||
TOKEN:
|
||||
from_secret: docker_hub
|
||||
commands:
|
||||
- img login -u voidic -p $TOKEN
|
||||
- img build -t voidic/snort:$DRONE_TAG --platform linux/amd64,linux/arm64 -f Dockerfile.prebuilt .
|
||||
- img push voidic/snort:$DRONE_TAG
|
||||
- dockerd &
|
||||
- docker login -u voidic -p $TOKEN
|
||||
- docker buildx create --name mybuilder --bootstrap --use
|
||||
- docker buildx build -t voidic/snort:$DRONE_TAG --platform linux/amd64,linux/arm64 --push -f Dockerfile.prebuilt .
|
||||
- kill $(cat /var/run/docker.pid)
|
||||
volumes:
|
||||
- name: cache
|
||||
claim:
|
||||
|
3
.gitignore
vendored
@ -11,4 +11,5 @@ dist/
|
||||
*.tgz
|
||||
*.log
|
||||
.DS_Store
|
||||
.pnp*
|
||||
.pnp*
|
||||
docs/
|
874
.yarn/releases/yarn-3.6.3.cjs
vendored
893
.yarn/releases/yarn-4.1.1.cjs
vendored
Executable file
10
.yarnrc.yml
@ -1 +1,9 @@
|
||||
yarnPath: .yarn/releases/yarn-3.6.3.cjs
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
npmScopes:
|
||||
here:
|
||||
npmRegistryServer: "https://repo.platform.here.com/artifactory/api/npm/maps-api-for-javascript/"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.1.1.cjs
|
||||
|
18
Dockerfile
@ -1,12 +1,12 @@
|
||||
FROM node:19 as build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock .yarnrc.yml .
|
||||
COPY .yarn .yarn
|
||||
COPY packages packages
|
||||
RUN yarn --network-timeout 1000000
|
||||
RUN yarn build
|
||||
FROM node:current as build
|
||||
WORKDIR /src
|
||||
RUN apt update \
|
||||
&& apt install -y --no-install-recommends git \
|
||||
&& git clone --single-branch -b main https://git.v0l.io/Kieran/snort \
|
||||
&& cd snort \
|
||||
&& yarn --network-timeout 1000000 \
|
||||
&& yarn build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:mainline-alpine
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/packages/app/build /usr/share/nginx/html
|
||||
COPY --from=build /src/snort/packages/app/build /usr/share/nginx/html
|
||||
|
13
README.md
@ -65,6 +65,19 @@ To build the application and system packages, use
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
Tauri desktop application:
|
||||
|
||||
```
|
||||
# install dependencies
|
||||
yarn
|
||||
|
||||
# develop
|
||||
yarn tauri dev
|
||||
|
||||
# build
|
||||
yarn tauri build
|
||||
```
|
||||
|
||||
### Translations
|
||||
|
||||
[![Crowdin](https://badges.crowdin.net/snort/localized.svg)](https://crowdin.com/project/snort)
|
||||
|
@ -3,6 +3,9 @@ server {
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
add_header Content-Security-Policy "default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com";
|
||||
add_header Cross-Origin-Opener-Policy same-origin;
|
||||
add_header Cross-Origin-Embedder-Policy require-corp;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
|
50
functions/_middleware.ts
Normal file
@ -0,0 +1,50 @@
|
||||
interface Env {}
|
||||
|
||||
const HOST = "snort.social";
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async context => {
|
||||
const u = new URL(context.request.url);
|
||||
|
||||
const prefixes = ["npub1", "nprofile1", "naddr1", "nevent1", "note1"];
|
||||
const isEntityPath = prefixes.some(
|
||||
a => u.pathname.startsWith(`/${a}`) || u.pathname.startsWith(`/e/${a}`) || u.pathname.startsWith(`/p/${a}`),
|
||||
);
|
||||
const nostrAddress = u.pathname.match(/^\/([a-zA-Z0-9_]+)$/i);
|
||||
const next = await context.next();
|
||||
if (u.pathname != "/" && (isEntityPath || nostrAddress)) {
|
||||
//console.log("Handeling path: ", u.pathname, isEntityPath, nostrAddress[1]);
|
||||
try {
|
||||
let id = u.pathname.split("/").at(-1);
|
||||
if (!isEntityPath && nostrAddress) {
|
||||
id = `${id}@${HOST}`;
|
||||
}
|
||||
const fetchApi = `https://nostr.api.v0l.io/api/v1/opengraph/${id}?canonical=${encodeURIComponent(
|
||||
`https://${HOST}/%s`,
|
||||
)}`;
|
||||
console.log("Fetching tags from: ", fetchApi);
|
||||
const rsp = await fetch(fetchApi, {
|
||||
method: "POST",
|
||||
body: await next.arrayBuffer(),
|
||||
headers: {
|
||||
"user-agent": `SnortFunctions/1.0 (https://${HOST})`,
|
||||
"content-type": "text/html",
|
||||
accept: "text/html",
|
||||
},
|
||||
});
|
||||
if (rsp.ok) {
|
||||
const body = await rsp.text();
|
||||
if (body.length > 0) {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
...Object.fromEntries(rsp.headers.entries()),
|
||||
"cache-control": "public, max-age=60",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
@ -1,30 +0,0 @@
|
||||
interface Env {}
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async context => {
|
||||
const id = context.params.id as string;
|
||||
|
||||
const next = await context.next();
|
||||
try {
|
||||
const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/e/${id}`, {
|
||||
method: "POST",
|
||||
body: await next.arrayBuffer(),
|
||||
headers: {
|
||||
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
});
|
||||
if (rsp.ok) {
|
||||
const body = await rsp.text();
|
||||
if (body.length > 0) {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return next;
|
||||
};
|
@ -1,30 +0,0 @@
|
||||
interface Env {}
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async context => {
|
||||
const id = context.params.id as string;
|
||||
|
||||
const next = await context.next();
|
||||
try {
|
||||
const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/p/${id}`, {
|
||||
method: "POST",
|
||||
body: await next.arrayBuffer(),
|
||||
headers: {
|
||||
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
});
|
||||
if (rsp.ok) {
|
||||
const body = await rsp.text();
|
||||
if (body.length > 0) {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return next;
|
||||
};
|
13
package.json
@ -4,24 +4,29 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "yarn workspace @snort/shared build && yarn workspace @snort/system build && yarn workspace @snort/system-web build && yarn workspace @snort/system-react build && yarn workspace @snort/app build",
|
||||
"build": "yarn workspace @snort/shared build && yarn workspace @snort/worker-relay build && yarn workspace @snort/system build && yarn workspace @snort/system-web build && yarn workspace @snort/system-react build && yarn workspace @snort/wallet build && yarn workspace @snort/app build",
|
||||
"start": "yarn build && yarn workspace @snort/app start",
|
||||
"test": "yarn build && yarn workspace @snort/app test && yarn workspace @snort/system test",
|
||||
"pre:commit": "yarn workspace @snort/app intl-extract && yarn workspace @snort/app intl-compile && yarn prettier --write .",
|
||||
"push-prod": "git checkout snort-prod && git merge --ff-only main && git push && git checkout main"
|
||||
"push-prod": "git switch snort-prod && git merge --ff-only main && git push && git checkout main",
|
||||
"docs": "typedoc --entryPointStrategy packages ./packages/* --exclude ./packages/app --exclude ./packages/webrtc-server --name snort.social"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "avoid",
|
||||
"trailingComma": "all"
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
},
|
||||
"packageManager": "yarn@3.6.3",
|
||||
"packageManager": "yarn@4.1.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20230307.0",
|
||||
"@tauri-apps/cli": "^1.2.3",
|
||||
"eslint": "^8.48.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typedoc": "^0.25.7"
|
||||
}
|
||||
}
|
||||
|
@ -16,11 +16,20 @@ module.exports = {
|
||||
],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react-refresh/only-export-components": "warn",
|
||||
"react-refresh/only-export-components": "error",
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"max-lines": ["warn", { max: 300, skipBlankLines: true, skipComments: true }],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.tsx"],
|
||||
rules: {
|
||||
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
|
||||
},
|
||||
},
|
||||
],
|
||||
root: true,
|
||||
ignorePatterns: ["build/", "*.test.ts", "*.js"],
|
||||
env: {
|
||||
|
@ -1,3 +1,47 @@
|
||||
# v0.2.0
|
||||
|
||||
`+16,990,-9,649`
|
||||
|
||||
## Added
|
||||
|
||||
- Check notification settings page
|
||||
- New settings page layout - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Community Leaders / Invite system - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Settings->Tools pages (Check follows relay health etc) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- New wallet pages design - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Alby OAuth wallet connection - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Cashu wallet support (WIP) - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Followed by friends feed page - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Fuzzysearch profiles everywhere - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Worker Relay package `@snort/worker-relay` - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Replaces all previous caching objects, all caches are handled inside `@snort/system` via worker relay
|
||||
- "View as user" button - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Play live streams directly in feed with embed iframe - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Negentropy v1 support - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
|
||||
## Changed
|
||||
|
||||
- Hidden note styles & preferences - nostr:npub1cz2ve34nk0ukn0ph4yq2qx3ud8rfy5e0ak4epx42dn8gha0sdgpsgra9kv
|
||||
- Keybinds for grid modal navigation - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Cache trending sections in browser - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Cache images / nostr.json in service worker - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Add dimensions to `imeta` tag for void.cat uploads - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Check event sigs in `@snort/system-wasm` - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
- Primary color scheme - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Note creator styles (removed hashtags input) - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Cache link preview results in memory - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Show only 1 task at a time in task list - nostr:
|
||||
- Render media in reply to note creator - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Show top zappers inline with footer icons on notes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Add more search relays - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Moved link previews and opengraph tagging to https://nostr.api.v0l.io - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
|
||||
## Fixed
|
||||
|
||||
- Iris account error mesage - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Light theme color fixes - nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
|
||||
- Notifications page overflow - nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
|
||||
|
||||
# v0.1.24
|
||||
|
||||
`+11,573,-3,010`
|
||||
@ -583,7 +627,7 @@ https://git.v0l.io/Kieran/snort/compare/v0.1.9...v0.1.10
|
||||
- Fix event mention bug by @SamSamskies in https://github.com/v0l/snort/pull/421
|
||||
- fix NaN when parsing empty string by @SamSamskies in https://github.com/v0l/snort/pull/422
|
||||
- NIP06 support by @w3irdrobot in https://github.com/v0l/snort/pull/425
|
||||
- Added key attr to Tabs to remove React warning by @w3irdrobot in https://github.com/v0l/snort/pull/424
|
||||
- Added key attr to TabSelectors to remove React warning by @w3irdrobot in https://github.com/v0l/snort/pull/424
|
||||
- New Crowdin updates by @v0l in https://github.com/v0l/snort/pull/426
|
||||
- New Crowdin updates by @v0l in https://github.com/v0l/snort/pull/436
|
||||
- Update Wavlake embed URL, add support for album & artist links by @blastshielddown in https://github.com/v0l/snort/pull/439
|
||||
|
@ -1,2 +0,0 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://analytics.v0l.io https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
|
@ -4,8 +4,7 @@
|
||||
"appTitle": "Snort - Nostr",
|
||||
"hostname": "snort.social",
|
||||
"nip05Domain": "snort.social",
|
||||
"favicon": "public/favicon.ico",
|
||||
"appleTouchIconUrl": "/nostrich_512.png",
|
||||
"icon": "/nostrich_512.png",
|
||||
"navLogo": null,
|
||||
"publicDir": "public/snort",
|
||||
"httpCache": "",
|
||||
@ -17,12 +16,18 @@
|
||||
"deck": true,
|
||||
"zapPool": true,
|
||||
"notificationGraph": true,
|
||||
"communityLeaders": true
|
||||
"communityLeaders": true,
|
||||
"nostrAddress": true,
|
||||
"pushNotifications": true
|
||||
},
|
||||
"signUp": {
|
||||
"moderation": true,
|
||||
"quickStart": false,
|
||||
"defaultFollows": ["npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws"]
|
||||
},
|
||||
"defaultPreferences": {
|
||||
"hideMutedNotes": false,
|
||||
"defaultRootTab": "following"
|
||||
},
|
||||
"media": {
|
||||
"bypassImgProxyError": false,
|
||||
"preferLargeMedia": true
|
||||
@ -33,15 +38,21 @@
|
||||
"noteCreatorToast": false,
|
||||
"hideFromNavbar": ["/graph"],
|
||||
"deckSubKind": 1,
|
||||
"showPowIcon": true,
|
||||
"eventLinkPrefix": "nevent",
|
||||
"profileLinkPrefix": "nprofile",
|
||||
"defaultRelays": {
|
||||
"wss://relay.snort.social/": { "read": true, "write": true },
|
||||
"wss://nostr.wine/": { "read": true, "write": false },
|
||||
"wss://eden.nostr.land/": { "read": true, "write": false }
|
||||
"wss://relay.damus.io/": { "read": true, "write": true },
|
||||
"wss://nos.lol/": { "read": true, "write": true }
|
||||
},
|
||||
"alby": {
|
||||
"clientId": "pohiJjPhQR",
|
||||
"clientSecret": "GAl1YKLA3FveK1gLBYok"
|
||||
}
|
||||
},
|
||||
"chatChannels": [
|
||||
{ "type": "telegram", "value": "https://t.me/irismessenger" },
|
||||
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
|
||||
]
|
||||
}
|
||||
|
@ -4,8 +4,7 @@
|
||||
"appTitle": "iris",
|
||||
"hostname": "iris.to",
|
||||
"nip05Domain": "iris.to",
|
||||
"favicon": "public/iris/favicon.ico",
|
||||
"appleTouchIconUrl": "/img/apple-touch-icon.png",
|
||||
"icon": "/img/icon128.png",
|
||||
"navLogo": "/img/icon128.png",
|
||||
"publicDir": "public/iris",
|
||||
"httpCache": "",
|
||||
@ -13,14 +12,18 @@
|
||||
"defaultZapPoolFee": 0.5,
|
||||
"features": {
|
||||
"analytics": true,
|
||||
"subscriptions": false,
|
||||
"subscriptions": true,
|
||||
"deck": true,
|
||||
"zapPool": true,
|
||||
"notificationGraph": false,
|
||||
"communityLeaders": true
|
||||
},
|
||||
"defaultPreferences": {
|
||||
"hideMutedNotes": true,
|
||||
"defaultRootTab": "for-you"
|
||||
},
|
||||
"signUp": {
|
||||
"moderation": false,
|
||||
"quickStart": true,
|
||||
"defaultFollows": ["npub1wnwwcv0a8wx0m9stck34ajlwhzuua68ts8mw3kjvspn42dcfyjxs4n95l8"]
|
||||
},
|
||||
"media": {
|
||||
@ -34,12 +37,20 @@
|
||||
"hideFromNavbar": [],
|
||||
"eventLinkPrefix": "note",
|
||||
"profileLinkPrefix": "npub",
|
||||
"showPowIcon": false,
|
||||
"defaultRelays": {
|
||||
"ws://localhost:7777": { "read": true, "write": true },
|
||||
"wss://relay.snort.social/": { "read": true, "write": true },
|
||||
"wss://nostr.wine/": { "read": true, "write": false },
|
||||
"wss://eden.nostr.land/": { "read": true, "write": false },
|
||||
"wss://relay.nostr.band/": { "read": true, "write": true },
|
||||
"wss://relay.damus.io/": { "read": true, "write": true }
|
||||
},
|
||||
"chatChannels": [
|
||||
{ "type": "telegram", "value": "https://t.me/irismessenger" },
|
||||
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
|
||||
],
|
||||
"alby": {
|
||||
"clientId": "5rYcHDrlDb",
|
||||
"clientSecret": "QAI3QmgiaPH3BfTMzzFd"
|
||||
}
|
||||
}
|
||||
|
50
packages/app/config/nostr.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"appName": "Nostr",
|
||||
"appNameCapitalized": "Nostr",
|
||||
"appTitle": "Nostr",
|
||||
"hostname": "nostr.com",
|
||||
"nip05Domain": "nostr.com",
|
||||
"icon": "/nostr.jpg",
|
||||
"navLogo": null,
|
||||
"publicDir": "public/nostr",
|
||||
"httpCache": "",
|
||||
"animalNamePlaceholders": false,
|
||||
"defaultZapPoolFee": 0,
|
||||
"features": {
|
||||
"analytics": false,
|
||||
"subscriptions": false,
|
||||
"deck": false,
|
||||
"zapPool": false,
|
||||
"notificationGraph": true,
|
||||
"communityLeaders": false,
|
||||
"nostrAddress": false,
|
||||
"pushNotifications": false
|
||||
},
|
||||
"signUp": {
|
||||
"quickStart": false,
|
||||
"defaultFollows": []
|
||||
},
|
||||
"defaultPreferences": {
|
||||
"hideMutedNotes": false,
|
||||
"defaultRootTab": "following"
|
||||
},
|
||||
"media": {
|
||||
"bypassImgProxyError": false,
|
||||
"preferLargeMedia": true
|
||||
},
|
||||
"communityLeaders": null,
|
||||
"noteCreatorToast": true,
|
||||
"hideFromNavbar": ["/graph"],
|
||||
"deckSubKind": 1,
|
||||
"showPowIcon": true,
|
||||
"eventLinkPrefix": "nevent",
|
||||
"profileLinkPrefix": "nprofile",
|
||||
"defaultRelays": {
|
||||
"wss://relay.snort.social/": { "read": true, "write": true },
|
||||
"wss://nostr.wine/": { "read": true, "write": false },
|
||||
"wss://eden.nostr.land/": { "read": true, "write": false },
|
||||
"wss://nos.lol/": { "read": true, "write": true }
|
||||
},
|
||||
"alby": null,
|
||||
"chatChannels": null
|
||||
}
|
23
packages/app/custom.d.ts
vendored
@ -1,4 +1,5 @@
|
||||
/// <reference types="@webbtc/webln-types" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.jpg" {
|
||||
const value: unknown;
|
||||
@ -46,8 +47,7 @@ declare const CONFIG: {
|
||||
appTitle: string;
|
||||
hostname: string;
|
||||
nip05Domain: string;
|
||||
favicon: string;
|
||||
appleTouchIconUrl: string;
|
||||
icon: string;
|
||||
navLogo: string | null;
|
||||
httpCache: string;
|
||||
animalNamePlaceholders: boolean;
|
||||
@ -59,12 +59,15 @@ declare const CONFIG: {
|
||||
zapPool: boolean;
|
||||
notificationGraph: boolean;
|
||||
communityLeaders: boolean;
|
||||
nostrAddress: boolean;
|
||||
pushNotifications: boolean;
|
||||
};
|
||||
defaultPreferences: {
|
||||
checkSigs: boolean;
|
||||
hideMutedNotes: boolean;
|
||||
defaultRootTab: "following" | "for-you";
|
||||
};
|
||||
signUp: {
|
||||
moderation: boolean;
|
||||
quickStart: boolean;
|
||||
defaultFollows: Array<string>;
|
||||
};
|
||||
media: {
|
||||
@ -89,18 +92,20 @@ declare const CONFIG: {
|
||||
eventLinkPrefix: NostrPrefix;
|
||||
profileLinkPrefix: NostrPrefix;
|
||||
defaultRelays: Record<string, RelaySettings>;
|
||||
showPowIcon: boolean;
|
||||
|
||||
// Alby wallet oAuth config
|
||||
alby?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Single relay (Debug)
|
||||
*/
|
||||
declare const SINGLE_RELAY: string | undefined;
|
||||
// public chat channels for site
|
||||
chatChannels?: Array<{
|
||||
type: "nip28" | "telegram";
|
||||
value: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build git hash
|
||||
|
@ -11,10 +11,12 @@
|
||||
name="keywords"
|
||||
content="nostr snort fast decentralized social media censorship resistant open source software" />
|
||||
<link rel="preconnect" href="https://imgproxy.snort.social" />
|
||||
<link rel="apple-touch-icon" href="" />
|
||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<title></title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
|
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@snort/app",
|
||||
"version": "0.1.24",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "0.6.1",
|
||||
"@lightninglabs/lnc-web": "^0.2.8-alpha",
|
||||
"@cashu/cashu-ts": "^1.0.0-rc.3",
|
||||
"@here/maps-api-for-javascript": "^1.50.0",
|
||||
"@noble/curves": "^1.0.0",
|
||||
"@noble/hashes": "^1.3.3",
|
||||
"@scure/base": "^1.1.1",
|
||||
@ -14,6 +14,8 @@
|
||||
"@snort/system-react": "workspace:*",
|
||||
"@snort/system-wasm": "workspace:*",
|
||||
"@snort/system-web": "workspace:*",
|
||||
"@snort/wallet": "workspace:*",
|
||||
"@snort/worker-relay": "workspace:*",
|
||||
"@szhsin/react-menu": "^3.3.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@void-cat/api": "^1.0.12",
|
||||
@ -22,8 +24,10 @@
|
||||
"debug": "^4.3.4",
|
||||
"dexie": "^3.2.4",
|
||||
"emojilib": "^3.0.10",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"latlon-geohash": "^2.0.0",
|
||||
"light-bolt11-decoder": "^2.1.0",
|
||||
"lottie-react": "^2.4.0",
|
||||
"marked": "^9.1.0",
|
||||
@ -40,9 +44,11 @@
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"recharts": "^2.8.0",
|
||||
"three": "^0.157.0",
|
||||
"typescript-lru-cache": "^2.0.0",
|
||||
"use-long-press": "^3.2.0",
|
||||
"use-sync-external-store": "^1.2.0",
|
||||
"uuid": "^9.0.0",
|
||||
"workbox-cacheable-response": "^7.0.0",
|
||||
"workbox-core": "^6.4.2",
|
||||
"workbox-expiration": "^7.0.0",
|
||||
"workbox-precaching": "^7.0.0",
|
||||
@ -82,6 +88,7 @@
|
||||
"@formatjs/cli": "^6.1.3",
|
||||
"@types/config": "^3.3.3",
|
||||
"@types/debug": "^4.1.8",
|
||||
"@types/latlon-geohash": "^2.0.3",
|
||||
"@types/node": "^20.4.1",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
@ -112,8 +119,8 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tinybench": "^2.5.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^0.17.0",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-pwa": "^0.19.2",
|
||||
"vite-plugin-version-mark": "^0.0.10",
|
||||
"vitest": "^0.34.6"
|
||||
}
|
||||
|
2
packages/app/public/iris/_headers
Normal file
@ -0,0 +1,2 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
|
Before Width: | Height: | Size: 15 KiB |
BIN
packages/app/public/iris/favicon.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
2
packages/app/public/nostr/_headers
Normal file
@ -0,0 +1,2 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
|
BIN
packages/app/public/nostr/favicon.png
Normal file
After Width: | Height: | Size: 165 B |
BIN
packages/app/public/nostr/img/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 405 B |
BIN
packages/app/public/nostr/nostr.jpg
Normal file
After Width: | Height: | Size: 3.9 KiB |
2
packages/app/public/snort/_headers
Normal file
@ -0,0 +1,2 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
|
BIN
packages/app/public/snort/favicon.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
packages/app/public/snort/img/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 44 KiB |
16
packages/app/src/Cache/CommunityLeadersStore.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { ExternalStore } from "@snort/shared";
|
||||
|
||||
class CommunityLeadersStore extends ExternalStore<Array<string>> {
|
||||
#leaders: Array<string> = [];
|
||||
|
||||
setLeaders(arr: Array<string>) {
|
||||
this.#leaders = arr;
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
takeSnapshot(): string[] {
|
||||
return [...this.#leaders];
|
||||
}
|
||||
}
|
||||
|
||||
export const LeadersStore = new CommunityLeadersStore();
|
100
packages/app/src/Cache/EventCacheWorker.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { CachedTable, CacheEvents } from "@snort/shared";
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { WorkerRelayInterface } from "@snort/worker-relay";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
|
||||
export class EventCacheWorker extends EventEmitter<CacheEvents> implements CachedTable<NostrEvent> {
|
||||
#relay: WorkerRelayInterface;
|
||||
#keys = new Set<string>();
|
||||
#cache = new Map<string, NostrEvent>();
|
||||
|
||||
constructor(relay: WorkerRelayInterface) {
|
||||
super();
|
||||
this.#relay = relay;
|
||||
}
|
||||
|
||||
async preload() {
|
||||
const ids = await this.#relay.query([
|
||||
"REQ",
|
||||
"preload-event-cache",
|
||||
{
|
||||
ids_only: true,
|
||||
},
|
||||
]);
|
||||
this.#keys = new Set<string>(ids as unknown as Array<string>);
|
||||
}
|
||||
|
||||
keysOnTable(): string[] {
|
||||
return [...this.#keys];
|
||||
}
|
||||
|
||||
getFromCache(key?: string | undefined): NostrEvent | undefined {
|
||||
if (key) {
|
||||
return this.#cache.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
discover(ev: NostrEvent) {
|
||||
this.#keys.add(this.key(ev));
|
||||
}
|
||||
|
||||
async get(key?: string | undefined): Promise<NostrEvent | undefined> {
|
||||
if (key) {
|
||||
const res = await this.bulkGet([key]);
|
||||
if (res.length > 0) {
|
||||
return res[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async bulkGet(keys: string[]): Promise<NostrEvent[]> {
|
||||
const results = await this.#relay.query([
|
||||
"REQ",
|
||||
"EventCacheWorker.bulkGet",
|
||||
{
|
||||
ids: keys,
|
||||
},
|
||||
]);
|
||||
for (const ev of results) {
|
||||
this.#cache.set(ev.id, ev);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async set(obj: NostrEvent): Promise<void> {
|
||||
await this.#relay.event(obj);
|
||||
this.#keys.add(obj.id);
|
||||
}
|
||||
|
||||
async bulkSet(obj: NostrEvent[] | readonly NostrEvent[]): Promise<void> {
|
||||
await Promise.all(
|
||||
obj.map(async a => {
|
||||
await this.#relay.event(a);
|
||||
this.#keys.add(a.id);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async update<TWithCreated extends NostrEvent & { created: number; loaded: number }>(
|
||||
m: TWithCreated,
|
||||
): Promise<"new" | "refresh" | "updated" | "no_change"> {
|
||||
if (await this.#relay.event(m)) {
|
||||
return "updated";
|
||||
}
|
||||
return "no_change";
|
||||
}
|
||||
|
||||
async buffer(keys: string[]): Promise<string[]> {
|
||||
const missing = keys.filter(a => !this.#keys.has(a));
|
||||
const res = await this.bulkGet(missing);
|
||||
return missing.filter(a => !res.some(b => this.key(b) === a));
|
||||
}
|
||||
|
||||
key(of: NostrEvent): string {
|
||||
return of.id;
|
||||
}
|
||||
|
||||
snapshot(): NostrEvent[] {
|
||||
return [...this.#cache.values()];
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import { FeedCache } from "@snort/shared";
|
||||
|
||||
import { db, EventInteraction } from "@/Db";
|
||||
import { LoginStore } from "@/Utils/Login";
|
||||
|
||||
export class EventInteractionCache extends FeedCache<EventInteraction> {
|
||||
constructor() {
|
||||
super("EventInteraction", db.eventInteraction);
|
||||
}
|
||||
|
||||
key(of: EventInteraction): string {
|
||||
return `${of.event}:${of.by}`;
|
||||
}
|
||||
|
||||
override async preload(): Promise<void> {
|
||||
await super.preload();
|
||||
|
||||
const data = window.localStorage.getItem("zap-cache");
|
||||
if (data) {
|
||||
const toImport = [...new Set<string>(JSON.parse(data) as Array<string>)].map(a => {
|
||||
const ret = {
|
||||
event: a,
|
||||
by: LoginStore.takeSnapshot().publicKey,
|
||||
zapped: true,
|
||||
reacted: false,
|
||||
reposted: false,
|
||||
} as EventInteraction;
|
||||
ret.id = this.key(ret);
|
||||
return ret;
|
||||
});
|
||||
await this.bulkSet(toImport);
|
||||
|
||||
console.debug(`Imported dumb-zap-cache events: `, toImport.length);
|
||||
window.localStorage.removeItem("zap-cache");
|
||||
}
|
||||
await this.buffer([...this.onTable]);
|
||||
}
|
||||
|
||||
takeSnapshot(): EventInteraction[] {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
import { unixNowMs } from "@snort/shared";
|
||||
import { EventKind, RequestBuilder, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import { db } from "@/Db";
|
||||
import { LoginSession } from "@/Utils/Login";
|
||||
|
||||
import { RefreshFeedCache } from "./RefreshFeedCache";
|
||||
|
||||
export class FollowListCache extends RefreshFeedCache<TaggedNostrEvent> {
|
||||
constructor() {
|
||||
super("FollowListCache", db.followLists);
|
||||
}
|
||||
|
||||
buildSub(session: LoginSession, rb: RequestBuilder): void {
|
||||
const since = this.newest();
|
||||
rb.withFilter()
|
||||
.kinds([EventKind.ContactList])
|
||||
.authors(session.follows.item)
|
||||
.since(since === 0 ? undefined : since);
|
||||
}
|
||||
|
||||
async onEvent(evs: readonly TaggedNostrEvent[]) {
|
||||
await Promise.all(
|
||||
evs.map(async e => {
|
||||
const update = await super.update({
|
||||
...e,
|
||||
created: e.created_at,
|
||||
loaded: unixNowMs(),
|
||||
});
|
||||
if (update !== "no_change") {
|
||||
socialGraphInstance.handleEvent(e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
key(of: TaggedNostrEvent): string {
|
||||
return of.pubkey;
|
||||
}
|
||||
|
||||
takeSnapshot() {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
|
||||
override async preload() {
|
||||
await super.preload();
|
||||
this.snapshot().forEach(e => socialGraphInstance.handleEvent(e));
|
||||
}
|
||||
}
|
@ -1,135 +0,0 @@
|
||||
import { unixNow, unixNowMs } from "@snort/shared";
|
||||
import { EventKind, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system";
|
||||
import debug from "debug";
|
||||
|
||||
import { db } from "@/Db";
|
||||
import { Day, Hour } from "@/Utils/Const";
|
||||
import { LoginSession } from "@/Utils/Login";
|
||||
|
||||
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
|
||||
|
||||
const WindowSize = Hour * 6;
|
||||
const MaxCacheWindow = Day * 7;
|
||||
|
||||
export class FollowsFeedCache extends RefreshFeedCache<TaggedNostrEvent> {
|
||||
#kinds = [EventKind.TextNote, EventKind.Repost, EventKind.Polls];
|
||||
#oldest?: number;
|
||||
|
||||
constructor() {
|
||||
super("FollowsFeedCache", db.followsFeed);
|
||||
}
|
||||
|
||||
key(of: TWithCreated<TaggedNostrEvent>): string {
|
||||
return of.id;
|
||||
}
|
||||
|
||||
takeSnapshot(): TWithCreated<TaggedNostrEvent>[] {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
|
||||
buildSub(session: LoginSession, rb: RequestBuilder): void {
|
||||
const authors = [...session.follows.item];
|
||||
if (session.publicKey) {
|
||||
authors.push(session.publicKey);
|
||||
}
|
||||
const since = this.newest();
|
||||
rb.withFilter()
|
||||
.kinds(this.#kinds)
|
||||
.authors(authors)
|
||||
.since(since === 0 ? unixNow() - WindowSize : since);
|
||||
}
|
||||
|
||||
async onEvent(evs: readonly TaggedNostrEvent[]): Promise<void> {
|
||||
const filtered = evs.filter(a => this.#kinds.includes(a.kind));
|
||||
if (filtered.length > 0) {
|
||||
await this.bulkSet(filtered);
|
||||
this.notifyChange(filtered.map(a => this.key(a)));
|
||||
}
|
||||
}
|
||||
|
||||
override async preload() {
|
||||
const start = unixNowMs();
|
||||
const keys = (await this.table?.toCollection().primaryKeys()) ?? [];
|
||||
this.onTable = new Set<string>(keys.map(a => a as string));
|
||||
|
||||
// load only latest 50 posts, rest can be loaded on-demand
|
||||
const latest = await this.table?.orderBy("created_at").reverse().limit(50).toArray();
|
||||
latest?.forEach(v => this.cache.set(this.key(v), v));
|
||||
|
||||
// cleanup older than 7 days
|
||||
await this.table
|
||||
?.where("created_at")
|
||||
.below(unixNow() - MaxCacheWindow)
|
||||
.delete();
|
||||
|
||||
const oldest = await this.table?.orderBy("created_at").first();
|
||||
this.#oldest = oldest?.created_at;
|
||||
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
|
||||
|
||||
debug(this.name)(`Loaded %d/%d in %d ms`, latest?.length ?? 0, keys.length, (unixNowMs() - start).toLocaleString());
|
||||
}
|
||||
|
||||
async loadMore(system: SystemInterface, session: LoginSession, before: number) {
|
||||
if (this.#oldest && before <= this.#oldest) {
|
||||
const rb = new RequestBuilder(`${this.name}-loadmore`);
|
||||
const authors = [...session.follows.item];
|
||||
if (session.publicKey) {
|
||||
authors.push(session.publicKey);
|
||||
}
|
||||
rb.withFilter()
|
||||
.kinds(this.#kinds)
|
||||
.authors(authors)
|
||||
.until(before)
|
||||
.since(before - WindowSize);
|
||||
await system.Fetch(rb, async evs => {
|
||||
await this.bulkSet(evs);
|
||||
});
|
||||
} else {
|
||||
const latest = await this.table
|
||||
?.where("created_at")
|
||||
.between(before - WindowSize, before)
|
||||
.reverse()
|
||||
.sortBy("created_at");
|
||||
latest?.forEach(v => {
|
||||
const k = this.key(v);
|
||||
this.cache.set(k, v);
|
||||
this.onTable.add(k);
|
||||
});
|
||||
|
||||
this.notifyChange(latest?.map(a => this.key(a)) ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill cache with new follows
|
||||
*/
|
||||
async backFill(system: SystemInterface, keys: Array<string>) {
|
||||
if (keys.length === 0) return;
|
||||
|
||||
const rb = new RequestBuilder(`${this.name}-backfill`);
|
||||
rb.withFilter()
|
||||
.kinds(this.#kinds)
|
||||
.authors(keys)
|
||||
.until(unixNow())
|
||||
.since(this.#oldest ?? unixNow() - MaxCacheWindow);
|
||||
await system.Fetch(rb, async evs => {
|
||||
await this.bulkSet(evs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill cache based on follows list
|
||||
*/
|
||||
async backFillIfMissing(system: SystemInterface, keys: Array<string>) {
|
||||
if (!this.#oldest) return;
|
||||
|
||||
const start = unixNowMs();
|
||||
const everything = await this.table?.toArray();
|
||||
if ((everything?.length ?? 0) > 0) {
|
||||
const allKeys = new Set(everything?.map(a => a.pubkey));
|
||||
const missingKeys = keys.filter(a => !allKeys.has(a));
|
||||
await this.backFill(system, missingKeys);
|
||||
debug(this.name)(`Backfilled %d keys in %d ms`, missingKeys.length, (unixNowMs() - start).toLocaleString());
|
||||
}
|
||||
}
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { EventKind, NostrEvent, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import { db, NostrEventForSession } from "@/Db";
|
||||
import { Day } from "@/Utils/Const";
|
||||
import { LoginSession } from "@/Utils/Login";
|
||||
|
||||
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
|
||||
|
||||
export class NotificationsCache extends RefreshFeedCache<NostrEventForSession> {
|
||||
#kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
|
||||
|
||||
constructor() {
|
||||
super("notifications", db.notifications);
|
||||
}
|
||||
|
||||
buildSub(session: LoginSession, rb: RequestBuilder) {
|
||||
if (session.publicKey) {
|
||||
const newest = this.newest(v => v.tags.some(a => a[0] === "p" && a[1] === session.publicKey));
|
||||
rb.withFilter()
|
||||
.kinds(this.#kinds)
|
||||
.tag("p", [session.publicKey])
|
||||
.since(newest === 0 ? unixNow() - Day * 30 : newest);
|
||||
}
|
||||
}
|
||||
|
||||
async onEvent(evs: readonly TaggedNostrEvent[], pubKey: string) {
|
||||
const filtered = evs.filter(a => this.#kinds.includes(a.kind) && a.tags.some(b => b[0] === "p"));
|
||||
if (filtered.length > 0) {
|
||||
await this.bulkSet(
|
||||
filtered.map(v => ({
|
||||
...v,
|
||||
forSession: pubKey,
|
||||
})),
|
||||
);
|
||||
this.notifyChange(filtered.map(v => this.key(v)));
|
||||
}
|
||||
}
|
||||
|
||||
key(of: TWithCreated<NostrEvent>): string {
|
||||
return of.id;
|
||||
}
|
||||
|
||||
takeSnapshot() {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
import { FeedCache } from "@snort/shared";
|
||||
|
||||
import { db, Payment } from "@/Db";
|
||||
|
||||
export class Payments extends FeedCache<Payment> {
|
||||
constructor() {
|
||||
super("PaymentsCache", db.payments);
|
||||
}
|
||||
|
||||
key(of: Payment): string {
|
||||
return of.url;
|
||||
}
|
||||
|
||||
takeSnapshot(): Array<Payment> {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
}
|
112
packages/app/src/Cache/ProfileWorkerCache.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
|
||||
import { CachedMetadata, mapEventToProfile, NostrEvent } from "@snort/system";
|
||||
import { WorkerRelayInterface } from "@snort/worker-relay";
|
||||
import debug from "debug";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
|
||||
export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implements CachedTable<CachedMetadata> {
|
||||
#relay: WorkerRelayInterface;
|
||||
#keys = new Set<string>();
|
||||
#cache = new Map<string, CachedMetadata>();
|
||||
#log = debug("ProfileCacheRelayWorker");
|
||||
|
||||
constructor(relay: WorkerRelayInterface) {
|
||||
super();
|
||||
this.#relay = relay;
|
||||
}
|
||||
|
||||
async preload() {
|
||||
const start = unixNowMs();
|
||||
const profiles = await this.#relay.query([
|
||||
"REQ",
|
||||
"profiles-preload",
|
||||
{
|
||||
kinds: [0],
|
||||
},
|
||||
]);
|
||||
this.#cache = new Map<string, CachedMetadata>(profiles.map(a => [a.pubkey, unwrap(mapEventToProfile(a))]));
|
||||
this.#keys = new Set<string>(this.#cache.keys());
|
||||
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
|
||||
}
|
||||
|
||||
keysOnTable(): string[] {
|
||||
return [...this.#keys];
|
||||
}
|
||||
|
||||
getFromCache(key?: string | undefined) {
|
||||
if (key) {
|
||||
return this.#cache.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
discover(ev: NostrEvent) {
|
||||
if (ev.kind === 0) {
|
||||
this.#keys.add(ev.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
async get(key?: string | undefined) {
|
||||
if (key) {
|
||||
const cached = this.getFromCache(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const res = await this.bulkGet([key]);
|
||||
if (res.length > 0) {
|
||||
return res[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async bulkGet(keys: string[]) {
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const results = await this.#relay.query([
|
||||
"REQ",
|
||||
"ProfileCacheRelayWorker.bulkGet",
|
||||
{
|
||||
authors: keys,
|
||||
kinds: [0],
|
||||
},
|
||||
]);
|
||||
const mapped = removeUndefined(results.map(a => mapEventToProfile(a)));
|
||||
for (const pf of mapped) {
|
||||
this.#cache.set(this.key(pf), pf);
|
||||
}
|
||||
this.emit(
|
||||
"change",
|
||||
mapped.map(a => this.key(a)),
|
||||
);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async set(obj: CachedMetadata) {
|
||||
this.#keys.add(this.key(obj));
|
||||
}
|
||||
|
||||
async bulkSet(obj: CachedMetadata[] | readonly CachedMetadata[]) {
|
||||
const mapped = obj.map(a => this.key(a));
|
||||
mapped.forEach(a => this.#keys.add(a));
|
||||
// todo: store in cache
|
||||
this.emit("change", mapped);
|
||||
}
|
||||
|
||||
async update(): Promise<"new" | "refresh" | "updated" | "no_change"> {
|
||||
// do nothing
|
||||
return "refresh";
|
||||
}
|
||||
|
||||
async buffer(keys: string[]) {
|
||||
const missing = keys.filter(a => !this.#cache.has(a));
|
||||
const res = await this.bulkGet(missing);
|
||||
return missing.filter(a => !res.some(b => this.key(b) === a));
|
||||
}
|
||||
|
||||
key(of: CachedMetadata) {
|
||||
return of.pubkey;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return [...this.#cache.values()];
|
||||
}
|
||||
}
|
@ -24,7 +24,6 @@ export abstract class RefreshFeedCache<T> extends FeedCache<TWithCreated<T>> {
|
||||
|
||||
override async preload(): Promise<void> {
|
||||
await super.preload();
|
||||
// load all dms to memory
|
||||
await this.buffer([...this.onTable]);
|
||||
}
|
||||
}
|
||||
|
6
packages/app/src/Cache/TextCache.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { ParsedFragment } from "@snort/system";
|
||||
import { LRUCache } from "typescript-lru-cache";
|
||||
|
||||
export const TextCache = new LRUCache<string, Array<ParsedFragment>>({
|
||||
maxSize: 1000,
|
||||
});
|
117
packages/app/src/Cache/UserFollowsWorker.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
|
||||
import { EventKind, NostrEvent, UsersFollows } from "@snort/system";
|
||||
import { WorkerRelayInterface } from "@snort/worker-relay";
|
||||
import debug from "debug";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
|
||||
export class UserFollowsWorker extends EventEmitter<CacheEvents> implements CachedTable<UsersFollows> {
|
||||
#relay: WorkerRelayInterface;
|
||||
#keys = new Set<string>();
|
||||
#cache = new Map<string, UsersFollows>();
|
||||
#log = debug("UserFollowsWorker");
|
||||
|
||||
constructor(relay: WorkerRelayInterface) {
|
||||
super();
|
||||
this.#relay = relay;
|
||||
}
|
||||
|
||||
async preload() {
|
||||
const start = unixNowMs();
|
||||
const profiles = await this.#relay.query([
|
||||
"REQ",
|
||||
"profiles-preload",
|
||||
{
|
||||
kinds: [3],
|
||||
},
|
||||
]);
|
||||
this.#cache = new Map<string, UsersFollows>(profiles.map(a => [a.pubkey, unwrap(mapEventToUserFollows(a))]));
|
||||
this.#keys = new Set<string>(this.#cache.keys());
|
||||
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
|
||||
}
|
||||
|
||||
keysOnTable(): string[] {
|
||||
return [...this.#keys];
|
||||
}
|
||||
|
||||
getFromCache(key?: string | undefined): UsersFollows | undefined {
|
||||
if (key) {
|
||||
return this.#cache.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
discover(ev: NostrEvent) {
|
||||
this.#keys.add(ev.pubkey);
|
||||
}
|
||||
|
||||
async get(key?: string | undefined): Promise<UsersFollows | undefined> {
|
||||
if (key) {
|
||||
const res = await this.bulkGet([key]);
|
||||
if (res.length > 0) {
|
||||
return res[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async bulkGet(keys: string[]) {
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const results = await this.#relay.query([
|
||||
"REQ",
|
||||
"UserFollowsWorker.bulkGet",
|
||||
{
|
||||
authors: keys,
|
||||
kinds: [3],
|
||||
},
|
||||
]);
|
||||
const mapped = removeUndefined(results.map(a => mapEventToUserFollows(a)));
|
||||
for (const pf of mapped) {
|
||||
this.#cache.set(this.key(pf), pf);
|
||||
}
|
||||
this.emit(
|
||||
"change",
|
||||
mapped.map(a => this.key(a)),
|
||||
);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async set(obj: UsersFollows) {
|
||||
this.#keys.add(this.key(obj));
|
||||
}
|
||||
|
||||
async bulkSet(obj: UsersFollows[] | readonly UsersFollows[]) {
|
||||
const mapped = obj.map(a => this.key(a));
|
||||
mapped.forEach(a => this.#keys.add(a));
|
||||
// todo: store in cache
|
||||
this.emit("change", mapped);
|
||||
}
|
||||
|
||||
async update(): Promise<"new" | "refresh" | "updated" | "no_change"> {
|
||||
// do nothing
|
||||
return "refresh";
|
||||
}
|
||||
|
||||
async buffer(keys: string[]): Promise<string[]> {
|
||||
const missing = keys.filter(a => !this.#keys.has(a));
|
||||
const res = await this.bulkGet(missing);
|
||||
return missing.filter(a => !res.some(b => this.key(b) === a));
|
||||
}
|
||||
|
||||
key(of: UsersFollows): string {
|
||||
return of.pubkey;
|
||||
}
|
||||
|
||||
snapshot(): UsersFollows[] {
|
||||
return [...this.#cache.values()];
|
||||
}
|
||||
}
|
||||
|
||||
export function mapEventToUserFollows(ev: NostrEvent): UsersFollows | undefined {
|
||||
if (ev.kind !== EventKind.ContactList) return;
|
||||
|
||||
return {
|
||||
pubkey: ev.pubkey,
|
||||
loaded: unixNowMs(),
|
||||
created: ev.created_at,
|
||||
follows: ev.tags,
|
||||
};
|
||||
}
|
@ -1,38 +1,45 @@
|
||||
import { RelayMetricCache, UserProfileCache, UserRelaysCache } from "@snort/system";
|
||||
import { RelayMetricCache, UserRelaysCache } from "@snort/system";
|
||||
import { SnortSystemDb } from "@snort/system-web";
|
||||
import { WorkerRelayInterface } from "@snort/worker-relay";
|
||||
import WorkerVite from "@snort/worker-relay/src/worker?worker";
|
||||
|
||||
import { ChatCache } from "./ChatCache";
|
||||
import { EventInteractionCache } from "./EventInteractionCache";
|
||||
import { FollowListCache } from "./FollowListCache";
|
||||
import { FollowsFeedCache } from "./FollowsFeed";
|
||||
import { EventCacheWorker } from "./EventCacheWorker";
|
||||
import { GiftWrapCache } from "./GiftWrapCache";
|
||||
import { NotificationsCache } from "./Notifications";
|
||||
import { Payments } from "./PaymentsCache";
|
||||
import { ProfileCacheRelayWorker } from "./ProfileWorkerCache";
|
||||
import { UserFollowsWorker } from "./UserFollowsWorker";
|
||||
|
||||
export const Relay = new WorkerRelayInterface(
|
||||
import.meta.env.DEV ? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url) : new WorkerVite(),
|
||||
);
|
||||
export async function initRelayWorker() {
|
||||
try {
|
||||
await Relay.init({
|
||||
databasePath: "relay.db",
|
||||
insertBatchSize: 100,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export const SystemDb = new SnortSystemDb();
|
||||
export const UserCache = new UserProfileCache(SystemDb.users);
|
||||
export const UserRelays = new UserRelaysCache(SystemDb.userRelays);
|
||||
export const RelayMetrics = new RelayMetricCache(SystemDb.relayMetrics);
|
||||
|
||||
export const Chats = new ChatCache();
|
||||
export const PaymentsCache = new Payments();
|
||||
export const InteractionCache = new EventInteractionCache();
|
||||
export const UserFollows = new UserFollowsWorker(Relay);
|
||||
export const UserCache = new ProfileCacheRelayWorker(Relay);
|
||||
export const EventsCache = new EventCacheWorker(Relay);
|
||||
|
||||
export const GiftsCache = new GiftWrapCache();
|
||||
export const Notifications = new NotificationsCache();
|
||||
export const FollowsFeed = new FollowsFeedCache();
|
||||
export const FollowLists = new FollowListCache();
|
||||
|
||||
export async function preload(follows?: Array<string>) {
|
||||
const preloads = [
|
||||
UserCache.preload(follows),
|
||||
Chats.preload(),
|
||||
InteractionCache.preload(),
|
||||
UserRelays.preload(follows),
|
||||
UserCache.preload(),
|
||||
RelayMetrics.preload(),
|
||||
GiftsCache.preload(),
|
||||
Notifications.preload(),
|
||||
FollowsFeed.preload(),
|
||||
FollowLists.preload(),
|
||||
UserRelays.preload(follows),
|
||||
EventsCache.preload(),
|
||||
UserFollows.preload(),
|
||||
];
|
||||
await Promise.all(preloads);
|
||||
}
|
||||
|
@ -19,10 +19,10 @@
|
||||
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 1px;
|
||||
}
|
||||
|
||||
.light .spinner-button:hover {
|
||||
.light .spinner-button:not(.primary):hover {
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0 1px 3px;
|
||||
}
|
||||
|
||||
.light .spinner-button > span {
|
||||
.light .spinner-button:not(.primary) > span {
|
||||
color: black;
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import classNames from "classnames";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
import ShowMore from "@/Components/Event/ShowMore";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
|
||||
interface CollapsedProps {
|
||||
@ -13,8 +12,8 @@ interface CollapsedProps {
|
||||
|
||||
const Collapsed = ({ text, children, collapsed, setCollapsed }: CollapsedProps) => {
|
||||
return collapsed ? (
|
||||
<div className="collapsed">
|
||||
<ShowMore text={text} onClick={() => setCollapsed(false)} />
|
||||
<div className="text-nostr-purple px-4 pb-3 cursor-pointer hover:underline" onClick={() => setCollapsed(false)}>
|
||||
{text}
|
||||
</div>
|
||||
) : (
|
||||
<div className="uncollapsed">{children}</div>
|
||||
|
@ -9,15 +9,21 @@ export interface CopyProps {
|
||||
text: string;
|
||||
maxSize?: number;
|
||||
className?: string;
|
||||
showText?: boolean;
|
||||
mask?: string;
|
||||
}
|
||||
export default function Copy({ text, maxSize = 32, className }: CopyProps) {
|
||||
export default function Copy({ text, maxSize = 32, className, showText, mask }: CopyProps) {
|
||||
const { copy, copied } = useCopy();
|
||||
const sliceLength = maxSize / 2;
|
||||
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
|
||||
const displayText = mask ? mask.repeat(text.length) : text;
|
||||
const trimmed =
|
||||
displayText.length > maxSize
|
||||
? `${displayText.slice(0, sliceLength)}...${displayText.slice(-sliceLength)}`
|
||||
: displayText;
|
||||
|
||||
return (
|
||||
<div className={classNames("copy flex pointer g8 items-center", className)} onClick={() => copy(text)}>
|
||||
<span className="copy-body">{trimmed}</span>
|
||||
{(showText ?? true) && <span className="copy-body">{trimmed}</span>}
|
||||
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
|
||||
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}
|
||||
</span>
|
||||
|
@ -3,13 +3,21 @@ const AppleMusicEmbed = ({ link }: { link: string }) => {
|
||||
const isSongLink = /\?i=\d+$/.test(convertedUrl);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
|
||||
frameBorder="0"
|
||||
height={isSongLink ? 175 : 450}
|
||||
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
|
||||
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
|
||||
src={convertedUrl}></iframe>
|
||||
<>
|
||||
<iframe
|
||||
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
|
||||
frameBorder="0"
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
height={isSongLink ? 175 : 450}
|
||||
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
|
||||
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
|
||||
src={convertedUrl}
|
||||
/>
|
||||
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
|
||||
{link}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,8 +1,3 @@
|
||||
.cashu {
|
||||
background: var(--cashu-gradient);
|
||||
}
|
||||
|
||||
.cashu h1 {
|
||||
font-size: 44px;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
@ -4,7 +4,10 @@ import { useUserProfile } from "@snort/system-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import ECashIcon from "@/Components/Icons/ECash";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import { useCopy } from "@/Hooks/useCopy";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
interface Token {
|
||||
@ -20,13 +23,9 @@ interface Token {
|
||||
export default function CashuNuts({ token }: { token: string }) {
|
||||
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
|
||||
const profile = useUserProfile(publicKey);
|
||||
const { copy } = useCopy();
|
||||
|
||||
async function copyToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
|
||||
e.stopPropagation();
|
||||
await navigator.clipboard.writeText(token);
|
||||
}
|
||||
async function redeemToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
|
||||
e.stopPropagation();
|
||||
async function redeemToken(token: string) {
|
||||
const lnurl = profile?.lud16 ?? "";
|
||||
const url = `https://redeem.cashu.me?token=${encodeURIComponent(token)}&lightning=${encodeURIComponent(
|
||||
lnurl,
|
||||
@ -53,87 +52,30 @@ export default function CashuNuts({ token }: { token: string }) {
|
||||
|
||||
const amount = cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
|
||||
return (
|
||||
<div className="cashu flex justify-between p24 br">
|
||||
<div className="flex flex-col g8 f-ellipsis">
|
||||
<div className="flex items-center g16">
|
||||
<svg width="30" height="39" viewBox="0 0 30 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 47711">
|
||||
<path
|
||||
id="Rectangle 585"
|
||||
d="M29.3809 2.47055L29.3809 11.7277L26.7913 11.021C23.8493 10.2181 20.727 10.3835 17.8863 11.4929C15.5024 12.4238 12.9113 12.6933 10.3869 12.2728L7.11501 11.7277L7.11501 2.47054L10.3869 3.01557C12.9113 3.43607 15.5024 3.1666 17.8863 2.23566C20.727 1.12632 23.8493 0.960876 26.7913 1.7638L29.3809 2.47055Z"
|
||||
fill="url(#paint0_linear_1976_19241)"
|
||||
/>
|
||||
<path
|
||||
id="Rectangle 587"
|
||||
d="M29.3809 27.9803L29.3809 37.2375L26.7913 36.5308C23.8493 35.7278 20.727 35.8933 17.8863 37.0026C15.5024 37.9336 12.9113 38.203 10.3869 37.7825L7.11501 37.2375L7.11501 27.9803L10.3869 28.5253C12.9113 28.9458 15.5024 28.6764 17.8863 27.7454C20.727 26.6361 23.8493 26.4706 26.7913 27.2736L29.3809 27.9803Z"
|
||||
fill="url(#paint1_linear_1976_19241)"
|
||||
/>
|
||||
<path
|
||||
id="Rectangle 586"
|
||||
d="M8.494e-08 15.2069L4.89585e-07 24.4641L2.5896 23.7573C5.53159 22.9544 8.6539 23.1198 11.4946 24.2292C13.8784 25.1601 16.4695 25.4296 18.9939 25.0091L22.2658 24.4641L22.2658 15.2069L18.9939 15.7519C16.4695 16.1724 13.8784 15.9029 11.4946 14.972C8.6539 13.8627 5.53159 13.6972 2.5896 14.5001L8.494e-08 15.2069Z"
|
||||
fill="url(#paint2_linear_1976_19241)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_1976_19241"
|
||||
x1="29.3809"
|
||||
y1="6.7213"
|
||||
x2="7.11501"
|
||||
y2="6.7213"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_1976_19241"
|
||||
x1="29.3809"
|
||||
y1="32.2311"
|
||||
x2="7.11501"
|
||||
y2="32.2311"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_1976_19241"
|
||||
x1="2.70746e-07"
|
||||
y1="19.4576"
|
||||
x2="22.2658"
|
||||
y2="19.4576"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="cashu flex justify-between p24 br items-center">
|
||||
<div className="flex flex-col gap-2 f-ellipsis">
|
||||
<div className="flex items-center gap-4">
|
||||
<ECashIcon width={30} />
|
||||
<FormattedMessage
|
||||
defaultMessage="<h1>{n}</h1> Cashu sats"
|
||||
id="6/SF6e"
|
||||
defaultMessage="{n} eSats"
|
||||
id="yAztTU"
|
||||
values={{
|
||||
h1: c => <h1>{c}</h1>,
|
||||
n: <FormattedNumber value={amount} />,
|
||||
n: (
|
||||
<span className="text-3xl">
|
||||
<FormattedNumber value={amount} />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<small className="xs w-max">
|
||||
<FormattedMessage
|
||||
defaultMessage="<b>Mint:</b> {url}"
|
||||
id="zwb6LR"
|
||||
values={{
|
||||
b: c => <b>{c}</b>,
|
||||
url: new URL(cashu.token[0].mint).hostname,
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
<div className="flex g8">
|
||||
<button onClick={e => copyToken(e, token)}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<AsyncButton onClick={() => copy(token)}>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
<button onClick={e => redeemToken(e, token)}>
|
||||
</AsyncButton>
|
||||
<AsyncButton onClick={() => redeemToken(token)}>
|
||||
<FormattedMessage defaultMessage="Redeem" id="XrSk2j" description="Button: Redeem Cashu token" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
34
packages/app/src/Components/Embed/GenericPlayer.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Icon from "../Icons/Icon";
|
||||
import { ProxyImg } from "../ProxyImg";
|
||||
|
||||
export default function GenericPlayer({ url, poster }: { url: string; poster: string }) {
|
||||
const [play, setPlay] = useState(false);
|
||||
|
||||
if (!play) {
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-video"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPlay(true);
|
||||
}}>
|
||||
<ProxyImg className="absolute" src={poster} />
|
||||
<div className="absolute w-full h-full opacity-0 hover:opacity-100 hover:bg-black/30 flex items-center justify-center transition">
|
||||
<Icon name="play-square-outline" size={50} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
className="aspect-video w-full"
|
||||
src={url}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
);
|
||||
}
|
@ -46,15 +46,22 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
|
||||
|
||||
if (youtubeId) {
|
||||
return (
|
||||
<iframe
|
||||
className="-mx-4 md:mx-0 w-max my-2"
|
||||
src={`https://www.youtube.com/embed/${youtubeId}`}
|
||||
title="YouTube video player"
|
||||
key={youtubeId}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
<>
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
className="-mx-4 md:mx-0 w-max my-2"
|
||||
src={`https://www.youtube.com/embed/${youtubeId}`}
|
||||
title="YouTube video player"
|
||||
key={youtubeId}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
||||
{a}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
} else if (tidalId) {
|
||||
return <TidalEmbed link={a} />;
|
||||
|
@ -7,7 +7,7 @@ import { useMemo } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import SendSats from "@/Components/SendSats/SendSats";
|
||||
import ZapModal from "@/Components/ZapModal/ZapModal";
|
||||
import { useWallet } from "@/Wallet";
|
||||
|
||||
import messages from "../messages";
|
||||
@ -36,7 +36,7 @@ export default function Invoice(props: InvoiceProps) {
|
||||
<FormattedMessage {...messages.Invoice} />
|
||||
</h4>
|
||||
<Icon name="zapCircle" className="zap-circle" />
|
||||
<SendSats
|
||||
<ZapModal
|
||||
title={formatMessage(messages.PayInvoice)}
|
||||
invoice={invoice}
|
||||
show={showInvoice}
|
||||
|
@ -38,7 +38,9 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link-preview-image {
|
||||
.link-preview-container img,
|
||||
.link-preview-container video,
|
||||
.link-preview-container iframe {
|
||||
margin: 0 0 15px 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background-image: var(--img-url);
|
||||
|
@ -1,14 +1,17 @@
|
||||
import "./LinkPreview.css";
|
||||
|
||||
import { CSSProperties, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LRUCache } from "typescript-lru-cache";
|
||||
|
||||
import { MediaElement } from "@/Components/Embed/MediaElement";
|
||||
import Spinner from "@/Components/Icons/Spinner";
|
||||
import SnortApi, { LinkPreviewData } from "@/External/SnortApi";
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import { LinkPreviewData, NostrServices } from "@/External/NostrServices";
|
||||
|
||||
import { ProxyImg } from "../ProxyImg";
|
||||
import GenericPlayer from "./GenericPlayer";
|
||||
|
||||
async function fetchUrlPreviewInfo(url: string) {
|
||||
const api = new SnortApi();
|
||||
const api = new NostrServices("https://nostr.api.v0l.io");
|
||||
try {
|
||||
return await api.linkPreview(url.endsWith(")") ? url.slice(0, -1) : url);
|
||||
} catch (e) {
|
||||
@ -16,18 +19,23 @@ async function fetchUrlPreviewInfo(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new LRUCache<string, LinkPreviewData>({
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
const LinkPreview = ({ url }: { url: string }) => {
|
||||
const [preview, setPreview] = useState<LinkPreviewData | null>();
|
||||
const { proxy } = useImgProxy();
|
||||
const [preview, setPreview] = useState<LinkPreviewData | null>(cache.get(url));
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (preview) return;
|
||||
const data = await fetchUrlPreviewInfo(url);
|
||||
if (data) {
|
||||
const type = data.og_tags?.find(a => a[0].toLowerCase() === "og:type");
|
||||
const canPreviewType = type?.[1].startsWith("image") || type?.[1].startsWith("video") || false;
|
||||
if (canPreviewType || data.image) {
|
||||
setPreview(data);
|
||||
cache.set(url, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -49,9 +57,12 @@ const LinkPreview = ({ url }: { url: string }) => {
|
||||
const urlTags = ["og:video:secure_url", "og:video:url", "og:video"];
|
||||
const link = preview?.og_tags?.find(a => urlTags.includes(a[0].toLowerCase()))?.[1];
|
||||
const videoType = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:video:type")?.[1] ?? "video/mp4";
|
||||
if (link) {
|
||||
if (link && videoType.startsWith("video/")) {
|
||||
return <MediaElement url={link} mime={videoType} />;
|
||||
}
|
||||
if (link && videoType.startsWith("text/html") && preview?.image) {
|
||||
return <GenericPlayer url={link} poster={preview?.image} />;
|
||||
}
|
||||
}
|
||||
if (type?.startsWith("image")) {
|
||||
const urlTags = ["og:image:secure_url", "og:image:url", "og:image"];
|
||||
@ -62,9 +73,7 @@ const LinkPreview = ({ url }: { url: string }) => {
|
||||
}
|
||||
}
|
||||
if (preview?.image) {
|
||||
const backgroundImage = preview?.image ? `url(${proxy(preview?.image)})` : "";
|
||||
const style = { "--img-url": backgroundImage } as CSSProperties;
|
||||
return <div className="link-preview-image" style={style}></div>;
|
||||
return <ProxyImg src={preview?.image} className="w-full object-cover aspect-video" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ interface MediaElementProps {
|
||||
url: string;
|
||||
meta?: IMeta;
|
||||
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface AudioElementProps {
|
||||
@ -25,6 +26,7 @@ interface VideoElementProps {
|
||||
interface ImageElementProps {
|
||||
url: string;
|
||||
meta?: IMeta;
|
||||
size?: number;
|
||||
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
|
||||
}
|
||||
|
||||
@ -32,7 +34,7 @@ const AudioElement = ({ url }: AudioElementProps) => {
|
||||
return <audio key={url} src={url} controls />;
|
||||
};
|
||||
|
||||
const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
|
||||
const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const style = useMemo(() => {
|
||||
const style = {} as CSSProperties;
|
||||
@ -47,10 +49,12 @@ const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
|
||||
<div
|
||||
className={classNames("flex items-center -mx-4 md:mx-0 my-2", {
|
||||
"md:h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
|
||||
"cursor-pointer": onMediaClick,
|
||||
})}>
|
||||
<ProxyImg
|
||||
key={url}
|
||||
src={url}
|
||||
size={size}
|
||||
sha256={meta?.sha256}
|
||||
onClick={onMediaClick}
|
||||
className={classNames("max-h-[80vh] w-full h-full object-contain object-center", {
|
||||
@ -87,6 +91,7 @@ const VideoElement = ({ url }: VideoElementProps) => {
|
||||
"md:h-[510px]": !CONFIG.media.preferLargeMedia,
|
||||
})}>
|
||||
<video
|
||||
crossOrigin="anonymous"
|
||||
ref={videoRef}
|
||||
loop={true}
|
||||
muted={!isMobile}
|
||||
@ -102,7 +107,7 @@ const VideoElement = ({ url }: VideoElementProps) => {
|
||||
|
||||
export function MediaElement(props: MediaElementProps) {
|
||||
if (props.mime.startsWith("image/")) {
|
||||
return <ImageElement url={props.url} meta={props.meta} onMediaClick={props.onMediaClick} />;
|
||||
return <ImageElement url={props.url} meta={props.meta} onMediaClick={props.onMediaClick} size={props.size} />;
|
||||
} else if (props.mime.startsWith("audio/")) {
|
||||
return <AudioElement url={props.url} />;
|
||||
} else if (props.mime.startsWith("video/")) {
|
||||
|
@ -4,7 +4,7 @@ import { MixCloudRegex } from "@/Utils/Const";
|
||||
const MixCloudEmbed = ({ link }: { link: string }) => {
|
||||
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
|
||||
|
||||
const theme = useLogin(s => s.appData.item.preferences.theme);
|
||||
const theme = useLogin(s => s.appData.json.preferences.theme);
|
||||
const lightParams = theme === "light" ? "light=1" : "light=0";
|
||||
return (
|
||||
<>
|
||||
@ -16,6 +16,9 @@ const MixCloudEmbed = ({ link }: { link: string }) => {
|
||||
frameBorder="0"
|
||||
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
|
||||
/>
|
||||
<a href={link} target="_blank" rel="noreferrer">
|
||||
{link}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -8,10 +8,6 @@ export default function NostrLink({ link, depth }: { link: string; depth?: numbe
|
||||
const nav = tryParseNostrLink(link);
|
||||
|
||||
if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
|
||||
if (nav.id.startsWith("npub")) {
|
||||
// eslint-disable-next-line no-debugger
|
||||
debugger;
|
||||
}
|
||||
return <Mention link={nav} />;
|
||||
} else if (nav?.type === NostrPrefix.Note || nav?.type === NostrPrefix.Event || nav?.type === NostrPrefix.Address) {
|
||||
if ((depth ?? 0) > 0) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { LNURL } from "@snort/shared";
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { WalletInvoiceState } from "@snort/wallet";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
|
||||
import { UserCache } from "@/Cache";
|
||||
@ -10,7 +11,6 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils";
|
||||
import { useWallet } from "@/Wallet";
|
||||
import { WalletInvoiceState } from "@/Wallet";
|
||||
|
||||
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
|
||||
const wallet = useWallet();
|
||||
@ -22,7 +22,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
|
||||
for (const pk of ids) {
|
||||
try {
|
||||
const profile = await UserCache.get(pk);
|
||||
const amtSend = login.appData.item.preferences.defaultZapAmount;
|
||||
const amtSend = login.appData.json.preferences.defaultZapAmount;
|
||||
const lnurl = profile?.lud16 || profile?.lud06;
|
||||
if (lnurl) {
|
||||
const svc = new LNURL(lnurl);
|
||||
@ -74,7 +74,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
|
||||
defaultMessage="Zap all {n} sats"
|
||||
id="IVbtTS"
|
||||
values={{
|
||||
n: <FormattedNumber value={login.appData.item.preferences.defaultZapAmount * ids.length} />,
|
||||
n: <FormattedNumber value={login.appData.json.preferences.defaultZapAmount * ids.length} />,
|
||||
}}
|
||||
/>
|
||||
</AsyncButton>
|
||||
|
@ -1,11 +1,19 @@
|
||||
const SoundCloudEmbed = ({ link }: { link: string }) => {
|
||||
return (
|
||||
<iframe
|
||||
width="100%"
|
||||
height="166"
|
||||
scrolling="no"
|
||||
allow="autoplay"
|
||||
src={`https://w.soundcloud.com/player/?url=${link}`}></iframe>
|
||||
<>
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
width="100%"
|
||||
height="166"
|
||||
scrolling="no"
|
||||
allow="autoplay"
|
||||
src={`https://w.soundcloud.com/player/?url=${link}`}
|
||||
/>
|
||||
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
|
||||
{link}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -2,14 +2,22 @@ const SpotifyEmbed = ({ link }: { link: string }) => {
|
||||
const convertedUrl = link.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
|
||||
|
||||
return (
|
||||
<iframe
|
||||
style={{ borderRadius: 12 }}
|
||||
src={convertedUrl}
|
||||
width="100%"
|
||||
height="352"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"></iframe>
|
||||
<>
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
style={{ borderRadius: 12 }}
|
||||
src={convertedUrl}
|
||||
width="100%"
|
||||
height="352"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"
|
||||
/>
|
||||
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
|
||||
{link}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -46,13 +46,25 @@ const TidalEmbed = ({ link }: { link: string }) => {
|
||||
.catch(console.error);
|
||||
}, [link]);
|
||||
|
||||
if (!source)
|
||||
if (!source) {
|
||||
return (
|
||||
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
|
||||
{link}
|
||||
</a>
|
||||
);
|
||||
return <iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} />;
|
||||
}
|
||||
const iframe = (
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
<iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} credentialless="" />
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{iframe}
|
||||
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
|
||||
{link}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TidalEmbed;
|
||||
|
@ -2,7 +2,14 @@ const TwitchEmbed = ({ link }: { link: string }) => {
|
||||
const channel = link.split("/").slice(-1);
|
||||
|
||||
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
|
||||
return <iframe src={`https://player.twitch.tv/${args}`} className="w-max" allowFullScreen={true}></iframe>;
|
||||
return (
|
||||
<>
|
||||
<iframe src={`https://player.twitch.tv/${args}`} className="w-max" allowFullScreen={true} />
|
||||
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
|
||||
{link}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwitchEmbed;
|
||||
|
@ -2,13 +2,21 @@ const WavlakeEmbed = ({ link }: { link: string }) => {
|
||||
const convertedUrl = link.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
|
||||
|
||||
return (
|
||||
<iframe
|
||||
style={{ borderRadius: 12 }}
|
||||
src={convertedUrl}
|
||||
width="100%"
|
||||
height="380"
|
||||
frameBorder="0"
|
||||
loading="lazy"></iframe>
|
||||
<>
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
style={{ borderRadius: 12 }}
|
||||
src={convertedUrl}
|
||||
width="100%"
|
||||
height="380"
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
|
||||
{link}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
import {trackEvent} from "@/Utils";
|
||||
import { trackEvent } from "@/Utils";
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
@ -18,7 +19,7 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, errorMessage: error.message };
|
||||
return { hasError: true, errorMessage: error.message, stack: error.stack };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
@ -33,6 +34,7 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
|
||||
<div className="p-2">
|
||||
<h1>Something went wrong.</h1>
|
||||
<p>Error: {this.state.errorMessage}</p>
|
||||
<pre className="text-xs overflow-auto mt-8">{this.state.stack}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -36,7 +36,6 @@
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
margin: 8px 12px;
|
||||
background-color: var(--gray-superdark);
|
||||
min-height: 100px;
|
||||
width: stretch;
|
||||
width: -webkit-fill-available;
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines */
|
||||
import "./NoteCreator.css";
|
||||
|
||||
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
|
||||
@ -41,7 +42,6 @@ const replyToNoteOptions = {
|
||||
showProfileCard: false,
|
||||
showTime: false,
|
||||
canClick: false,
|
||||
showMedia: false,
|
||||
longFormPreview: true,
|
||||
};
|
||||
|
||||
@ -50,14 +50,13 @@ const quoteNoteOptions = {
|
||||
showContextMenu: false,
|
||||
showTime: false,
|
||||
canClick: false,
|
||||
showMedia: false,
|
||||
longFormPreview: true,
|
||||
};
|
||||
|
||||
export function NoteCreator() {
|
||||
const { formatMessage } = useIntl();
|
||||
const uploader = useFileUpload();
|
||||
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.item.preferences.pow }));
|
||||
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.json.preferences.pow }));
|
||||
const { system, publisher: pub } = useEventPublisher();
|
||||
const publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub;
|
||||
const note = useNoteCreator();
|
||||
@ -317,7 +316,9 @@ export function NoteCreator() {
|
||||
|
||||
function getPreviewNote() {
|
||||
if (note.preview) {
|
||||
return <Note data={note.preview as TaggedNostrEvent} options={previewNoteOptions} />;
|
||||
return (
|
||||
<Note className="hover:bg-transparent" data={note.preview as TaggedNostrEvent} options={previewNoteOptions} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -614,7 +615,10 @@ export function NoteCreator() {
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Reply To" id="8ED/4u" />
|
||||
</h4>
|
||||
<Note data={note.replyTo} options={replyToNoteOptions} />
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<Note className="hover:bg-transparent" data={note.replyTo} options={replyToNoteOptions} />
|
||||
</div>
|
||||
<hr className="border-border-color border-1 -mx-6" />
|
||||
</>
|
||||
)}
|
||||
{note.quote && (
|
||||
@ -622,7 +626,10 @@ export function NoteCreator() {
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
|
||||
</h4>
|
||||
<Note data={note.quote} options={quoteNoteOptions} />
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<Note className="hover:bg-transparent" data={note.quote} options={quoteNoteOptions} />
|
||||
</div>
|
||||
<hr className="border-border-color border-1 -mx-6" />
|
||||
</>
|
||||
)}
|
||||
{note.preview && getPreviewNote()}
|
||||
|
@ -8,7 +8,7 @@ import IconButton from "@/Components/Button/IconButton";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { saveRelays } from "@/Pages/settings/Relays";
|
||||
import { saveRelays } from "@/Pages/settings/saveRelays";
|
||||
import { getRelayName } from "@/Utils";
|
||||
import { removeRelay } from "@/Utils/Login";
|
||||
|
||||
|
@ -8,7 +8,7 @@ export async function sendEventToRelays(
|
||||
setResults?: (x: Array<OkResponse>) => void,
|
||||
) {
|
||||
if (customRelays) {
|
||||
system.HandleEvent({ ...ev, relays: [] });
|
||||
system.HandleEvent("*", { ...ev, relays: [] });
|
||||
return removeUndefined(
|
||||
await Promise.all(
|
||||
customRelays.map(async r => {
|
||||
|
@ -57,21 +57,6 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.note .footer .footer-reactions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.note .footer .footer-reactions {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.note > .header img:hover,
|
||||
.note > .header .name > .reply:hover {
|
||||
cursor: pointer;
|
||||
@ -115,13 +100,7 @@
|
||||
}
|
||||
|
||||
.reaction-pill {
|
||||
display: flex;
|
||||
min-width: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
font-feature-settings: "tnum";
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.reaction-pill:not(.reacted):not(:hover) {
|
||||
@ -137,15 +116,6 @@
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.hidden-note .header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card.note.hidden-note {
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.expand-note {
|
||||
padding: 0 0 16px 0;
|
||||
font-weight: 400;
|
||||
|
@ -41,7 +41,7 @@ export interface NoteProps {
|
||||
ignoreModeration?: boolean;
|
||||
onClick?: (e: TaggedNostrEvent) => void;
|
||||
depth?: number;
|
||||
searchedValue?: string;
|
||||
highlightText?: string;
|
||||
threadChains?: Map<string, Array<NostrEvent>>;
|
||||
context?: ReactNode;
|
||||
options?: NotePropsOptions;
|
||||
|
@ -1,22 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import messages from "../messages";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
|
||||
const hideMutedNotes = useLogin(s => s.appData.json.preferences.hideMutedNotes);
|
||||
const [show, setShow] = useState(false);
|
||||
if (hideMutedNotes) return;
|
||||
|
||||
return show ? (
|
||||
children
|
||||
) : (
|
||||
<div className="card note hidden-note">
|
||||
<div className="header">
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="This note has been muted" id="qfmMQh" />
|
||||
</p>
|
||||
<button type="button" onClick={() => setShow(true)}>
|
||||
<FormattedMessage {...messages.Show} />
|
||||
</button>
|
||||
<div className="bb p flex items-center justify-between">
|
||||
<div className="text-sm text-secondary">
|
||||
<FormattedMessage defaultMessage="This note has been muted" id="qfmMQh" />
|
||||
</div>
|
||||
<button className="btn btn-sm btn-neutral" onClick={() => setShow(true)}>
|
||||
<FormattedMessage defaultMessage="Show" id="K7AkdL" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
40
packages/app/src/Components/Event/LoadMore.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import usePageDimensions from "@/Hooks/usePageDimensions";
|
||||
import { debounce } from "@/Utils";
|
||||
|
||||
interface ShowMoreProps {
|
||||
text?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const LoadMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick}>
|
||||
{text || <FormattedMessage defaultMessage="Load more" id="00LcfG" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadMore;
|
||||
|
||||
export function AutoLoadMore({ text, onClick, className }: ShowMoreProps) {
|
||||
const { ref, inView } = useInView({ rootMargin: "1000px" });
|
||||
const { height } = usePageDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
// TODO improve feed performance. Something in image grid makes it slow when feed size grows.
|
||||
return debounce(100, onClick);
|
||||
}
|
||||
}, [inView, height]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<LoadMore onClick={onClick} text={text} className={className} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -46,22 +46,3 @@
|
||||
width: -webkit-fill-available;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.long-form-note .footer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.long-form-note .footer .footer-reactions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.long-form-note .footer .footer-reactions {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import { findTag } from "@/Utils";
|
||||
|
||||
import { Markdown } from "./Markdown";
|
||||
import NoteFooter from "./Note/NoteFooter";
|
||||
import NoteFooter from "./Note/NoteFooter/NoteFooter";
|
||||
import NoteTime from "./Note/NoteTime";
|
||||
|
||||
interface LongFormTextProps {
|
||||
@ -32,13 +32,8 @@ export function LongFormText(props: LongFormTextProps) {
|
||||
const [reading, setReading] = useState(false);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const related = useReactions(
|
||||
NostrLink.fromEvent(props.ev).id + "related",
|
||||
[NostrLink.fromEvent(props.ev)],
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), related.data ?? []);
|
||||
const related = useReactions("note:reactions", [NostrLink.fromEvent(props.ev)], undefined, false);
|
||||
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), related);
|
||||
|
||||
function previewText() {
|
||||
return (
|
||||
@ -154,7 +149,9 @@ export function LongFormText(props: LongFormTextProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames("long-form-note flex flex-col g16 p break-words")}>
|
||||
<div
|
||||
className={classNames("long-form-note flex flex-col g16 p break-words", { "cursor-pointer": props.isPreview })}
|
||||
onClick={props.onClick}>
|
||||
<ProfilePreview
|
||||
pubkey={props.ev.pubkey}
|
||||
actions={
|
||||
|
@ -10,8 +10,8 @@ import { findTag } from "@/Utils";
|
||||
export default function NostrFileHeader({ link }: { link: NostrLink }) {
|
||||
const ev = useEventFeed(link);
|
||||
|
||||
if (!ev.data) return <PageSpinner />;
|
||||
return <NostrFileElement ev={ev.data} />;
|
||||
if (!ev) return <PageSpinner />;
|
||||
return <NostrFileElement ev={ev} />;
|
||||
}
|
||||
|
||||
export function NostrFileElement({ ev }: { ev: NostrEvent }) {
|
||||
|
@ -1,24 +1,28 @@
|
||||
import { EventKind, NostrLink } from "@snort/system";
|
||||
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LRUCache } from "typescript-lru-cache";
|
||||
|
||||
import { Relay } from "@/Cache";
|
||||
import NoteHeader from "@/Components/Event/Note/NoteHeader";
|
||||
import NoteQuote from "@/Components/Event/Note/NoteQuote";
|
||||
import { NoteText } from "@/Components/Event/Note/NoteText";
|
||||
import { TranslationInfo } from "@/Components/Event/Note/TranslationInfo";
|
||||
import { NoteTranslation } from "@/Components/Event/Note/types";
|
||||
import Username from "@/Components/User/Username";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
import { chainKey } from "@/Hooks/useThreadContext";
|
||||
import { findTag } from "@/Utils";
|
||||
import { chainKey } from "@/Utils/Thread/ChainKey";
|
||||
|
||||
import messages from "../../messages";
|
||||
import Text from "../../Text/Text";
|
||||
import { NoteProps } from "../EventComponent";
|
||||
import HiddenNote from "../HiddenNote";
|
||||
import Poll from "../Poll";
|
||||
import { NoteTranslation } from "./NoteContextMenu";
|
||||
import NoteFooter from "./NoteFooter";
|
||||
import NoteFooter from "./NoteFooter/NoteFooter";
|
||||
|
||||
const defaultOptions = {
|
||||
showHeader: true,
|
||||
@ -30,14 +34,33 @@ const defaultOptions = {
|
||||
};
|
||||
|
||||
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
|
||||
const translationCache = new LRUCache<string, NoteTranslation>({ maxSize: 300 });
|
||||
|
||||
export function Note(props: NoteProps) {
|
||||
const { data: ev, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
|
||||
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className ?? "");
|
||||
const { isEventMuted } = useModeration();
|
||||
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
|
||||
const { ref: setSeenAtRef, inView: setSeenAtInView } = useInView({ rootMargin: "0px", threshold: 1 });
|
||||
const [showTranslation, setShowTranslation] = useState(true);
|
||||
const [translated, setTranslated] = useState<NoteTranslation>();
|
||||
const [translated, setTranslated] = useState<NoteTranslation>(translationCache.get(ev.id));
|
||||
const cachedSetTranslated = useCallback(
|
||||
(translation: NoteTranslation) => {
|
||||
translationCache.set(ev.id, translation);
|
||||
setTranslated(translation);
|
||||
},
|
||||
[ev.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
if (setSeenAtInView) {
|
||||
timeout = setTimeout(() => {
|
||||
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearTimeout(timeout);
|
||||
}, [setSeenAtInView]);
|
||||
|
||||
const optionsMerged = { ...defaultOptions, ...opt };
|
||||
const goToEvent = useGoToEvent(props, optionsMerged);
|
||||
@ -50,13 +73,24 @@ export function Note(props: NoteProps) {
|
||||
if (waitUntilInView && !inView) return null;
|
||||
return (
|
||||
<>
|
||||
{optionsMerged.showHeader && <NoteHeader ev={ev} options={optionsMerged} setTranslated={setTranslated} />}
|
||||
{optionsMerged.showHeader && (
|
||||
<NoteHeader
|
||||
ev={ev}
|
||||
options={optionsMerged}
|
||||
setTranslated={translated === null ? cachedSetTranslated : undefined}
|
||||
/>
|
||||
)}
|
||||
<div className="body" onClick={e => goToEvent(e, ev)}>
|
||||
<NoteText {...props} translated={translated} showTranslation={showTranslation} />
|
||||
{translated && <TranslationInfo translated={translated} setShowTranslation={setShowTranslation} />}
|
||||
{ev.kind === EventKind.Polls && <Poll ev={ev} />}
|
||||
{ev.kind === EventKind.Polls && <Poll ev={ev} zaps={[]} />}
|
||||
{optionsMerged.showFooter && (
|
||||
<div className="mt-4">
|
||||
<NoteFooter ev={ev} replyCount={props.threadChains?.get(chainKey(ev))?.length} />
|
||||
</div>
|
||||
)}
|
||||
<div ref={setSeenAtRef} />
|
||||
</div>
|
||||
{optionsMerged.showFooter && <NoteFooter ev={ev} replies={props.threadChains?.get(chainKey(ev))?.length} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -114,7 +148,26 @@ function useGoToEvent(props, options) {
|
||||
);
|
||||
}
|
||||
|
||||
function handleNonTextNote(ev) {
|
||||
function Reaction({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const reactedToTag = ev.tags.findLast(tag => tag[0] === "e");
|
||||
const pTag = ev.tags.findLast(tag => tag[0] === "p");
|
||||
if (!reactedToTag?.length) {
|
||||
return null;
|
||||
}
|
||||
const link = NostrLink.fromTag(reactedToTag, pTag?.[1]);
|
||||
return (
|
||||
<div className="note card">
|
||||
<div className="text-gray-medium font-bold">
|
||||
<Username pubkey={ev.pubkey} onLinkVisit={() => {}} />
|
||||
<span> </span>
|
||||
<FormattedMessage defaultMessage="liked" id="TvKqBp" />
|
||||
</div>
|
||||
<NoteQuote link={link} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleNonTextNote(ev: TaggedNostrEvent) {
|
||||
const alt = findTag(ev, "alt");
|
||||
if (alt) {
|
||||
return (
|
||||
@ -122,6 +175,8 @@ function handleNonTextNote(ev) {
|
||||
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
} else if (ev.kind === EventKind.Reaction) {
|
||||
return <Reaction ev={ev} />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
|
||||
import { HexKey, NostrLink, NostrPrefix } from "@snort/system";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import { NoteContextMenuProps, NoteTranslation } from "@/Components/Event/Note/types";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import messages from "@/Components/messages";
|
||||
import SnortApi from "@/External/SnortApi";
|
||||
@ -14,20 +15,7 @@ import { getCurrentSubscription, SubscriptionType } from "@/Utils/Subscription";
|
||||
|
||||
import { ReBroadcaster } from "../../ReBroadcaster";
|
||||
|
||||
export interface NoteTranslation {
|
||||
text: string;
|
||||
fromLanguage: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface NosteContextMenuProps {
|
||||
ev: TaggedNostrEvent;
|
||||
setShowReactions(b: boolean): void;
|
||||
react(content: string): Promise<void>;
|
||||
onTranslated?: (t: NoteTranslation) => void;
|
||||
}
|
||||
|
||||
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
|
||||
export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const login = useLogin();
|
||||
const { mute, block } = useModeration();
|
||||
@ -60,6 +48,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
if (!props.onTranslated) return;
|
||||
const api = new SnortApi();
|
||||
const targetLang = lang.split("-")[0].toUpperCase();
|
||||
const result = await api.translate({
|
||||
@ -67,24 +56,29 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
|
||||
target_lang: targetLang,
|
||||
});
|
||||
|
||||
if ("translations" in result) {
|
||||
if (
|
||||
typeof props.onTranslated === "function" &&
|
||||
result.translations.length > 0 &&
|
||||
targetLang != result.translations[0].detected_source_language
|
||||
) {
|
||||
props.onTranslated({
|
||||
text: result.translations[0].text,
|
||||
fromLanguage: langNames.of(result.translations[0].detected_source_language),
|
||||
confidence: 1,
|
||||
} as NoteTranslation);
|
||||
}
|
||||
if (
|
||||
"translations" in result &&
|
||||
result.translations.length > 0 &&
|
||||
targetLang != result.translations[0].detected_source_language
|
||||
) {
|
||||
props.onTranslated({
|
||||
text: result.translations[0].text,
|
||||
fromLanguage: langNames.of(result.translations[0].detected_source_language),
|
||||
confidence: 1,
|
||||
} as NoteTranslation);
|
||||
} else {
|
||||
props.onTranslated({
|
||||
text: "",
|
||||
fromLanguage: "",
|
||||
confidence: 0,
|
||||
skipped: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sub = getCurrentSubscription(login.subscriptions);
|
||||
if (sub?.type === SubscriptionType.Premium && (login.appData.item.preferences.autoTranslate ?? true)) {
|
||||
if (sub?.type === SubscriptionType.Premium && (login.appData.json.preferences.autoTranslate ?? true)) {
|
||||
translate();
|
||||
}
|
||||
}, []);
|
||||
@ -106,10 +100,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
|
||||
async function bookmark(id: string) {
|
||||
if (publisher) {
|
||||
const es = [...login.bookmarked.item, id];
|
||||
const ev = await publisher.bookmarks(
|
||||
es.map(a => new NostrLink(NostrPrefix.Note, a)),
|
||||
"bookmark",
|
||||
);
|
||||
const ev = await publisher.bookmarks(es.map(a => new NostrLink(NostrPrefix.Note, a)));
|
||||
system.BroadcastEvent(ev);
|
||||
setBookmarked(login, es, ev.created_at * 1000);
|
||||
}
|
||||
@ -163,12 +154,6 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
|
||||
<FormattedMessage {...messages.Mute} />
|
||||
</MenuItem>
|
||||
)}
|
||||
{login.appData.item.preferences.enableReactions && !login.readonly && (
|
||||
<MenuItem onClick={() => props.react("-")}>
|
||||
<Icon name="dislike" />
|
||||
<FormattedMessage {...messages.DislikeAction} />
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleReBroadcastButtonClick}>
|
||||
<Icon name="relay" />
|
||||
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
|
||||
|
@ -1,322 +0,0 @@
|
||||
import { barrierQueue, normalizeReaction, processWorkQueue, WorkQueueItem } from "@snort/shared";
|
||||
import { countLeadingZeros, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { useEventReactions, useReactions, useUserProfile } from "@snort/system-react";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import classNames from "classnames";
|
||||
import React, { forwardRef, useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useLongPress } from "use-long-press";
|
||||
|
||||
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
|
||||
import { ZapsSummary } from "@/Components/Event/Zap";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import SendSats from "@/Components/SendSats/SendSats";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import { useInteractionCache } from "@/Hooks/useInteractionCache";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { useNoteCreator } from "@/State/NoteCreator";
|
||||
import { findTag, getDisplayName } from "@/Utils";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
import { Zapper, ZapTarget } from "@/Utils/Zapper";
|
||||
import { ZapPoolController } from "@/Utils/ZapPoolController";
|
||||
import { useWallet } from "@/Wallet";
|
||||
|
||||
import messages from "../../messages";
|
||||
|
||||
const ZapperQueue: Array<WorkQueueItem> = [];
|
||||
processWorkQueue(ZapperQueue);
|
||||
|
||||
export interface NoteFooterProps {
|
||||
replies?: number;
|
||||
ev: TaggedNostrEvent;
|
||||
}
|
||||
|
||||
export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { ev } = props;
|
||||
const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]);
|
||||
const ids = useMemo(() => [link], [link]);
|
||||
|
||||
const related = useReactions(link.id + "related", ids, undefined, false);
|
||||
const { reactions, zaps, reposts } = useEventReactions(link, related.data ?? []);
|
||||
const { positive } = reactions;
|
||||
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
publicKey,
|
||||
preferences: prefs,
|
||||
readonly,
|
||||
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, readonly: s.readonly }));
|
||||
const author = useUserProfile(ev.pubkey);
|
||||
const interactionCache = useInteractionCache(publicKey, ev.id);
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
|
||||
const [tip, setTip] = useState(false);
|
||||
const [zapping, setZapping] = useState(false);
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
|
||||
const canFastZap = wallet?.isReady() && !readonly;
|
||||
const isMine = ev.pubkey === publicKey;
|
||||
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
|
||||
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
|
||||
const longPress = useLongPress(
|
||||
e => {
|
||||
e.stopPropagation();
|
||||
setTip(true);
|
||||
},
|
||||
{
|
||||
captureEvent: true,
|
||||
},
|
||||
);
|
||||
|
||||
function hasReacted(emoji: string) {
|
||||
return (
|
||||
interactionCache.data.reacted ||
|
||||
positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey)
|
||||
);
|
||||
}
|
||||
|
||||
function hasReposted() {
|
||||
return interactionCache.data.reposted || reposts.some(a => a.pubkey === publicKey);
|
||||
}
|
||||
|
||||
async function react(content: string) {
|
||||
if (!hasReacted(content) && publisher) {
|
||||
const evLike = await publisher.react(ev, content);
|
||||
system.BroadcastEvent(evLike);
|
||||
interactionCache.react();
|
||||
}
|
||||
}
|
||||
|
||||
async function repost() {
|
||||
if (!hasReposted() && publisher) {
|
||||
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
|
||||
const evRepost = await publisher.repost(ev);
|
||||
system.BroadcastEvent(evRepost);
|
||||
await interactionCache.repost();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getZapTarget(): Array<ZapTarget> | undefined {
|
||||
if (ev.tags.some(v => v[0] === "zap")) {
|
||||
return Zapper.fromEvent(ev);
|
||||
}
|
||||
|
||||
const authorTarget = author?.lud16 || author?.lud06;
|
||||
if (authorTarget) {
|
||||
return [
|
||||
{
|
||||
type: "lnurl",
|
||||
value: authorTarget,
|
||||
weight: 1,
|
||||
name: getDisplayName(author, ev.pubkey),
|
||||
zap: {
|
||||
pubkey: ev.pubkey,
|
||||
event: link,
|
||||
},
|
||||
} as ZapTarget,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function fastZap(e?: React.MouseEvent) {
|
||||
if (zapping || e?.isPropagationStopped()) return;
|
||||
|
||||
const lnurl = getZapTarget();
|
||||
if (canFastZap && lnurl) {
|
||||
setZapping(true);
|
||||
try {
|
||||
await fastZapInner(lnurl, prefs.defaultZapAmount);
|
||||
} catch (e) {
|
||||
console.warn("Fast zap failed", e);
|
||||
if (!(e instanceof Error) || e.message !== "User rejected") {
|
||||
setTip(true);
|
||||
}
|
||||
} finally {
|
||||
setZapping(false);
|
||||
}
|
||||
} else {
|
||||
setTip(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
|
||||
if (wallet) {
|
||||
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
|
||||
await barrierQueue(ZapperQueue, async () => {
|
||||
const zapper = new Zapper(system, publisher);
|
||||
const result = await zapper.send(wallet, targets, amount);
|
||||
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
|
||||
if (totalSent > 0) {
|
||||
if (CONFIG.features.zapPool) {
|
||||
ZapPoolController?.allocate(totalSent);
|
||||
}
|
||||
await interactionCache.zap();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (prefs.autoZap && !didZap && !isMine && !zapping) {
|
||||
const lnurl = getZapTarget();
|
||||
if (wallet?.isReady() && lnurl) {
|
||||
setZapping(true);
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
await fastZapInner(lnurl, prefs.defaultZapAmount);
|
||||
} catch {
|
||||
// ignored
|
||||
} finally {
|
||||
setZapping(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [prefs.autoZap, author, zapping]);
|
||||
|
||||
function powIcon() {
|
||||
const pow = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
|
||||
if (pow) {
|
||||
return (
|
||||
<AsyncFooterIcon
|
||||
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
|
||||
iconName="diamond"
|
||||
value={pow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function tipButton() {
|
||||
const targets = getZapTarget();
|
||||
if (targets) {
|
||||
return (
|
||||
<AsyncFooterIcon
|
||||
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
|
||||
{...longPress()}
|
||||
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
|
||||
iconName={canFastZap ? "zapFast" : "zap"}
|
||||
value={zapTotal}
|
||||
onClick={e => fastZap(e)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function repostIcon() {
|
||||
if (readonly) return;
|
||||
return (
|
||||
<Menu
|
||||
menuButton={
|
||||
<AsyncFooterIcon
|
||||
className={hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue"}
|
||||
iconName="repeat"
|
||||
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
|
||||
value={reposts.length}
|
||||
/>
|
||||
}
|
||||
menuClassName="ctx-menu"
|
||||
align="start">
|
||||
<div className="close-menu-container">
|
||||
{/* This menu item serves as a "close menu" button;
|
||||
it allows the user to click anywhere nearby the menu to close it. */}
|
||||
<MenuItem>
|
||||
<div className="close-menu" />
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem onClick={() => repost()} disabled={hasReposted()}>
|
||||
<Icon name="repeat" />
|
||||
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
note.update(n => {
|
||||
n.reset();
|
||||
n.quote = ev;
|
||||
n.show = true;
|
||||
})
|
||||
}>
|
||||
<Icon name="edit" />
|
||||
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function reactionIcon() {
|
||||
if (!prefs.enableReactions) {
|
||||
return null;
|
||||
}
|
||||
const reacted = hasReacted("+");
|
||||
return (
|
||||
<AsyncFooterIcon
|
||||
className={reacted ? "reacted text-nostr-red" : "hover:text-nostr-red"}
|
||||
iconName={reacted ? "heart-solid" : "heart"}
|
||||
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
|
||||
value={positive.length}
|
||||
onClick={async () => {
|
||||
if (readonly) return;
|
||||
await react(prefs.reactionEmoji);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function replyIcon() {
|
||||
if (readonly) return;
|
||||
return (
|
||||
<AsyncFooterIcon
|
||||
className={note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple"}
|
||||
iconName="reply"
|
||||
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
|
||||
value={props.replies ?? 0}
|
||||
onClick={async () => handleReplyButtonClick()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleReplyButtonClick = () => {
|
||||
note.update(v => {
|
||||
if (v.replyTo?.id !== ev.id) {
|
||||
v.reset();
|
||||
}
|
||||
v.show = true;
|
||||
v.replyTo = ev;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="footer">
|
||||
<div className="footer-reactions">
|
||||
{replyIcon()}
|
||||
{repostIcon()}
|
||||
{reactionIcon()}
|
||||
{tipButton()}
|
||||
{powIcon()}
|
||||
</div>
|
||||
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
|
||||
</div>
|
||||
<ZapsSummary zaps={zaps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, ref) => {
|
||||
const mergedProps = {
|
||||
...props,
|
||||
iconSize: 18,
|
||||
className: classNames("transition duration-200 ease-in-out reaction-pill cursor-pointer", props.className),
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncIcon ref={ref} {...mergedProps}>
|
||||
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
|
||||
</AsyncIcon>
|
||||
);
|
||||
});
|
||||
|
||||
AsyncFooterIcon.displayName = "AsyncFooterIcon";
|
@ -0,0 +1,21 @@
|
||||
import classNames from "classnames";
|
||||
|
||||
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
|
||||
export const AsyncFooterIcon = (props: AsyncIconProps & { value: number }) => {
|
||||
const mergedProps = {
|
||||
...props,
|
||||
iconSize: 18,
|
||||
className: classNames(
|
||||
"transition duration-200 ease-in-out flex flex-row reaction-pill cursor-pointer gap-2 items-center",
|
||||
props.className,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncIcon {...mergedProps}>
|
||||
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
|
||||
</AsyncIcon>
|
||||
);
|
||||
};
|
@ -0,0 +1,158 @@
|
||||
import { barrierQueue } from "@snort/shared";
|
||||
import { NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useLongPress } from "use-long-press";
|
||||
|
||||
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
|
||||
import { ZapperQueue } from "@/Components/Event/Note/NoteFooter/ZapperQueue";
|
||||
import { ZapsSummary } from "@/Components/Event/ZapsSummary";
|
||||
import ZapModal from "@/Components/ZapModal/ZapModal";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { getDisplayName } from "@/Utils";
|
||||
import { Zapper, ZapTarget } from "@/Utils/Zapper";
|
||||
import { ZapPoolController } from "@/Utils/ZapPoolController";
|
||||
import { useWallet } from "@/Wallet";
|
||||
|
||||
export interface ZapIconProps {
|
||||
ev: TaggedNostrEvent;
|
||||
zaps: Array<ParsedZap>;
|
||||
onClickZappers?: () => void;
|
||||
}
|
||||
|
||||
export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
|
||||
const {
|
||||
publicKey,
|
||||
readonly,
|
||||
preferences: prefs,
|
||||
} = useLogin(s => ({
|
||||
publicKey: s.publicKey,
|
||||
readonly: s.readonly,
|
||||
preferences: s.appData.json.preferences,
|
||||
}));
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
|
||||
const didZap = zaps.some(a => a.sender === publicKey);
|
||||
const [showZapModal, setShowZapModal] = useState(false);
|
||||
const { formatMessage } = useIntl();
|
||||
const [zapping, setZapping] = useState(false);
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const author = useUserProfile(ev.pubkey);
|
||||
const isMine = ev.pubkey === publicKey;
|
||||
|
||||
const longPress = useLongPress(() => setShowZapModal(true), { captureEvent: true });
|
||||
|
||||
const getZapTarget = (): Array<ZapTarget> | undefined => {
|
||||
if (ev.tags.some(v => v[0] === "zap")) {
|
||||
return Zapper.fromEvent(ev);
|
||||
}
|
||||
|
||||
const authorTarget = author?.lud16 || author?.lud06;
|
||||
if (authorTarget) {
|
||||
return [
|
||||
{
|
||||
type: "lnurl",
|
||||
value: authorTarget,
|
||||
weight: 1,
|
||||
name: getDisplayName(author, ev.pubkey),
|
||||
zap: {
|
||||
pubkey: ev.pubkey,
|
||||
event: link,
|
||||
},
|
||||
} as ZapTarget,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const fastZap = async (e: React.MouseEvent) => {
|
||||
if (zapping || e?.isPropagationStopped()) return;
|
||||
|
||||
const lnurl = getZapTarget();
|
||||
if (canFastZap && lnurl) {
|
||||
setZapping(true);
|
||||
try {
|
||||
await fastZapInner(lnurl, prefs.defaultZapAmount);
|
||||
} catch (e) {
|
||||
console.warn("Fast zap failed", e);
|
||||
if (!(e instanceof Error) || e.message !== "User rejected") {
|
||||
setShowZapModal(true);
|
||||
}
|
||||
} finally {
|
||||
setZapping(false);
|
||||
}
|
||||
} else {
|
||||
setShowZapModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
|
||||
if (wallet) {
|
||||
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
|
||||
await barrierQueue(ZapperQueue, async () => {
|
||||
const zapper = new Zapper(system, publisher);
|
||||
const result = await zapper.send(wallet, targets, amount);
|
||||
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
|
||||
if (totalSent > 0) {
|
||||
if (CONFIG.features.zapPool) {
|
||||
ZapPoolController?.allocate(totalSent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const canFastZap = wallet?.isReady() && !readonly;
|
||||
|
||||
const targets = getZapTarget();
|
||||
|
||||
useEffect(() => {
|
||||
if (prefs.autoZap && !didZap && !isMine && !zapping) {
|
||||
const lnurl = getZapTarget();
|
||||
if (wallet?.isReady() && lnurl) {
|
||||
setZapping(true);
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
await fastZapInner(lnurl, prefs.defaultZapAmount);
|
||||
} catch {
|
||||
// ignored
|
||||
} finally {
|
||||
setZapping(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [prefs.autoZap, author, zapping]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{targets && (
|
||||
<>
|
||||
<div className="flex flex-row flex-none min-w-[50px] md:min-w-[80px] gap-4 items-center">
|
||||
<AsyncFooterIcon
|
||||
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
|
||||
{...longPress()}
|
||||
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
|
||||
iconName={canFastZap ? "zapFast" : "zap"}
|
||||
value={zapTotal}
|
||||
onClick={fastZap}
|
||||
/>
|
||||
<ZapsSummary zaps={zaps} onClick={onClickZappers ?? (() => {})} />
|
||||
</div>
|
||||
{showZapModal && (
|
||||
<ZapModal
|
||||
targets={getZapTarget()}
|
||||
onClose={() => setShowZapModal(false)}
|
||||
note={ev.id}
|
||||
show={true}
|
||||
allocatePool={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,53 @@
|
||||
import { normalizeReaction } from "@snort/shared";
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
export const LikeButton = ({
|
||||
ev,
|
||||
positiveReactions,
|
||||
}: {
|
||||
ev: TaggedNostrEvent;
|
||||
positiveReactions: TaggedNostrEvent[];
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
|
||||
const { publisher, system } = useEventPublisher();
|
||||
|
||||
const hasReacted = (emoji: string) => {
|
||||
return positiveReactions?.some(
|
||||
({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey,
|
||||
);
|
||||
};
|
||||
|
||||
const react = async (content: string) => {
|
||||
if (!hasReacted(content) && publisher) {
|
||||
const evLike = await publisher.react(ev, content);
|
||||
system.BroadcastEvent(evLike);
|
||||
}
|
||||
if (!publisher) {
|
||||
navigate("/login");
|
||||
}
|
||||
};
|
||||
|
||||
const reacted = hasReacted("+");
|
||||
|
||||
return (
|
||||
<AsyncFooterIcon
|
||||
className={classNames(
|
||||
"flex-none min-w-[50px] md:min-w-[80px]",
|
||||
reacted ? "reacted text-nostr-red" : "hover:text-nostr-red",
|
||||
)}
|
||||
iconName={reacted ? "heart-solid" : "heart"}
|
||||
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
|
||||
value={positiveReactions.length}
|
||||
onClick={() => react("+")}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,44 @@
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { useEventReactions, useReactions } from "@snort/system-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { FooterZapButton } from "@/Components/Event/Note/NoteFooter/FooterZapButton";
|
||||
import { LikeButton } from "@/Components/Event/Note/NoteFooter/LikeButton";
|
||||
import { PowIcon } from "@/Components/Event/Note/NoteFooter/PowIcon";
|
||||
import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
|
||||
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
|
||||
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
export interface NoteFooterProps {
|
||||
replyCount?: number;
|
||||
ev: TaggedNostrEvent;
|
||||
}
|
||||
|
||||
export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { ev } = props;
|
||||
const link = useMemo(() => NostrLink.fromEvent(ev), [ev.id]);
|
||||
const ids = useMemo(() => [link], [link]);
|
||||
const [showReactions, setShowReactions] = useState(false);
|
||||
|
||||
const related = useReactions("reactions", ids, undefined, false);
|
||||
const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
|
||||
const { positive } = reactions;
|
||||
|
||||
const { preferences: prefs, readonly } = useLogin(s => ({
|
||||
preferences: s.appData.json.preferences,
|
||||
publicKey: s.publicKey,
|
||||
readonly: s.readonly,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-4 overflow-hidden max-w-full h-6 items-center">
|
||||
<ReplyButton ev={ev} replyCount={props.replyCount ?? replies.length} readonly={readonly} />
|
||||
<RepostButton ev={ev} reposts={reposts} />
|
||||
{prefs.enableReactions && <LikeButton ev={ev} positiveReactions={positive} />}
|
||||
{CONFIG.showPowIcon && <PowIcon ev={ev} />}
|
||||
<FooterZapButton ev={ev} zaps={zaps} onClickZappers={() => setShowReactions(true)} />
|
||||
{showReactions && <ReactionsModal initialTab={1} onClose={() => setShowReactions(false)} event={ev} />}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { countLeadingZeros, TaggedNostrEvent } from "@snort/system";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
|
||||
import { findTag } from "@/Utils";
|
||||
|
||||
export const PowIcon = ({ ev }: { ev: TaggedNostrEvent }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const powValue = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
|
||||
if (!powValue) return null;
|
||||
|
||||
return (
|
||||
<AsyncFooterIcon
|
||||
className="hidden md:flex flex-none min-w-[50px] md:min-w-[80px]"
|
||||
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
|
||||
iconName="diamond"
|
||||
value={powValue}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,58 @@
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { useNoteCreator } from "@/State/NoteCreator";
|
||||
|
||||
export const ReplyButton = ({
|
||||
ev,
|
||||
replyCount,
|
||||
readonly,
|
||||
}: {
|
||||
ev: TaggedNostrEvent;
|
||||
replyCount?: number;
|
||||
readonly: boolean;
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const publicKey = useLogin(s => s.publicKey);
|
||||
const note = useNoteCreator(n => ({
|
||||
show: n.show,
|
||||
replyTo: n.replyTo,
|
||||
update: n.update,
|
||||
quote: n.quote,
|
||||
}));
|
||||
|
||||
const handleReplyButtonClick = () => {
|
||||
if (!publicKey) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
note.update(v => {
|
||||
if (v.replyTo?.id !== ev.id) {
|
||||
v.reset();
|
||||
}
|
||||
v.show = true;
|
||||
v.replyTo = ev;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncFooterIcon
|
||||
className={classNames(
|
||||
"flex-none min-w-[50px] md:min-w-[80px]",
|
||||
note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple",
|
||||
)}
|
||||
iconName="reply"
|
||||
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
|
||||
value={replyCount ?? 0}
|
||||
onClick={handleReplyButtonClick}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,77 @@
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import classNames from "classnames";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import messages from "@/Components/messages";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { useNoteCreator } from "@/State/NoteCreator";
|
||||
|
||||
export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: TaggedNostrEvent[] }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const { publicKey, preferences: prefs } = useLogin(s => ({
|
||||
preferences: s.appData.json.preferences,
|
||||
publicKey: s.publicKey,
|
||||
}));
|
||||
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
|
||||
|
||||
const hasReposted = () => {
|
||||
return reposts.some(a => a.pubkey === publicKey);
|
||||
};
|
||||
|
||||
const repost = async () => {
|
||||
if (!hasReposted() && publisher) {
|
||||
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
|
||||
const evRepost = await publisher.repost(ev);
|
||||
system.BroadcastEvent(evRepost);
|
||||
}
|
||||
}
|
||||
if (!publisher) {
|
||||
navigate("/login");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
menuButton={
|
||||
<AsyncFooterIcon
|
||||
className={classNames(
|
||||
"flex-none min-w-[50px] md:min-w-[80px]",
|
||||
hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue",
|
||||
)}
|
||||
iconName="repeat"
|
||||
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
|
||||
value={reposts.length}
|
||||
/>
|
||||
}
|
||||
menuClassName="ctx-menu"
|
||||
align="start">
|
||||
<div className="close-menu-container">
|
||||
<MenuItem>
|
||||
<div className="close-menu" />
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem onClick={repost} disabled={hasReposted()}>
|
||||
<Icon name="repeat" />
|
||||
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
note.update(n => {
|
||||
n.reset();
|
||||
n.quote = ev;
|
||||
n.show = true;
|
||||
})
|
||||
}>
|
||||
<Icon name="edit" />
|
||||
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
import { processWorkQueue, WorkQueueItem } from "@snort/shared";
|
||||
|
||||
export const ZapperQueue: Array<WorkQueueItem> = [];
|
||||
|
||||
processWorkQueue(ZapperQueue);
|
@ -3,10 +3,11 @@ import React, { useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import { NotePropsOptions } from "@/Components/Event/EventComponent";
|
||||
import { NoteContextMenu, NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
|
||||
import { NoteContextMenu } from "@/Components/Event/Note/NoteContextMenu";
|
||||
import NoteTime from "@/Components/Event/Note/NoteTime";
|
||||
import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
|
||||
import ReplyTag from "@/Components/Event/Note/ReplyTag";
|
||||
import { NoteTranslation } from "@/Components/Event/Note/types";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import messages from "@/Components/messages";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
@ -17,7 +18,7 @@ import { setBookmarked, setPinned } from "@/Utils/Login";
|
||||
export default function NoteHeader(props: {
|
||||
ev: TaggedNostrEvent;
|
||||
options: NotePropsOptions;
|
||||
setTranslated: (t: NoteTranslation) => void;
|
||||
setTranslated?: (t: NoteTranslation) => void;
|
||||
context?: React.ReactNode;
|
||||
}) {
|
||||
const [showReactions, setShowReactions] = useState(false);
|
||||
@ -49,6 +50,8 @@ export default function NoteHeader(props: {
|
||||
}
|
||||
}
|
||||
|
||||
const onTranslated = setTranslated ? (t: NoteTranslation) => setTranslated(t) : undefined;
|
||||
|
||||
return (
|
||||
<div className="header flex">
|
||||
<ProfileImage
|
||||
@ -79,12 +82,12 @@ export default function NoteHeader(props: {
|
||||
<NoteContextMenu
|
||||
ev={ev}
|
||||
react={async () => {}}
|
||||
onTranslated={t => setTranslated(t)}
|
||||
onTranslated={onTranslated}
|
||||
setShowReactions={setShowReactions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReactionsModal show={showReactions} setShow={setShowReactions} event={ev} />
|
||||
{showReactions && <ReactionsModal onClose={() => setShowReactions(false)} event={ev} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -11,11 +11,11 @@ const options = {
|
||||
|
||||
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
|
||||
const ev = useEventFeed(link);
|
||||
if (!ev.data)
|
||||
if (!ev)
|
||||
return (
|
||||
<div className="note-quote flex items-center justify-center h-[110px]">
|
||||
<PageSpinner />
|
||||
</div>
|
||||
);
|
||||
return <Note data={ev.data} className="note-quote" depth={(depth ?? 0) + 1} options={options} />;
|
||||
return <Note data={ev} className="note-quote" depth={(depth ?? 0) + 1} options={options} />;
|
||||
}
|
||||
|
@ -1,22 +1,22 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { memo, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { NoteProps } from "@/Components/Event/EventComponent";
|
||||
import { NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
|
||||
import { NoteTranslation } from "@/Components/Event/Note/types";
|
||||
import Reveal from "@/Components/Event/Reveal";
|
||||
import Text from "@/Components/Text/Text";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
|
||||
const TEXT_TRUNCATE_LENGTH = 400;
|
||||
export const NoteText = function InnerContent(
|
||||
export const NoteText = memo(function InnerContent(
|
||||
props: NoteProps & { translated: NoteTranslation; showTranslation?: boolean },
|
||||
) {
|
||||
const { data: ev, options, translated, showTranslation } = props;
|
||||
const appData = useLogin(s => s.appData);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const body = translated && showTranslation ? translated.text : ev?.content ?? "";
|
||||
const id = translated && showTranslation ? `${ev.id}-translated` : ev.id;
|
||||
const body = translated && !translated.skipped && showTranslation ? translated.text : ev?.content ?? "";
|
||||
const id = translated && !translated.skipped && showTranslation ? `${ev.id}-translated` : ev.id;
|
||||
const shouldTruncate = options?.truncate && body.length > TEXT_TRUNCATE_LENGTH;
|
||||
|
||||
const ToggleShowMore = () => (
|
||||
@ -40,7 +40,7 @@ export const NoteText = function InnerContent(
|
||||
{shouldTruncate && showMore && <ToggleShowMore />}
|
||||
<Text
|
||||
id={id}
|
||||
highlighText={props.searchedValue}
|
||||
highlightText={props.highlightText}
|
||||
content={body}
|
||||
tags={ev.tags}
|
||||
creator={ev.pubkey}
|
||||
@ -53,7 +53,7 @@ export const NoteText = function InnerContent(
|
||||
</>
|
||||
);
|
||||
|
||||
if (!appData.item.showContentWarningPosts) {
|
||||
if (!appData.json.showContentWarningPosts) {
|
||||
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
|
||||
if (contentWarning) {
|
||||
return (
|
||||
@ -94,4 +94,4 @@ export const NoteText = function InnerContent(
|
||||
}
|
||||
}
|
||||
return innerContent;
|
||||
};
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export interface NoteTimeProps {
|
||||
@ -38,7 +38,7 @@ const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [time, setTime] = useState<string | ReactNode>(calcTime(from));
|
||||
const [time] = useState<string | ReactNode>(calcTime(from));
|
||||
|
||||
const absoluteTime = useMemo(
|
||||
() =>
|
||||
@ -51,15 +51,6 @@ const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
|
||||
|
||||
const isoDate = useMemo(() => new Date(from).toISOString(), [from]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
const newTime = calcTime(from);
|
||||
setTime(s => (s !== newTime ? newTime : s));
|
||||
}, 60_000); // update every minute
|
||||
|
||||
return () => clearInterval(t);
|
||||
}, [from]);
|
||||
|
||||
return (
|
||||
<time dateTime={isoDate} title={absoluteTime}>
|
||||
{time || fallback}
|
||||
|
@ -2,35 +2,34 @@ import "./ReactionsModal.css";
|
||||
|
||||
import { NostrLink, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
|
||||
import { useEventReactions, useReactions } from "@snort/system-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormattedMessage, MessageDescriptor, useIntl } from "react-intl";
|
||||
|
||||
import CloseButton from "@/Components/Button/CloseButton";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import Modal from "@/Components/Modal/Modal";
|
||||
import Tabs from "@/Components/Tabs/Tabs";
|
||||
import TabSelectors, { Tab } from "@/Components/TabSelectors/TabSelectors";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
|
||||
import messages from "../../messages";
|
||||
|
||||
interface ReactionsModalProps {
|
||||
show: boolean;
|
||||
setShow(b: boolean): void;
|
||||
onClose(): void;
|
||||
event: TaggedNostrEvent;
|
||||
initialTab?: number;
|
||||
}
|
||||
|
||||
const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
|
||||
const ReactionsModal = ({ onClose, event, initialTab = 0 }: ReactionsModalProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const onClose = () => setShow(false);
|
||||
|
||||
const link = NostrLink.fromEvent(event);
|
||||
|
||||
const related = useReactions(link.id + "related", [link], undefined, false);
|
||||
const { reactions, zaps, reposts } = useEventReactions(link, related.data ?? []);
|
||||
const related = useReactions("note:reactions", [link], undefined, false);
|
||||
const { reactions, zaps, reposts } = useEventReactions(link, related);
|
||||
const { positive, negative } = reactions;
|
||||
|
||||
const sortEvents = events =>
|
||||
const sortEvents = (events: Array<TaggedNostrEvent>) =>
|
||||
events.sort(
|
||||
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
|
||||
);
|
||||
@ -41,11 +40,12 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
|
||||
|
||||
const total = positive.length + negative.length + zaps.length + reposts.length;
|
||||
|
||||
const createTab = (message, count, value, disabled = false) => ({
|
||||
text: formatMessage(message, { n: count }),
|
||||
value,
|
||||
disabled,
|
||||
});
|
||||
const createTab = (message: MessageDescriptor, count: number, value: number, disabled = false) =>
|
||||
({
|
||||
text: formatMessage(message, { n: count }),
|
||||
value,
|
||||
disabled,
|
||||
}) as Tab;
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const baseTabs = [
|
||||
@ -57,24 +57,18 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
|
||||
return dislikes.length !== 0 ? baseTabs.concat(createTab(messages.Dislikes, dislikes.length, 3)) : baseTabs;
|
||||
}, [likes.length, zaps.length, reposts.length, dislikes.length, formatMessage]);
|
||||
|
||||
const [tab, setTab] = useState(tabs[0]);
|
||||
const [tab, setTab] = useState(tabs[initialTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
setTab(tabs[0]);
|
||||
}
|
||||
}, [show, tabs]);
|
||||
|
||||
const renderReactionItem = (ev, icon, size) => (
|
||||
const renderReactionItem = (ev: TaggedNostrEvent, icon: string, iconClass?: string, size?: number) => (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">
|
||||
<Icon name={icon} size={size} />
|
||||
<Icon name={icon} size={size} className={iconClass} />
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return show ? (
|
||||
return (
|
||||
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
|
||||
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
|
||||
<div className="reactions-header">
|
||||
@ -82,16 +76,16 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
|
||||
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
|
||||
</h2>
|
||||
</div>
|
||||
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
|
||||
<TabSelectors tabs={tabs} tab={tab} setTab={setTab} />
|
||||
<div className="reactions-body" key={tab.value}>
|
||||
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart"))}
|
||||
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart-solid", "text-heart"))}
|
||||
{tab.value === 1 &&
|
||||
zaps.map(
|
||||
z =>
|
||||
z.sender && (
|
||||
<div key={z.id} className="reactions-item">
|
||||
<div className="zap-reaction-icon">
|
||||
<Icon name="zap" size={20} />
|
||||
<Icon name="zap-solid" size={20} className="text-zap" />
|
||||
<span className="zap-amount">{formatShort(z.amount)}</span>
|
||||
</div>
|
||||
<ProfileImage
|
||||
@ -106,11 +100,11 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", 16))}
|
||||
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", "text-repost", 16))}
|
||||
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactionsModal;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { NoteTranslation } from "@/Components/Event/Note/NoteContextMenu";
|
||||
import { NoteTranslation } from "@/Components/Event/Note/types";
|
||||
import messages from "@/Components/messages";
|
||||
|
||||
interface TranslationInfoProps {
|
||||
@ -23,7 +23,7 @@ export function TranslationInfo({ translated, setShowTranslation }: TranslationI
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else if (translated) {
|
||||
} else if (translated && !translated.skipped) {
|
||||
return (
|
||||
<p className="text-xs font-semibold text-gray-light">
|
||||
<FormattedMessage {...messages.TranslationFailed} />
|
||||
|
18
packages/app/src/Components/Event/Note/types.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
export interface NoteTranslation {
|
||||
text: string;
|
||||
fromLanguage: string;
|
||||
confidence: number;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface NoteContextMenuProps {
|
||||
ev: TaggedNostrEvent;
|
||||
|
||||
setShowReactions(b: boolean): void;
|
||||
|
||||
react(content: string): Promise<void>;
|
||||
|
||||
onTranslated?: (t: NoteTranslation) => void;
|
||||
}
|
@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
|
||||
import Spinner from "@/Components/Icons/Spinner";
|
||||
import SendSats from "@/Components/SendSats/SendSats";
|
||||
import ZapModal from "@/Components/ZapModal/ZapModal";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { unwrap } from "@/Utils";
|
||||
@ -27,18 +27,18 @@ export default function Poll(props: PollProps) {
|
||||
preferences: prefs,
|
||||
publicKey: myPubKey,
|
||||
relays,
|
||||
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, relays: s.relays }));
|
||||
} = useLogin(s => ({ preferences: s.appData.json.preferences, publicKey: s.publicKey, relays: s.relays }));
|
||||
const pollerProfile = useUserProfile(props.ev.pubkey);
|
||||
const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys");
|
||||
const [error, setError] = useState("");
|
||||
const [invoice, setInvoice] = useState("");
|
||||
const [voting, setVoting] = useState<number>();
|
||||
const didVote = props.zaps.some(a => a.sender === myPubKey);
|
||||
const didVote = props.zaps?.some(a => a.sender === myPubKey);
|
||||
const isMyPoll = props.ev.pubkey === myPubKey;
|
||||
const showResults = didVote || isMyPoll;
|
||||
|
||||
const options = props.ev.tags
|
||||
.filter(a => a[0] === "poll_option")
|
||||
?.filter(a => a[0] === "poll_option")
|
||||
.sort((a, b) => (Number(a[1]) > Number(b[1]) ? 1 : -1));
|
||||
|
||||
async function zapVote(ev: React.MouseEvent, opt: number) {
|
||||
@ -107,9 +107,9 @@ export default function Poll(props: PollProps) {
|
||||
const totalVotes = (() => {
|
||||
switch (tallyBy) {
|
||||
case "zaps":
|
||||
return props.zaps.filter(a => a.pollOption !== undefined).reduce((acc, v) => (acc += v.amount), 0);
|
||||
return props.zaps?.filter(a => a.pollOption !== undefined).reduce((acc, v) => (acc += v.amount), 0) ?? 0;
|
||||
case "pubkeys":
|
||||
return new Set(props.zaps.filter(a => a.pollOption !== undefined).map(a => unwrap(a.sender))).size;
|
||||
return new Set(props.zaps?.filter(a => a.pollOption !== undefined).map(a => unwrap(a.sender)) ?? []).size;
|
||||
}
|
||||
})();
|
||||
|
||||
@ -141,10 +141,10 @@ export default function Poll(props: PollProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="poll-body">
|
||||
{options.map(a => {
|
||||
{options?.map(a => {
|
||||
const opt = Number(a[1]);
|
||||
const desc = a[2];
|
||||
const zapsOnOption = props.zaps.filter(b => b.pollOption === opt);
|
||||
const zapsOnOption = props.zaps?.filter(b => b.pollOption === opt) ?? [];
|
||||
const total = (() => {
|
||||
switch (tallyBy) {
|
||||
case "zaps":
|
||||
@ -172,7 +172,7 @@ export default function Poll(props: PollProps) {
|
||||
{error && <b className="error">{error}</b>}
|
||||
</div>
|
||||
|
||||
<SendSats show={invoice !== ""} onClose={() => setInvoice("")} invoice={invoice} />
|
||||
<ZapModal show={invoice !== ""} onClose={() => setInvoice("")} invoice={invoice} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
|
||||
|
||||
import { MediaElement } from "@/Components/Embed/MediaElement";
|
||||
import Reveal from "@/Components/Event/Reveal";
|
||||
import useFollowsControls from "@/Hooks/useFollowControls";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { FileExtensionRegex } from "@/Utils/Const";
|
||||
|
||||
@ -12,16 +13,17 @@ interface RevealMediaProps {
|
||||
link: string;
|
||||
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
|
||||
meta?: IMeta;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function RevealMedia(props: RevealMediaProps) {
|
||||
const { preferences, follows, publicKey } = useLogin(s => ({
|
||||
preferences: s.appData.item.preferences,
|
||||
follows: s.follows.item,
|
||||
const { preferences, publicKey } = useLogin(s => ({
|
||||
preferences: s.appData.json.preferences,
|
||||
publicKey: s.publicKey,
|
||||
}));
|
||||
const { isFollowing } = useFollowsControls();
|
||||
|
||||
const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !follows.includes(props.creator);
|
||||
const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !isFollowing(props.creator);
|
||||
const isMine = props.creator === publicKey;
|
||||
const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows);
|
||||
const hostname = new URL(props.link).hostname;
|
||||
@ -73,6 +75,7 @@ export default function RevealMedia(props: RevealMediaProps) {
|
||||
url={url.toString()}
|
||||
onMediaClick={props.onMediaClick}
|
||||
meta={props.meta}
|
||||
size={props.size}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
@ -83,6 +86,7 @@ export default function RevealMedia(props: RevealMediaProps) {
|
||||
url={url.toString()}
|
||||
onMediaClick={props.onMediaClick}
|
||||
meta={props.meta}
|
||||
size={props.size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,18 +0,0 @@
|
||||
.show-more {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--highlight);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.show-more:hover {
|
||||
color: var(--highlight);
|
||||
background: none;
|
||||
border: none;
|
||||
font-weight: normal;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.show-more-container {
|
||||
min-height: 40px;
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import "./ShowMore.css";
|
||||
|
||||
import classNames from "classnames";
|
||||
import { useEffect } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface ShowMoreProps {
|
||||
text?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
|
||||
return (
|
||||
<div className="show-more-container">
|
||||
<button className={classNames("show-more", className)} onClick={onClick}>
|
||||
{text || <FormattedMessage defaultMessage="Show More" id="O8Z8t9" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowMore;
|
||||
|
||||
export function ShowMoreInView({ text, onClick, className }: ShowMoreProps) {
|
||||
const { ref, inView } = useInView({ rootMargin: "2000px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
onClick();
|
||||
}
|
||||
}, [inView]);
|
||||
|
||||
return (
|
||||
<div className={classNames("show-more-container", className)} ref={ref}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,348 +0,0 @@
|
||||
import "./Thread.css";
|
||||
|
||||
import { EventExt, NostrPrefix, parseNostrLink, TaggedNostrEvent, u256 } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import { Fragment, ReactNode, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import BackButton from "@/Components/Button/BackButton";
|
||||
import Collapsed from "@/Components/Collapsed";
|
||||
import Note from "@/Components/Event/EventComponent";
|
||||
import NoteGhost from "@/Components/Event/Note/NoteGhost";
|
||||
import { chainKey, ThreadContext, ThreadContextWrapper } from "@/Hooks/useThreadContext";
|
||||
|
||||
import messages from "../messages";
|
||||
|
||||
interface DividerProps {
|
||||
variant?: "regular" | "small";
|
||||
}
|
||||
|
||||
const Divider = ({ variant = "regular" }: DividerProps) => {
|
||||
const className = variant === "small" ? "divider divider-small" : "divider";
|
||||
return (
|
||||
<div className="divider-container">
|
||||
<div className={className}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SubthreadProps {
|
||||
isLastSubthread?: boolean;
|
||||
active: u256;
|
||||
notes: readonly TaggedNostrEvent[];
|
||||
chains: Map<u256, Array<TaggedNostrEvent>>;
|
||||
onNavigate: (e: TaggedNostrEvent) => void;
|
||||
}
|
||||
|
||||
const Subthread = ({ active, notes, chains, onNavigate }: SubthreadProps) => {
|
||||
const renderSubthread = (a: TaggedNostrEvent, idx: number) => {
|
||||
const isLastSubthread = idx === notes.length - 1;
|
||||
const replies = getReplies(a.id, chains);
|
||||
return (
|
||||
<Fragment key={a.id}>
|
||||
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
|
||||
<Divider />
|
||||
<Note
|
||||
highlight={active === a.id}
|
||||
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
|
||||
data={a}
|
||||
key={a.id}
|
||||
onClick={onNavigate}
|
||||
threadChains={chains}
|
||||
waitUntilInView={idx > 5}
|
||||
/>
|
||||
<div className="line-container"></div>
|
||||
</div>
|
||||
{replies.length > 0 && (
|
||||
<TierTwo
|
||||
active={active}
|
||||
isLastSubthread={isLastSubthread}
|
||||
notes={replies}
|
||||
chains={chains}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return <div className="subthread">{notes.map(renderSubthread)}</div>;
|
||||
};
|
||||
|
||||
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
|
||||
note: TaggedNostrEvent;
|
||||
isLast: boolean;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
const ThreadNote = ({ active, note, isLast, isLastSubthread, chains, onNavigate, idx }: ThreadNoteProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const replies = getReplies(note.id, chains);
|
||||
const activeInReplies = replies.map(r => r.id).includes(active);
|
||||
const [collapsed, setCollapsed] = useState(!activeInReplies);
|
||||
const hasMultipleNotes = replies.length > 1;
|
||||
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
|
||||
const className = classNames(
|
||||
"subthread-container",
|
||||
isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid",
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
<Divider variant="small" />
|
||||
<Note
|
||||
highlight={active === note.id}
|
||||
className={classNames("thread-note", { "is-last-note": isLastVisibleNote })}
|
||||
data={note}
|
||||
key={note.id}
|
||||
onClick={onNavigate}
|
||||
threadChains={chains}
|
||||
waitUntilInView={idx > 5}
|
||||
/>
|
||||
<div className="line-container"></div>
|
||||
</div>
|
||||
{replies.length > 0 && (
|
||||
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
|
||||
<TierThree
|
||||
active={active}
|
||||
isLastSubthread={isLastSubthread}
|
||||
notes={replies}
|
||||
chains={chains}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</Collapsed>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TierTwo = ({ active, isLastSubthread, notes, chains, onNavigate }: SubthreadProps) => {
|
||||
const [first, ...rest] = notes;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThreadNote
|
||||
active={active}
|
||||
onNavigate={onNavigate}
|
||||
note={first}
|
||||
chains={chains}
|
||||
isLastSubthread={isLastSubthread}
|
||||
isLast={rest.length === 0}
|
||||
idx={0}
|
||||
/>
|
||||
|
||||
{rest.map((r: TaggedNostrEvent, idx: number) => {
|
||||
const lastReply = idx === rest.length - 1;
|
||||
return (
|
||||
<ThreadNote
|
||||
key={r.id}
|
||||
active={active}
|
||||
onNavigate={onNavigate}
|
||||
note={r}
|
||||
chains={chains}
|
||||
isLastSubthread={isLastSubthread}
|
||||
isLast={lastReply}
|
||||
idx={idx}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TierThree = ({ active, isLastSubthread, notes, chains, onNavigate }: SubthreadProps) => {
|
||||
const [first, ...rest] = notes;
|
||||
const replies = getReplies(first.id, chains);
|
||||
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
|
||||
const isLast = replies.length === 0 && rest.length === 0;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames("subthread-container", {
|
||||
"subthread-multi": hasMultipleNotes,
|
||||
"subthread-last": isLast,
|
||||
"subthread-mid": !isLast,
|
||||
})}>
|
||||
<Divider variant="small" />
|
||||
<Note
|
||||
highlight={active === first.id}
|
||||
className={classNames("thread-note", { "is-last-note": isLastSubthread && isLast })}
|
||||
data={first}
|
||||
key={first.id}
|
||||
threadChains={chains}
|
||||
waitUntilInView={true}
|
||||
/>
|
||||
<div className="line-container"></div>
|
||||
</div>
|
||||
|
||||
{replies.length > 0 && (
|
||||
<TierThree
|
||||
active={active}
|
||||
isLastSubthread={isLastSubthread}
|
||||
notes={replies}
|
||||
chains={chains}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rest.map((r: TaggedNostrEvent, idx: number) => {
|
||||
const lastReply = idx === rest.length - 1;
|
||||
const lastNote = isLastSubthread && lastReply;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={classNames("subthread-container", {
|
||||
"subthread-multi": !lastReply,
|
||||
"subthread-last": !lastReply,
|
||||
"subthread-mid": lastReply,
|
||||
})}>
|
||||
<Divider variant="small" />
|
||||
<Note
|
||||
className={classNames("thread-note", { "is-last-note": lastNote })}
|
||||
highlight={active === r.id}
|
||||
data={r}
|
||||
key={r.id}
|
||||
onClick={onNavigate}
|
||||
threadChains={chains}
|
||||
waitUntilInView={idx > 5}
|
||||
/>
|
||||
<div className="line-container"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function ThreadRoute({ id }: { id?: string }) {
|
||||
const params = useParams();
|
||||
const resolvedId = id ?? params.id;
|
||||
const link = parseNostrLink(resolvedId ?? "", NostrPrefix.Note);
|
||||
|
||||
return (
|
||||
<ThreadContextWrapper link={link}>
|
||||
<Thread />
|
||||
</ThreadContextWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean }) {
|
||||
const thread = useContext(ThreadContext);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const isSingleNote = thread.chains?.size === 1 && [thread.chains.values].every(v => v.length === 0);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const rootOptions = useMemo(
|
||||
() => ({ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight, isRoot: true }),
|
||||
[props.disableSpotlight],
|
||||
);
|
||||
|
||||
const navigateThread = useCallback(
|
||||
(e: TaggedNostrEvent) => {
|
||||
thread.setCurrent(e.id);
|
||||
// navigate(`/${NostrLink.fromEvent(e).encode()}`, { replace: true });
|
||||
},
|
||||
[thread],
|
||||
);
|
||||
|
||||
const parent = useMemo(() => {
|
||||
if (thread.root) {
|
||||
const currentThread = EventExt.extractThread(thread.root);
|
||||
return (
|
||||
currentThread?.replyTo?.value ??
|
||||
currentThread?.root?.value ??
|
||||
(currentThread?.root?.key === "a" && currentThread.root?.value)
|
||||
);
|
||||
}
|
||||
}, [thread.root]);
|
||||
|
||||
function renderRoot(note: TaggedNostrEvent) {
|
||||
const className = `thread-root${isSingleNote ? " thread-root-single" : ""}`;
|
||||
if (note) {
|
||||
return (
|
||||
<Note
|
||||
className={className}
|
||||
key={note.id}
|
||||
data={note}
|
||||
options={rootOptions}
|
||||
onClick={navigateThread}
|
||||
threadChains={thread.chains}
|
||||
waitUntilInView={false}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChain(from: u256): ReactNode {
|
||||
if (!from || thread.chains.size === 0) {
|
||||
return;
|
||||
}
|
||||
const replies = thread.chains.get(from);
|
||||
if (replies && thread.current) {
|
||||
return <Subthread active={thread.current} notes={replies} chains={thread.chains} onNavigate={navigateThread} />;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (parent) {
|
||||
thread.setCurrent(parent);
|
||||
} else if (props.onBack) {
|
||||
props.onBack();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}
|
||||
|
||||
const parentText = formatMessage({
|
||||
defaultMessage: "Parent",
|
||||
id: "ADmfQT",
|
||||
description: "Link to parent note in thread",
|
||||
});
|
||||
|
||||
const debug = window.location.search.includes("debug=true");
|
||||
return (
|
||||
<>
|
||||
{debug && (
|
||||
<div className="main-content p xs">
|
||||
<h1>Chains</h1>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
Object.fromEntries([...thread.chains.entries()].map(([k, v]) => [k, v.map(c => c.id)])),
|
||||
undefined,
|
||||
" ",
|
||||
)}
|
||||
</pre>
|
||||
<h1>Current</h1>
|
||||
<pre>{JSON.stringify(thread.current)}</pre>
|
||||
<h1>Root</h1>
|
||||
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
|
||||
<h1>Data</h1>
|
||||
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
|
||||
<h1>Reactions</h1>
|
||||
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
|
||||
</div>
|
||||
)}
|
||||
{parent && (
|
||||
<div className="main-content p">
|
||||
<BackButton onClick={goBack} text={parentText} />
|
||||
</div>
|
||||
)}
|
||||
<div className="main-content">
|
||||
{thread.root && renderRoot(thread.root)}
|
||||
{thread.root && renderChain(chainKey(thread.root))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getReplies(from: u256, chains?: Map<u256, Array<TaggedNostrEvent>>): Array<TaggedNostrEvent> {
|
||||
if (!from || !chains) {
|
||||
return [];
|
||||
}
|
||||
const replies = chains.get(from);
|
||||
return replies ? replies : [];
|
||||
}
|
12
packages/app/src/Components/Event/Thread/Divider.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
interface DividerProps {
|
||||
variant?: "regular" | "small";
|
||||
}
|
||||
|
||||
export const Divider = ({ variant = "regular" }: DividerProps) => {
|
||||
const className = variant === "small" ? "divider divider-small" : "divider";
|
||||
return (
|
||||
<div className="divider-container">
|
||||
<div className={className}></div>
|
||||
</div>
|
||||
);
|
||||
};
|