Compare commits
166 Commits
21e1202b97
...
ce4d99dc88
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
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/
|
@ -1 +1,4 @@
|
||||
yarnPath: .yarn/releases/yarn-3.6.3.cjs
|
||||
npmScopes:
|
||||
"here":
|
||||
npmRegistryServer: "https://repo.platform.here.com/artifactory/api/npm/maps-api-for-javascript/"
|
||||
|
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
|
||||
|
@ -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;
|
||||
|
@ -18,7 +18,7 @@ export const onRequest: PagesFunction<Env> = async context => {
|
||||
if (!isEntityPath && nostrAddress) {
|
||||
id = `${id}@${HOST}`;
|
||||
}
|
||||
const fetchApi = `http://nostr.api.v0l.io/api/v1/opengraph/${id}?canonical=${encodeURIComponent(
|
||||
const fetchApi = `https://nostr.api.v0l.io/api/v1/opengraph/${id}?canonical=${encodeURIComponent(
|
||||
`https://${HOST}/%s`,
|
||||
)}`;
|
||||
console.log("Fetching tags from: ", fetchApi);
|
||||
@ -36,7 +36,10 @@ export const onRequest: PagesFunction<Env> = async context => {
|
||||
if (body.length > 0) {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
...Object.fromEntries(rsp.headers.entries()),
|
||||
"cache-control": "public, max-age=60",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -4,11 +4,12 @@
|
||||
"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/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 checkout 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,
|
||||
@ -23,5 +24,8 @@
|
||||
"eslint": "^8.48.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typedoc": "^0.25.7"
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,16 @@ module.exports = {
|
||||
"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,17 @@
|
||||
"deck": true,
|
||||
"zapPool": true,
|
||||
"notificationGraph": true,
|
||||
"communityLeaders": true
|
||||
"communityLeaders": true,
|
||||
"nostrAddress": true,
|
||||
"pushNotifications": true
|
||||
},
|
||||
"signUp": {
|
||||
"moderation": true,
|
||||
"defaultFollows": ["npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws"]
|
||||
},
|
||||
"defaultPreferences": {
|
||||
"hideMutedNotes": false
|
||||
},
|
||||
"media": {
|
||||
"bypassImgProxyError": false,
|
||||
"preferLargeMedia": true
|
||||
@ -33,6 +37,7 @@
|
||||
"noteCreatorToast": false,
|
||||
"hideFromNavbar": ["/graph"],
|
||||
"deckSubKind": 1,
|
||||
"showPowIcon": true,
|
||||
"eventLinkPrefix": "nevent",
|
||||
"profileLinkPrefix": "nprofile",
|
||||
"defaultRelays": {
|
||||
@ -43,5 +48,9 @@
|
||||
"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,12 +12,15 @@
|
||||
"defaultZapPoolFee": 0.5,
|
||||
"features": {
|
||||
"analytics": true,
|
||||
"subscriptions": false,
|
||||
"subscriptions": true,
|
||||
"deck": true,
|
||||
"zapPool": true,
|
||||
"notificationGraph": false,
|
||||
"communityLeaders": true
|
||||
},
|
||||
"defaultPreferences": {
|
||||
"hideMutedNotes": true
|
||||
},
|
||||
"signUp": {
|
||||
"moderation": false,
|
||||
"defaultFollows": ["npub1wnwwcv0a8wx0m9stck34ajlwhzuua68ts8mw3kjvspn42dcfyjxs4n95l8"]
|
||||
@ -34,6 +36,7 @@
|
||||
"hideFromNavbar": [],
|
||||
"eventLinkPrefix": "note",
|
||||
"profileLinkPrefix": "npub",
|
||||
"showPowIcon": false,
|
||||
"defaultRelays": {
|
||||
"ws://localhost:7777": { "read": true, "write": true },
|
||||
"wss://relay.snort.social/": { "read": true, "write": true },
|
||||
@ -41,5 +44,6 @@
|
||||
"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" }]
|
||||
}
|
||||
|
49
packages/app/config/nostr.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"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": {
|
||||
"moderation": true,
|
||||
"defaultFollows": []
|
||||
},
|
||||
"defaultPreferences": {
|
||||
"hideMutedNotes": false
|
||||
},
|
||||
"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
|
||||
}
|
20
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,9 +59,11 @@ declare const CONFIG: {
|
||||
zapPool: boolean;
|
||||
notificationGraph: boolean;
|
||||
communityLeaders: boolean;
|
||||
nostrAddress: boolean;
|
||||
pushNotifications: boolean;
|
||||
};
|
||||
defaultPreferences: {
|
||||
checkSigs: boolean;
|
||||
hideMutedNotes: boolean;
|
||||
};
|
||||
signUp: {
|
||||
moderation: boolean;
|
||||
@ -89,18 +91,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,8 +1,9 @@
|
||||
{
|
||||
"name": "@snort/app",
|
||||
"version": "0.1.24",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "0.6.1",
|
||||
"@here/maps-api-for-javascript": "^1.50.0",
|
||||
"@lightninglabs/lnc-web": "^0.2.8-alpha",
|
||||
"@noble/curves": "^1.0.0",
|
||||
"@noble/hashes": "^1.3.3",
|
||||
@ -14,6 +15,7 @@
|
||||
"@snort/system-react": "workspace:*",
|
||||
"@snort/system-wasm": "workspace:*",
|
||||
"@snort/system-web": "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",
|
||||
@ -84,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",
|
||||
@ -114,7 +119,7 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tinybench": "^2.5.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-pwa": "^0.17.0",
|
||||
"vite-plugin-version-mark": "^0.0.10",
|
||||
"vitest": "^0.34.6"
|
||||
|
4
packages/app/public/iris/_headers
Normal file
@ -0,0 +1,4 @@
|
||||
/*
|
||||
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;
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
Before Width: | Height: | Size: 15 KiB |
BIN
packages/app/public/iris/favicon.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
4
packages/app/public/nostr/_headers
Normal file
@ -0,0 +1,4 @@
|
||||
/*
|
||||
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;
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
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 |
4
packages/app/public/snort/_headers
Normal file
@ -0,0 +1,4 @@
|
||||
/*
|
||||
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;
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
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 |
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,41 +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);
|
||||
|
||||
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.cache.forEach(e => socialGraphInstance.handleEvent(e));
|
||||
}
|
||||
}
|
@ -1,136 +0,0 @@
|
||||
import { unixNow, unixNowMs } from "@snort/shared";
|
||||
import { EventKind, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
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.emit(
|
||||
"change",
|
||||
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.emit("change", latest?.map(a => this.key(a)) ?? []);
|
||||
this.log(`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.emit("change", 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);
|
||||
this.log(`Backfilled %d keys in %d ms`, missingKeys.length, (unixNowMs() - start).toLocaleString());
|
||||
}
|
||||
}
|
||||
}
|
@ -1,50 +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.emit(
|
||||
"change",
|
||||
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()];
|
||||
}
|
||||
}
|
@ -1,38 +1,37 @@
|
||||
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 WorkerRelayPath from "@snort/worker-relay/dist/worker?worker&url";
|
||||
|
||||
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";
|
||||
|
||||
export const Relay = new WorkerRelayInterface(WorkerRelayPath);
|
||||
export async function initRelayWorker() {
|
||||
try {
|
||||
await Relay.init("relay.db");
|
||||
} 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 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(),
|
||||
];
|
||||
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;
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ const AppleMusicEmbed = ({ link }: { link: string }) => {
|
||||
<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"
|
||||
|
@ -47,6 +47,8 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
|
||||
if (youtubeId) {
|
||||
return (
|
||||
<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"
|
||||
|
@ -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}
|
||||
|
@ -5,11 +5,11 @@ 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 { LinkPreviewData, NostrServices } from "@/External/NostrServices";
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
|
||||
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) {
|
||||
|
@ -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;
|
||||
@ -51,6 +53,7 @@ const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
|
||||
<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 +90,7 @@ const VideoElement = ({ url }: VideoElementProps) => {
|
||||
"md:h-[510px]": !CONFIG.media.preferLargeMedia,
|
||||
})}>
|
||||
<video
|
||||
crossOrigin="anonymous"
|
||||
ref={videoRef}
|
||||
loop={true}
|
||||
muted={!isMobile}
|
||||
@ -102,7 +106,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/")) {
|
||||
|
@ -10,6 +10,8 @@ const MixCloudEmbed = ({ link }: { link: string }) => {
|
||||
<>
|
||||
<br />
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
title="SoundCloud player"
|
||||
width="100%"
|
||||
height="120"
|
||||
|
@ -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,6 +1,8 @@
|
||||
const SoundCloudEmbed = ({ link }: { link: string }) => {
|
||||
return (
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
width="100%"
|
||||
height="166"
|
||||
scrolling="no"
|
||||
|
@ -3,6 +3,8 @@ const SpotifyEmbed = ({ link }: { link: string }) => {
|
||||
|
||||
return (
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
style={{ borderRadius: 12 }}
|
||||
src={convertedUrl}
|
||||
width="100%"
|
||||
|
@ -46,13 +46,15 @@ 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} />;
|
||||
}
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
return <iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} credentialless="" />;
|
||||
};
|
||||
|
||||
export default TidalEmbed;
|
||||
|
@ -2,7 +2,15 @@ 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}
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwitchEmbed;
|
||||
|
@ -3,6 +3,8 @@ const WavlakeEmbed = ({ link }: { link: string }) => {
|
||||
|
||||
return (
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
style={{ borderRadius: 12 }}
|
||||
src={convertedUrl}
|
||||
width="100%"
|
||||
|
@ -5,6 +5,7 @@ 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";
|
||||
@ -315,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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -612,8 +615,8 @@ export function NoteCreator() {
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Reply To" id="8ED/4u" />
|
||||
</h4>
|
||||
<div className="h-64 overflow-y-auto">
|
||||
<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" />
|
||||
</>
|
||||
@ -623,8 +626,8 @@ export function NoteCreator() {
|
||||
<h4>
|
||||
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
|
||||
</h4>
|
||||
<div className="h-64 overflow-y-auto">
|
||||
<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" />
|
||||
</>
|
||||
|
@ -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;
|
||||
|
@ -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.item.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>
|
||||
);
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -4,6 +4,7 @@ import React, { useCallback, 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 NoteHeader from "@/Components/Event/Note/NoteHeader";
|
||||
import { NoteText } from "@/Components/Event/Note/NoteText";
|
||||
@ -18,7 +19,7 @@ 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,6 +31,7 @@ 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;
|
||||
@ -37,7 +39,14 @@ export function Note(props: NoteProps) {
|
||||
const { isEventMuted } = useModeration();
|
||||
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
|
||||
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],
|
||||
);
|
||||
|
||||
const optionsMerged = { ...defaultOptions, ...opt };
|
||||
const goToEvent = useGoToEvent(props, optionsMerged);
|
||||
@ -50,13 +59,23 @@ 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} />}
|
||||
{optionsMerged.showFooter && (
|
||||
<div className="mt-4">
|
||||
<NoteFooter ev={ev} replyCount={props.threadChains?.get(chainKey(ev))?.length} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{optionsMerged.showFooter && <NoteFooter ev={ev} replies={props.threadChains?.get(chainKey(ev))?.length} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ export interface NoteTranslation {
|
||||
text: string;
|
||||
fromLanguage: string;
|
||||
confidence: number;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
interface NosteContextMenuProps {
|
||||
@ -60,6 +61,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,18 +69,23 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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/ZapsSummary";
|
||||
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("note:reactions", ids, undefined, false);
|
||||
const { reactions, zaps, reposts } = useEventReactions(link, related);
|
||||
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.item.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,48 @@
|
||||
import { normalizeReaction } from "@snort/shared";
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
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 { 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);
|
||||
}
|
||||
};
|
||||
|
||||
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 { reactions, zaps, reposts } = useEventReactions(link, related);
|
||||
const { positive } = reactions;
|
||||
|
||||
const { preferences: prefs, readonly } = useLogin(s => ({
|
||||
preferences: s.appData.item.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} readonly={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,50 @@
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import { AsyncFooterIcon } from "@/Components/Event/Note/NoteFooter/AsyncFooterIcon";
|
||||
import { useNoteCreator } from "@/State/NoteCreator";
|
||||
|
||||
export const ReplyButton = ({
|
||||
ev,
|
||||
replyCount,
|
||||
readonly,
|
||||
}: {
|
||||
ev: TaggedNostrEvent;
|
||||
replyCount?: number;
|
||||
readonly: boolean;
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const note = useNoteCreator(n => ({
|
||||
show: n.show,
|
||||
replyTo: n.replyTo,
|
||||
update: n.update,
|
||||
quote: n.quote,
|
||||
}));
|
||||
|
||||
const handleReplyButtonClick = () => {
|
||||
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,72 @@
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import classNames from "classnames";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
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 { publisher, system } = useEventPublisher();
|
||||
const { publicKey, preferences: prefs } = useLogin(s => ({
|
||||
preferences: s.appData.item.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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
@ -17,7 +17,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 +49,8 @@ export default function NoteHeader(props: {
|
||||
}
|
||||
}
|
||||
|
||||
const onTranslated = setTranslated ? (t: NoteTranslation) => setTranslated(t) : undefined;
|
||||
|
||||
return (
|
||||
<div className="header flex">
|
||||
<ProfileImage
|
||||
@ -79,12 +81,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>
|
||||
);
|
||||
}
|
||||
|
@ -15,8 +15,8 @@ export const NoteText = function InnerContent(
|
||||
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 = () => (
|
||||
|
@ -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,27 +2,26 @@ import "./ReactionsModal.css";
|
||||
|
||||
import { NostrLink, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
|
||||
import { useEventReactions, useReactions } from "@snort/system-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormattedMessage, 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 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);
|
||||
|
||||
@ -57,13 +56,7 @@ 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
setTab(tabs[0]);
|
||||
}
|
||||
}, [show, tabs]);
|
||||
const [tab, setTab] = useState(tabs[initialTab]);
|
||||
|
||||
const renderReactionItem = (ev, icon, size) => (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
@ -74,7 +67,7 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
|
||||
</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,7 +75,7 @@ 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 === 1 &&
|
||||
@ -110,7 +103,7 @@ const ReactionsModal = ({ show, setShow, event }: ReactionsModalProps) => {
|
||||
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactionsModal;
|
||||
|
@ -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} />
|
||||
|
@ -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";
|
||||
@ -33,12 +33,12 @@ export default function Poll(props: PollProps) {
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ interface RevealMediaProps {
|
||||
link: string;
|
||||
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
|
||||
meta?: IMeta;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function RevealMedia(props: RevealMediaProps) {
|
||||
@ -73,6 +74,7 @@ export default function RevealMedia(props: RevealMediaProps) {
|
||||
url={url.toString()}
|
||||
onMediaClick={props.onMediaClick}
|
||||
meta={props.meta}
|
||||
size={props.size}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
@ -83,6 +85,7 @@ export default function RevealMedia(props: RevealMediaProps) {
|
||||
url={url.toString()}
|
||||
onMediaClick={props.onMediaClick}
|
||||
meta={props.meta}
|
||||
size={props.size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -12,10 +12,6 @@
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
.thread-root.note > .footer {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.thread-root.note {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
@ -26,8 +22,6 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.thread-note.note .zaps-summary,
|
||||
.thread-note.note .footer,
|
||||
.thread-note.note .body {
|
||||
margin-left: 61px;
|
||||
}
|
||||
|
@ -44,15 +44,6 @@
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.zaps-summary {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.note.thread-root .zaps-summary {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.top-zap {
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
|
@ -5,7 +5,7 @@ import { useUserProfile } from "@snort/system-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import SendSats from "@/Components/SendSats/SendSats";
|
||||
import ZapModal from "@/Components/ZapModal/ZapModal";
|
||||
import { ZapTarget } from "@/Utils/Zapper";
|
||||
|
||||
const ZapButton = ({
|
||||
@ -30,7 +30,7 @@ const ZapButton = ({
|
||||
<Icon name="zap-solid" />
|
||||
{children}
|
||||
</button>
|
||||
<SendSats
|
||||
<ZapModal
|
||||
targets={[
|
||||
{
|
||||
type: "lnurl",
|
||||
|
@ -6,13 +6,12 @@ import { FormattedNumber } from "react-intl";
|
||||
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import Progress from "@/Components/Progress/Progress";
|
||||
import ZapModal from "@/Components/ZapModal/ZapModal";
|
||||
import useZapsFeed from "@/Feed/ZapsFeed";
|
||||
import { findTag } from "@/Utils";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
import { Zapper } from "@/Utils/Zapper";
|
||||
|
||||
import SendSats from "../SendSats/SendSats";
|
||||
|
||||
export function ZapGoal({ ev }: { ev: NostrEvent }) {
|
||||
const [zap, setZap] = useState(false);
|
||||
const zaps = useZapsFeed(NostrLink.fromEvent(ev));
|
||||
@ -27,7 +26,7 @@ export function ZapGoal({ ev }: { ev: NostrEvent }) {
|
||||
<div className="zap-button flex" onClick={() => setZap(true)}>
|
||||
<Icon name="zap" size={15} />
|
||||
</div>
|
||||
<SendSats targets={Zapper.fromEvent(ev)} show={zap} onClose={() => setZap(false)} />
|
||||
<ZapModal targets={Zapper.fromEvent(ev)} show={zap} onClose={() => setZap(false)} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
|
@ -1,50 +1,38 @@
|
||||
import { ParsedZap } from "@snort/system";
|
||||
import { useMemo } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import messages from "@/Components/messages";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import { AvatarGroup } from "@/Components/User/AvatarGroup";
|
||||
import { dedupe } from "@/Utils";
|
||||
|
||||
interface ZapsSummaryProps {
|
||||
zaps: ParsedZap[];
|
||||
onClick: () => void;
|
||||
}
|
||||
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const sortedZaps = useMemo(() => {
|
||||
export const ZapsSummary = ({ zaps, onClick }: ZapsSummaryProps) => {
|
||||
const sortedZappers = useMemo(() => {
|
||||
const pub = [...zaps.filter(z => z.sender && z.valid)];
|
||||
const priv = [...zaps.filter(z => !z.sender && z.valid)];
|
||||
pub.sort((a, b) => b.amount - a.amount);
|
||||
return pub.concat(priv);
|
||||
}, [zaps]);
|
||||
return dedupe(pub.concat(priv).map(z => z.sender)).slice(0, 3);
|
||||
}, [zaps]) as string[];
|
||||
|
||||
if (zaps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [topZap, ...restZaps] = sortedZaps;
|
||||
const { sender, amount, anonZap } = topZap;
|
||||
const myOnClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="zaps-summary">
|
||||
{amount && (
|
||||
<div className={`top-zap`}>
|
||||
<div className="summary">
|
||||
{sender && (
|
||||
<ProfileImage
|
||||
pubkey={anonZap ? "" : sender}
|
||||
showFollowDistance={false}
|
||||
overrideUsername={anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined}
|
||||
/>
|
||||
)}
|
||||
{restZaps.length > 0 ? (
|
||||
<FormattedMessage {...messages.Others} values={{ n: restZaps.length }} />
|
||||
) : (
|
||||
<FormattedMessage {...messages.Zapped} />
|
||||
)}{" "}
|
||||
<FormattedMessage {...messages.OthersZapped} values={{ n: restZaps.length }} />
|
||||
</div>
|
||||
<div className="zaps-summary" onClick={myOnClick}>
|
||||
<div className={`top-zap`}>
|
||||
<div className="summary">
|
||||
<AvatarGroup ids={sortedZappers} onClick={() => {}} />
|
||||
{zaps.length > 3 && <div className="hidden md:flex -ml-2">+{zaps.length - 3}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { NostrLink, ReqFilter, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { useMemo } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
|
||||
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
||||
const LazySimpleChart = lazy(async () => await import("@/Components/LineChart"));
|
||||
|
||||
export function GenericFeed({ link }: { link: NostrLink }) {
|
||||
const reqs = JSON.parse(link.id) as Array<ReqFilter>;
|
||||
const sub = useMemo(() => {
|
||||
const sub = new RequestBuilder("generic");
|
||||
sub.withOptions({ leaveOpen: true });
|
||||
const reqs = JSON.parse(link.id) as Array<ReqFilter>;
|
||||
reqs.forEach(a => {
|
||||
const f = sub.withBareFilter(a);
|
||||
link.relays?.forEach(r => f.relay(r));
|
||||
@ -18,13 +19,34 @@ export function GenericFeed({ link }: { link: NostrLink }) {
|
||||
|
||||
const evs = useRequestBuilder(sub);
|
||||
|
||||
const isTempSensor = reqs[0].kinds?.includes(8001) && reqs[0].kinds.length === 1;
|
||||
if (isTempSensor) {
|
||||
return (
|
||||
<div className="p flex flex-col gap-2">
|
||||
<Suspense>
|
||||
<LazySimpleChart
|
||||
data={evs
|
||||
.sort((a, b) => (a.created_at > b.created_at ? -1 : 1))
|
||||
.map(a => {
|
||||
return {
|
||||
time: a.created_at * 1000,
|
||||
...JSON.parse(a.content),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TimelineRenderer
|
||||
frags={[{ events: evs, refTime: 0 }]}
|
||||
latest={[]}
|
||||
showLatest={() => {
|
||||
//nothing
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<TimelineRenderer
|
||||
frags={[{ events: evs, refTime: 0 }]}
|
||||
latest={[]}
|
||||
showLatest={() => {
|
||||
//nothing
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,17 +1,14 @@
|
||||
import "./Timeline.css";
|
||||
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { EventKind, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
|
||||
import { socialGraphInstance, TaggedNostrEvent } from "@snort/system";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
|
||||
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
||||
import { LiveStreams } from "@/Components/LiveStream/LiveStreams";
|
||||
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineFeed";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
import { dedupeByPubkey, findTag } from "@/Utils";
|
||||
import { dedupeByPubkey } from "@/Utils";
|
||||
|
||||
export interface TimelineProps {
|
||||
postsOnly: boolean;
|
||||
@ -43,7 +40,6 @@ const Timeline = (props: TimelineProps) => {
|
||||
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
|
||||
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||
|
||||
const { muted, isEventMuted } = useModeration();
|
||||
const filterPosts = useCallback(
|
||||
(nts: readonly TaggedNostrEvent[]) => {
|
||||
const checkFollowDistance = (a: TaggedNostrEvent) => {
|
||||
@ -53,23 +49,19 @@ const Timeline = (props: TimelineProps) => {
|
||||
const followDistance = socialGraphInstance.getFollowDistance(a.pubkey);
|
||||
return followDistance === props.followDistance;
|
||||
};
|
||||
const a = [...nts.filter(a => a.kind !== EventKind.LiveEvent)];
|
||||
return a
|
||||
return nts
|
||||
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
|
||||
.filter(a => (props.ignoreModeration || !isEventMuted(a)) && checkFollowDistance(a));
|
||||
.filter(a => props.ignoreModeration || checkFollowDistance(a));
|
||||
},
|
||||
[props.postsOnly, muted, props.ignoreModeration, props.followDistance],
|
||||
[props.postsOnly, props.ignoreModeration, props.followDistance],
|
||||
);
|
||||
|
||||
const mainFeed = useMemo(() => {
|
||||
return filterPosts(feed.main ?? []);
|
||||
}, [feed, filterPosts]);
|
||||
}, [feed.main, filterPosts]);
|
||||
const latestFeed = useMemo(() => {
|
||||
return filterPosts(feed.latest ?? []).filter(a => !mainFeed.some(b => b.id === a.id));
|
||||
}, [feed, filterPosts]);
|
||||
const liveStreams = useMemo(() => {
|
||||
return (feed.main ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
|
||||
}, [feed]);
|
||||
}, [feed.latest, feed.main, filterPosts]);
|
||||
|
||||
const latestAuthors = useMemo(() => {
|
||||
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
|
||||
@ -84,7 +76,6 @@ const Timeline = (props: TimelineProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<LiveStreams evs={liveStreams} />
|
||||
<DisplayAsSelector
|
||||
show={props.showDisplayAsSelector}
|
||||
activeSelection={displayAs}
|
||||
@ -94,7 +85,7 @@ const Timeline = (props: TimelineProps) => {
|
||||
frags={[
|
||||
{
|
||||
events: mainFeed,
|
||||
refTime: mainFeed.at(0)?.created_at ?? unixNow(),
|
||||
refTime: 0,
|
||||
},
|
||||
]}
|
||||
latest={latestAuthors}
|
||||
|
@ -1,21 +1,15 @@
|
||||
import "./Timeline.css";
|
||||
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { ReactNode, useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { FollowsFeed } from "@/Cache";
|
||||
import { ShowMoreInView } from "@/Components/Event/ShowMore";
|
||||
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
|
||||
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
||||
import { LiveStreams } from "@/Components/LiveStream/LiveStreams";
|
||||
import useHashtagsFeed from "@/Feed/HashtagsFeed";
|
||||
import useHistoryState from "@/Hooks/useHistoryState";
|
||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
import { dedupeByPubkey, findTag, orderDescending } from "@/Utils";
|
||||
import { dedupeByPubkey } from "@/Utils";
|
||||
|
||||
export interface TimelineFollowsProps {
|
||||
postsOnly: boolean;
|
||||
@ -34,16 +28,21 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
||||
const login = useLogin();
|
||||
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
|
||||
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||
const [latest, setLatest] = useHistoryState(unixNow(), "TimelineFollowsLatest");
|
||||
const feed = useSyncExternalStore(
|
||||
cb => FollowsFeed.hook(cb, "*"),
|
||||
() => FollowsFeed.snapshot(),
|
||||
const subject = useMemo(
|
||||
() =>
|
||||
({
|
||||
type: "pubkey",
|
||||
items: login.follows.item,
|
||||
discriminator: login.publicKey?.slice(0, 12),
|
||||
extra: rb => {
|
||||
if (login.tags.item.length > 0) {
|
||||
rb.withFilter().kinds([EventKind.TextNote]).tag("t", login.tags.item);
|
||||
}
|
||||
},
|
||||
}) as TimelineSubject,
|
||||
[login.follows.item, login.tags.item],
|
||||
);
|
||||
const system = useContext(SnortContext);
|
||||
const { muted, isEventMuted } = useModeration();
|
||||
|
||||
const sortedFeed = useMemo(() => orderDescending(feed), [feed]);
|
||||
const oldest = useMemo(() => sortedFeed.at(-1)?.created_at, [sortedFeed]);
|
||||
const feed = useTimelineFeed(subject, { method: "TIME_RANGE" } as TimelineFeedOptions);
|
||||
|
||||
const postsOnly = useCallback(
|
||||
(a: NostrEvent) => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true),
|
||||
@ -55,49 +54,26 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
||||
const a = nts.filter(a => a.kind !== EventKind.LiveEvent);
|
||||
return a
|
||||
?.filter(postsOnly)
|
||||
.filter(a => !isEventMuted(a) && login.follows.item.includes(a.pubkey) && (props.noteFilter?.(a) ?? true));
|
||||
.filter(a => props.noteFilter?.(a) ?? true)
|
||||
.filter(a => login.follows.item.includes(a.pubkey) || a.tags.filter(a => a[0] === "t").length < 5);
|
||||
},
|
||||
[postsOnly, muted, login.follows.timestamp],
|
||||
[postsOnly, props.noteFilter, login.follows.timestamp],
|
||||
);
|
||||
|
||||
const mixin = useHashtagsFeed();
|
||||
const mainFeed = useMemo(() => {
|
||||
return filterPosts((sortedFeed ?? []).filter(a => a.created_at <= latest));
|
||||
}, [sortedFeed, filterPosts, latest, login.follows.timestamp]);
|
||||
|
||||
const findHashTagContext = (a: NostrEvent) => {
|
||||
const tag = a.tags.filter(a => a[0] === "t").find(a => login.tags.item.includes(a[1].toLowerCase()))?.[1];
|
||||
return tag;
|
||||
};
|
||||
const mixinFiltered = useMemo(() => {
|
||||
const mainFeedIds = new Set(mainFeed.map(a => a.id));
|
||||
return (mixin.data ?? [])
|
||||
.filter(a => !mainFeedIds.has(a.id) && postsOnly(a) && !isEventMuted(a))
|
||||
.filter(a => a.tags.filter(a => a[0] === "t").length < 5)
|
||||
.filter(a => !oldest || a.created_at >= oldest)
|
||||
.map(
|
||||
a =>
|
||||
({
|
||||
...a,
|
||||
context: findHashTagContext(a),
|
||||
}) as TaggedNostrEvent,
|
||||
);
|
||||
}, [mixin, mainFeed, postsOnly, isEventMuted]);
|
||||
return filterPosts(feed.main ?? []);
|
||||
}, [feed.main, filterPosts]);
|
||||
|
||||
const latestFeed = useMemo(() => {
|
||||
return filterPosts((sortedFeed ?? []).filter(a => a.created_at > latest));
|
||||
}, [sortedFeed, latest]);
|
||||
|
||||
const liveStreams = useMemo(() => {
|
||||
return (sortedFeed ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
|
||||
}, [sortedFeed]);
|
||||
return filterPosts(feed.latest ?? []);
|
||||
}, [feed.latest]);
|
||||
|
||||
const latestAuthors = useMemo(() => {
|
||||
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
|
||||
}, [latestFeed]);
|
||||
|
||||
function onShowLatest(scrollToTop = false) {
|
||||
setLatest(unixNow());
|
||||
feed.showLatest();
|
||||
if (scrollToTop) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
@ -105,14 +81,13 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{(props.liveStreams ?? true) && <LiveStreams evs={liveStreams} />}
|
||||
<DisplayAsSelector
|
||||
show={props.showDisplayAsSelector}
|
||||
activeSelection={displayAs}
|
||||
onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)}
|
||||
/>
|
||||
<TimelineRenderer
|
||||
frags={[{ events: orderDescending(mainFeed.concat(mixinFiltered)), refTime: latest }]}
|
||||
frags={[{ events: mainFeed, refTime: 0 }]}
|
||||
latest={latestAuthors}
|
||||
showLatest={t => onShowLatest(t)}
|
||||
noteOnClick={props.noteOnClick}
|
||||
@ -124,8 +99,12 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
|
||||
}}
|
||||
displayAs={displayAs}
|
||||
/>
|
||||
{sortedFeed.length > 0 && (
|
||||
<ShowMoreInView onClick={async () => await FollowsFeed.loadMore(system, login, oldest ?? unixNow())} />
|
||||
{mainFeed.length > 0 && (
|
||||
<ShowMoreInView
|
||||
onClick={() => {
|
||||
feed.loadMore();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -109,7 +109,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<div ref={containerRef} className="pb-[10vh]">
|
||||
{props.latest.length > 0 && (
|
||||
<>
|
||||
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>
|
||||
|
73
packages/app/src/Components/LineChart.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { sha256 } from "@snort/shared";
|
||||
import { Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
export default function SensorChart({ data }: { data: Array<{ time: number; [k: string]: number }> }) {
|
||||
return (
|
||||
<ResponsiveContainer height={250}>
|
||||
<LineChart data={data}>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={v => new Date(Number(v)).toLocaleTimeString()}
|
||||
/>
|
||||
{Object.keys(data[0] ?? {})
|
||||
.filter(a => a !== "time")
|
||||
.map(a => {
|
||||
const mapUnit = () => {
|
||||
switch (a) {
|
||||
case "temperature":
|
||||
return "C";
|
||||
case "humidity":
|
||||
return "%";
|
||||
case "wind_direction":
|
||||
return "deg";
|
||||
case "wind_speed":
|
||||
return "m/s";
|
||||
case "rain":
|
||||
return "mm";
|
||||
}
|
||||
};
|
||||
interface MinMax {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
const domainOf = () => {
|
||||
const domain = data.reduce<MinMax>(
|
||||
(acc, v) => {
|
||||
if (v[a] < acc.min) {
|
||||
acc.min = v[a];
|
||||
}
|
||||
if (v[a] > acc.max) {
|
||||
acc.max = v[a];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ min: Number.MAX_SAFE_INTEGER, max: Number.MIN_SAFE_INTEGER } as MinMax,
|
||||
);
|
||||
return [domain.min * 0.95, domain.max * 1.05];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line dataKey={a} type="monotone" dot={false} stroke={`#${sha256(a).slice(0, 6)}`} yAxisId={a} />
|
||||
<YAxis
|
||||
dataKey={a}
|
||||
unit={mapUnit()}
|
||||
yAxisId={a}
|
||||
type="number"
|
||||
scale="linear"
|
||||
domain={domainOf()}
|
||||
tickFormatter={v => Number(v).toLocaleString()}
|
||||
hide={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
<Legend />
|
||||
<Tooltip />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { NostrEvent, NostrLink } from "@snort/system";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@ -12,6 +13,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
|
||||
const status = findTag(ev, "status");
|
||||
const starts = Number(findTag(ev, "starts"));
|
||||
const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
|
||||
const [play, setPlay] = useState(false);
|
||||
|
||||
function statusLine() {
|
||||
switch (status) {
|
||||
@ -49,11 +51,9 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
|
||||
switch (status) {
|
||||
case "live": {
|
||||
return (
|
||||
<Link to={link} target="_blank">
|
||||
<button className="nowrap">
|
||||
<FormattedMessage defaultMessage="Join Stream" id="GQPtfk" />
|
||||
</button>
|
||||
</Link>
|
||||
<button className="nowrap" onClick={() => setPlay(true)}>
|
||||
<FormattedMessage defaultMessage="Watch Stream" id="furjvW" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
case "ended": {
|
||||
@ -69,7 +69,20 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (play) {
|
||||
const link = `https://zap.stream/embed/${NostrLink.fromEvent(ev).encode()}`;
|
||||
return (
|
||||
<iframe
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
credentialless=""
|
||||
src={link}
|
||||
width="100%"
|
||||
style={{
|
||||
aspectRatio: "16/9",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="sm:flex g12 br p24 bg-primary items-center">
|
||||
<div>
|
||||
|
@ -1,22 +1,27 @@
|
||||
import "./LiveStreams.css";
|
||||
|
||||
import { NostrEvent, NostrLink } from "@snort/system";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { EventKind, NostrEvent, NostrLink, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { CSSProperties, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { findTag } from "@/Utils";
|
||||
|
||||
export function LiveStreams({ evs }: { evs: Array<NostrEvent> }) {
|
||||
const streams = useMemo(() => {
|
||||
return [...evs].sort((a, b) => {
|
||||
const aStarts = Number(findTag(a, "starts") ?? a.created_at);
|
||||
const bStarts = Number(findTag(b, "starts") ?? b.created_at);
|
||||
return aStarts > bStarts ? -1 : 1;
|
||||
});
|
||||
}, [evs]);
|
||||
export function LiveStreams() {
|
||||
const follows = useLogin(s => s.follows.item);
|
||||
const sub = useMemo(() => {
|
||||
const since = unixNow() - 60 * 60 * 24;
|
||||
const rb = new RequestBuilder("follows:streams");
|
||||
rb.withFilter().kinds([EventKind.LiveEvent]).authors(follows).since(since);
|
||||
rb.withFilter().kinds([EventKind.LiveEvent]).tag("p", follows).since(since);
|
||||
return rb;
|
||||
}, [follows]);
|
||||
|
||||
const streams = useRequestBuilder(sub);
|
||||
if (streams.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import "./Modal.css";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface ModalProps {
|
||||
@ -60,12 +60,21 @@ export default function Modal(props: ModalProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
props.onClose?.(e);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={props.className === "hidden" ? props.className : `modal ${props.className || ""}`}
|
||||
onClick={props.onClose}>
|
||||
onMouseDown={handleBackdropClick}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<div
|
||||
className={props.bodyClassName || "modal-body"}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
props.onClick?.(e);
|
||||
|
@ -7,7 +7,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { UserCache } from "@/Cache";
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import Copy from "@/Components/Copy/Copy";
|
||||
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";
|
||||
@ -298,7 +298,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
</b>
|
||||
</div>
|
||||
)}
|
||||
<SendSats
|
||||
<ZapModal
|
||||
invoice={registerResponse?.invoice}
|
||||
show={showInvoice}
|
||||
onClose={() => setShowInvoice(false)}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { forwardRef, HTMLProps, ReactNode, useEffect, useState } from "react";
|
||||
import { forwardRef, HTMLProps, memo, ReactNode, useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import useImgProxy from "@/Hooks/useImgProxy";
|
||||
import { getUrlHostname } from "@/Utils";
|
||||
|
||||
@ -12,7 +13,9 @@ type ProxyImgProps = HTMLProps<HTMLImageElement> & {
|
||||
missingImageElement?: ReactNode;
|
||||
};
|
||||
|
||||
export const ProxyImg = forwardRef<HTMLImageElement, ProxyImgProps>(function ProxyImg(
|
||||
const defaultMissingImageElement = <Icon name="x" className="warning" />;
|
||||
|
||||
const ProxyImgComponent = forwardRef<HTMLImageElement, ProxyImgProps>(function ProxyImg(
|
||||
{ src, size, className, promptToLoadDirectly, missingImageElement, sha256, ...props }: ProxyImgProps,
|
||||
ref,
|
||||
) {
|
||||
@ -60,14 +63,9 @@ export const ProxyImg = forwardRef<HTMLImageElement, ProxyImgProps>(function Pro
|
||||
}
|
||||
};
|
||||
|
||||
if (!imgSrc || loadFailed)
|
||||
return (
|
||||
missingImageElement ?? (
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="Image not available" id="Y7FG5M" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
if (!imgSrc || loadFailed) {
|
||||
return missingImageElement ?? defaultMissingImageElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
@ -81,3 +79,8 @@ export const ProxyImg = forwardRef<HTMLImageElement, ProxyImgProps>(function Pro
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const ProxyImg = memo(ProxyImgComponent);
|
||||
ProxyImg.displayName = "ProxyImg";
|
||||
|
||||
export { ProxyImg };
|
||||
|
@ -24,9 +24,7 @@ export default function Relay(props: RelayProps) {
|
||||
const system = useContext(SnortContext);
|
||||
const login = useLogin();
|
||||
|
||||
const relaySettings = unwrap(
|
||||
login.relays.item[props.addr] ?? system.Sockets.find(a => a.address === props.addr)?.settings ?? {},
|
||||
);
|
||||
const relaySettings = unwrap(login.relays.item[props.addr] ?? system.pool.getConnection(props.addr)?.Settings ?? {});
|
||||
const state = useRelayState(props.addr);
|
||||
const name = useMemo(() => getRelayName(props.addr), [props.addr]);
|
||||
|
||||
@ -44,14 +42,14 @@ export default function Relay(props: RelayProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="relay bg-dark">
|
||||
<div className={classNames("flex items-center", state?.connected ? "bg-success" : "bg-error")}>
|
||||
<div className={classNames("flex items-center", state?.IsClosed === false ? "bg-success" : "bg-error")}>
|
||||
<RelayFavicon url={props.addr} />
|
||||
</div>
|
||||
<div className="flex flex-col g8">
|
||||
<div>
|
||||
<b>{name}</b>
|
||||
</div>
|
||||
{!state?.ephemeral && (
|
||||
{!state?.Ephemeral && (
|
||||
<div className="flex g8">
|
||||
<AsyncIcon
|
||||
iconName="write"
|
||||
@ -85,7 +83,7 @@ export default function Relay(props: RelayProps) {
|
||||
iconName="gear"
|
||||
iconSize={16}
|
||||
className="button-icon-sm transparent"
|
||||
onClick={() => navigate(state?.id ?? "")}
|
||||
onClick={() => navigate(state?.Id ?? "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import "./SearchBox.css";
|
||||
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { NostrLink, tryParseNostrLink } from "@snort/system";
|
||||
import { socialGraphInstance } from "@snort/system";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
@ -10,11 +8,9 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import Spinner from "@/Components/Icons/Spinner";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import fuzzySearch, { FuzzySearchResult } from "@/Db/FuzzySearch";
|
||||
import useProfileSearch from "@/Hooks/useProfileSearch";
|
||||
import { fetchNip05Pubkey } from "@/Utils/Nip05/Verifier";
|
||||
|
||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "../../Feed/TimelineFeed";
|
||||
|
||||
const MAX_RESULTS = 3;
|
||||
|
||||
export default function SearchBox() {
|
||||
@ -29,53 +25,7 @@ export default function SearchBox() {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const resultListRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const options: TimelineFeedOptions = {
|
||||
method: "LIMIT_UNTIL",
|
||||
window: undefined,
|
||||
now: unixNow(),
|
||||
};
|
||||
|
||||
const subject: TimelineSubject = {
|
||||
type: "profile_keyword",
|
||||
discriminator: search,
|
||||
items: search ? [search] : [],
|
||||
relay: undefined,
|
||||
streams: false,
|
||||
};
|
||||
|
||||
const { main } = useTimelineFeed(subject, options);
|
||||
|
||||
const [results, setResults] = useState<FuzzySearchResult[]>([]);
|
||||
useEffect(() => {
|
||||
const searchString = search.trim();
|
||||
const fuseResults = fuzzySearch.search(searchString);
|
||||
|
||||
const followDistanceNormalizationFactor = 3;
|
||||
|
||||
const combinedResults = fuseResults.map(result => {
|
||||
const fuseScore = result.score === undefined ? 1 : result.score;
|
||||
const followDistance =
|
||||
socialGraphInstance.getFollowDistance(result.item.pubkey) / followDistanceNormalizationFactor;
|
||||
|
||||
const startsWithSearchString = [result.item.name, result.item.display_name, result.item.nip05].some(
|
||||
field => field && field.toLowerCase?.().startsWith(searchString.toLowerCase()),
|
||||
);
|
||||
|
||||
const boostFactor = startsWithSearchString ? 0.25 : 1;
|
||||
|
||||
const weightForFuseScore = 0.8;
|
||||
const weightForFollowDistance = 0.2;
|
||||
|
||||
const combinedScore = (fuseScore * weightForFuseScore + followDistance * weightForFollowDistance) * boostFactor;
|
||||
|
||||
return { ...result, combinedScore };
|
||||
});
|
||||
|
||||
// Sort by combined score, lower is better
|
||||
combinedResults.sort((a, b) => a.combinedScore - b.combinedScore);
|
||||
|
||||
setResults(combinedResults.map(r => r.item));
|
||||
}, [search, main]);
|
||||
const results = useProfileSearch(search);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
|
@ -1,406 +0,0 @@
|
||||
import "./SendSats.css";
|
||||
|
||||
import { LNURLSuccessAction } from "@snort/shared";
|
||||
import { HexKey } from "@snort/system";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import AsyncButton from "@/Components/Button/AsyncButton";
|
||||
import CloseButton from "@/Components/Button/CloseButton";
|
||||
import Copy from "@/Components/Copy/Copy";
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import Modal from "@/Components/Modal/Modal";
|
||||
import QrCode from "@/Components/QrCode";
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { debounce } from "@/Utils";
|
||||
import { formatShort } from "@/Utils/Number";
|
||||
import { Zapper, ZapTarget, ZapTargetResult } from "@/Utils/Zapper";
|
||||
import { LNWallet, useWallet } from "@/Wallet";
|
||||
|
||||
import messages from "../messages";
|
||||
|
||||
enum ZapType {
|
||||
PublicZap = 1,
|
||||
AnonZap = 2,
|
||||
PrivateZap = 3,
|
||||
NonZap = 4,
|
||||
}
|
||||
|
||||
export interface SendSatsProps {
|
||||
onClose?: () => void;
|
||||
targets?: Array<ZapTarget>;
|
||||
show?: boolean;
|
||||
invoice?: string; // shortcut to invoice qr tab
|
||||
title?: ReactNode;
|
||||
notice?: string;
|
||||
note?: HexKey;
|
||||
allocatePool?: boolean;
|
||||
}
|
||||
|
||||
export default function SendSats(props: SendSatsProps) {
|
||||
const onClose = props.onClose || (() => undefined);
|
||||
|
||||
const [zapper, setZapper] = useState<Zapper>();
|
||||
const [invoice, setInvoice] = useState<Array<ZapTargetResult>>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [success, setSuccess] = useState<LNURLSuccessAction>();
|
||||
const [amount, setAmount] = useState<SendSatsInputSelection>();
|
||||
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const walletState = useWallet();
|
||||
const wallet = walletState.wallet;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.show) {
|
||||
const invoiceTarget = {
|
||||
target: {
|
||||
type: "lnurl",
|
||||
value: "",
|
||||
weight: 1,
|
||||
},
|
||||
pr: props.invoice,
|
||||
paid: false,
|
||||
sent: 0,
|
||||
fee: 0,
|
||||
} as ZapTargetResult;
|
||||
|
||||
setError(undefined);
|
||||
setInvoice(props.invoice ? [invoiceTarget] : undefined);
|
||||
setSuccess(undefined);
|
||||
}
|
||||
}, [props.show]);
|
||||
|
||||
useEffect(() => {
|
||||
if (success && !success.url) {
|
||||
// Fire onClose when success is set with no URL action
|
||||
return debounce(1_000, () => {
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
}, [success]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.targets && props.show) {
|
||||
try {
|
||||
const zapper = new Zapper(system, publisher);
|
||||
zapper.load(props.targets).then(() => {
|
||||
setZapper(zapper);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [props.targets, props.show]);
|
||||
|
||||
function successAction() {
|
||||
if (!success) return null;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<p className="flex g12">
|
||||
<Icon name="check" className="success" />
|
||||
{success?.description ?? <FormattedMessage defaultMessage="Paid" id="u/vOPu" />}
|
||||
</p>
|
||||
{success.url && (
|
||||
<p>
|
||||
<a href={success.url} rel="noreferrer" target="_blank">
|
||||
{success.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function title() {
|
||||
if (!props.targets) {
|
||||
return (
|
||||
<>
|
||||
<h2>
|
||||
{zapper?.canZap() ? (
|
||||
<FormattedMessage defaultMessage="Send zap" id="5ykRmX" />
|
||||
) : (
|
||||
<FormattedMessage defaultMessage="Send sats" id="DKnriN" />
|
||||
)}
|
||||
</h2>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (props.targets.length === 1 && props.targets[0].name) {
|
||||
const t = props.targets[0];
|
||||
const values = {
|
||||
name: t.name,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{t.zap?.pubkey && <ProfileImage pubkey={t.zap.pubkey} showUsername={false} />}
|
||||
<h2>
|
||||
{zapper?.canZap() ? (
|
||||
<FormattedMessage defaultMessage="Send zap to {name}" id="SMO+on" values={values} />
|
||||
) : (
|
||||
<FormattedMessage defaultMessage="Send sats to {name}" id="JGrt9q" values={values} />
|
||||
)}
|
||||
</h2>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (props.targets.length > 1) {
|
||||
const total = props.targets.reduce((acc, v) => (acc += v.weight), 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col g12">
|
||||
<h2>
|
||||
{zapper?.canZap() ? (
|
||||
<FormattedMessage defaultMessage="Send zap splits to" id="ZS+jRE" />
|
||||
) : (
|
||||
<FormattedMessage defaultMessage="Send sats splits to" id="uc0din" />
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex g4 f-wrap">
|
||||
{props.targets.map(v => (
|
||||
<ProfileImage
|
||||
key={v.value}
|
||||
pubkey={v.value}
|
||||
showUsername={false}
|
||||
showFollowDistance={false}
|
||||
imageOverlay={formatShort(Math.floor((amount?.amount ?? 0) * (v.weight / total)))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(props.show ?? false)) return null;
|
||||
return (
|
||||
<Modal id="send-sats" className="lnurl-modal" onClose={onClose}>
|
||||
<div className="p flex flex-col g12">
|
||||
<div className="flex g12">
|
||||
<div className="flex items-center grow">{props.title || title()}</div>
|
||||
<CloseButton onClick={onClose} />
|
||||
</div>
|
||||
{zapper && !invoice && (
|
||||
<SendSatsInput
|
||||
zapper={zapper}
|
||||
onChange={v => setAmount(v)}
|
||||
onNextStage={async p => {
|
||||
const targetsWithComments = (props.targets ?? []).map(v => {
|
||||
if (p.comment) {
|
||||
v.memo = p.comment;
|
||||
}
|
||||
if (p.type === ZapType.AnonZap && v.zap) {
|
||||
v.zap = {
|
||||
...v.zap,
|
||||
anon: true,
|
||||
};
|
||||
} else if (p.type === ZapType.NonZap) {
|
||||
v.zap = undefined;
|
||||
}
|
||||
return v;
|
||||
});
|
||||
if (targetsWithComments.length > 0) {
|
||||
const sends = await zapper.send(wallet, targetsWithComments, p.amount);
|
||||
if (sends[0].error) {
|
||||
setError(sends[0].error.message);
|
||||
} else if (sends.every(a => a.paid)) {
|
||||
setSuccess({});
|
||||
} else {
|
||||
setInvoice(sends);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{error && <p className="error">{error}</p>}
|
||||
{invoice && !success && (
|
||||
<SendSatsInvoice
|
||||
invoice={invoice}
|
||||
wallet={wallet}
|
||||
notice={props.notice}
|
||||
onInvoicePaid={() => {
|
||||
setSuccess({});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{successAction()}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface SendSatsInputSelection {
|
||||
amount: number;
|
||||
comment?: string;
|
||||
type: ZapType;
|
||||
}
|
||||
|
||||
function SendSatsInput(props: {
|
||||
zapper: Zapper;
|
||||
onChange?: (v: SendSatsInputSelection) => void;
|
||||
onNextStage: (v: SendSatsInputSelection) => Promise<void>;
|
||||
}) {
|
||||
const { defaultZapAmount, readonly } = useLogin(s => ({
|
||||
defaultZapAmount: s.appData.item.preferences.defaultZapAmount,
|
||||
readonly: s.readonly,
|
||||
}));
|
||||
const { formatMessage } = useIntl();
|
||||
const amounts: Record<string, string> = {
|
||||
[defaultZapAmount.toString()]: "",
|
||||
"1000": "👍",
|
||||
"5000": "💜",
|
||||
"10000": "😍",
|
||||
"20000": "🤩",
|
||||
"50000": "🔥",
|
||||
"100000": "🚀",
|
||||
"1000000": "🤯",
|
||||
};
|
||||
const [comment, setComment] = useState<string>();
|
||||
const [amount, setAmount] = useState<number>(defaultZapAmount);
|
||||
const [customAmount, setCustomAmount] = useState<number>(defaultZapAmount);
|
||||
const [zapType, setZapType] = useState(readonly ? ZapType.AnonZap : ZapType.PublicZap);
|
||||
|
||||
function getValue() {
|
||||
return {
|
||||
amount,
|
||||
comment,
|
||||
type: zapType,
|
||||
} as SendSatsInputSelection;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props.onChange) {
|
||||
props.onChange(getValue());
|
||||
}
|
||||
}, [amount, comment, zapType]);
|
||||
|
||||
function renderAmounts() {
|
||||
const min = props.zapper.minAmount() / 1000;
|
||||
const max = props.zapper.maxAmount() / 1000;
|
||||
const filteredAmounts = Object.entries(amounts).filter(([k]) => Number(k) >= min && Number(k) <= max);
|
||||
|
||||
return (
|
||||
<div className="amounts">
|
||||
{filteredAmounts.map(([k, v]) => (
|
||||
<span
|
||||
className={`sat-amount ${amount === Number(k) ? "active" : ""}`}
|
||||
key={k}
|
||||
onClick={() => setAmount(Number(k))}>
|
||||
{v}
|
||||
{k === "1000" ? "1K" : formatShort(Number(k))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function custom() {
|
||||
const min = props.zapper.minAmount() / 1000;
|
||||
const max = props.zapper.maxAmount() / 1000;
|
||||
|
||||
return (
|
||||
<div className="flex g8">
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
className="grow"
|
||||
placeholder={formatMessage(messages.Custom)}
|
||||
value={customAmount}
|
||||
onChange={e => setCustomAmount(parseInt(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
disabled={!customAmount}
|
||||
onClick={() => setAmount(customAmount ?? 0)}>
|
||||
<FormattedMessage {...messages.Confirm} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col g24">
|
||||
<div className="flex flex-col g8">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Zap amount in sats" id="zcaOTs" />
|
||||
</h3>
|
||||
{renderAmounts()}
|
||||
{custom()}
|
||||
{props.zapper.maxComment() > 0 && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={formatMessage(messages.Comment)}
|
||||
className="grow"
|
||||
maxLength={props.zapper.maxComment()}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<SendSatsZapTypeSelector zapType={zapType} setZapType={setZapType} />
|
||||
{(amount ?? 0) > 0 && (
|
||||
<AsyncButton onClick={() => props.onNextStage(getValue())}>
|
||||
<Icon name="zap" />
|
||||
<FormattedMessage defaultMessage="Zap {n} sats" id="8QDesP" values={{ n: formatShort(amount) }} />
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SendSatsZapTypeSelector({ zapType, setZapType }: { zapType: ZapType; setZapType: (t: ZapType) => void }) {
|
||||
const { readonly } = useLogin(s => ({ readonly: s.readonly }));
|
||||
const makeTab = (t: ZapType, n: React.ReactNode) => (
|
||||
<button type="button" className={zapType === t ? "" : "secondary"} onClick={() => setZapType(t)}>
|
||||
{n}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col g8">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Zap Type" id="+aZY2h" />
|
||||
</h3>
|
||||
<div className="flex g8">
|
||||
{!readonly &&
|
||||
makeTab(ZapType.PublicZap, <FormattedMessage defaultMessage="Public" id="/PCavi" description="Public Zap" />)}
|
||||
{/*makeTab(ZapType.PrivateZap, "Private")*/}
|
||||
{makeTab(ZapType.AnonZap, <FormattedMessage defaultMessage="Anon" id="wWLwvh" description="Anonymous Zap" />)}
|
||||
{makeTab(
|
||||
ZapType.NonZap,
|
||||
<FormattedMessage defaultMessage="Non-Zap" id="AnLrRC" description="Non-Zap, Regular LN payment" />,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SendSatsInvoice(props: {
|
||||
invoice: Array<ZapTargetResult>;
|
||||
wallet?: LNWallet;
|
||||
notice?: ReactNode;
|
||||
onInvoicePaid: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center g12 txt-center">
|
||||
{props.notice && <b className="error">{props.notice}</b>}
|
||||
{props.invoice.map(v => (
|
||||
<>
|
||||
<QrCode data={v.pr} link={`lightning:${v.pr}`} />
|
||||
<div className="flex flex-col g12">
|
||||
<Copy text={v.pr} maxSize={26} className="items-center" />
|
||||
<a href={`lightning:${v.pr}`}>
|
||||
<button type="button">
|
||||
<FormattedMessage defaultMessage="Open Wallet" id="HbefNb" />
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import "./Tabs.css";
|
||||
import "./TabSelectors.css";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
@ -20,7 +20,7 @@ interface TabElementProps extends Omit<TabsProps, "tabs"> {
|
||||
t: Tab;
|
||||
}
|
||||
|
||||
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
|
||||
export const TabSelector = ({ t, tab, setTab }: TabElementProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`tab${tab.value === t.value ? " active" : ""}${t.disabled ? " disabled" : ""}`}
|
||||
@ -30,15 +30,15 @@ export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
|
||||
const TabSelectors = ({ tabs, tab, setTab }: TabsProps) => {
|
||||
const horizontalScroll = useHorizontalScroll();
|
||||
return (
|
||||
<div className="tabs" ref={horizontalScroll}>
|
||||
{tabs.map((t, index) => (
|
||||
<TabElement key={index} tab={tab} setTab={setTab} t={t} />
|
||||
<TabSelector key={index} tab={tab} setTab={setTab} t={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
export default TabSelectors;
|
@ -30,6 +30,8 @@ export interface TextProps {
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const baseImageWidth = 910;
|
||||
|
||||
const gridConfigMap = new Map<number, number[][]>([
|
||||
[1, [[4, 3]]],
|
||||
[
|
||||
@ -137,14 +139,16 @@ export default function Text({
|
||||
</a>
|
||||
);
|
||||
|
||||
const RevealMediaInstance = ({ content, data }: { content: string; data?: object }) => {
|
||||
const RevealMediaInstance = ({ content, data, size }: { content: string; data?: object; size?: number }) => {
|
||||
const imeta = data as IMeta | undefined;
|
||||
|
||||
return (
|
||||
<RevealMedia
|
||||
key={content}
|
||||
link={content}
|
||||
creator={creator}
|
||||
meta={imeta}
|
||||
size={size}
|
||||
onMediaClick={e => {
|
||||
if (!disableMediaSpotlight) {
|
||||
e.stopPropagation();
|
||||
@ -196,7 +200,13 @@ export default function Text({
|
||||
}
|
||||
}
|
||||
if (galleryImages.length === 1) {
|
||||
chunks.push(<RevealMediaInstance content={galleryImages[0].content} data={galleryImages[0].data} />);
|
||||
chunks.push(
|
||||
<RevealMediaInstance
|
||||
content={galleryImages[0].content}
|
||||
data={galleryImages[0].data}
|
||||
size={baseImageWidth}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
// We build a grid layout to render the grouped images
|
||||
const imagesWithGridConfig = galleryImages.map((gi, index) => {
|
||||
@ -215,6 +225,7 @@ export default function Text({
|
||||
height,
|
||||
};
|
||||
});
|
||||
const size = Math.floor(baseImageWidth / Math.min(4, Math.ceil(Math.sqrt(galleryImages.length))));
|
||||
const gallery = (
|
||||
<div className="-mx-4 md:mx-0 my-2 gallery">
|
||||
{imagesWithGridConfig.map(img => (
|
||||
@ -226,7 +237,7 @@ export default function Text({
|
||||
gridColumn: `span ${img.gridColumn}`,
|
||||
gridRow: `span ${img.gridRow}`,
|
||||
}}>
|
||||
<RevealMediaInstance content={img.content} data={img.data} />
|
||||
<RevealMediaInstance content={img.content} data={img.data} size={size} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -243,7 +254,7 @@ export default function Text({
|
||||
if (disableMedia ?? false) {
|
||||
chunks.push(<DisableMedia content={element.content} />);
|
||||
} else {
|
||||
chunks.push(<RevealMediaInstance content={element.content} data={element.data} />);
|
||||
chunks.push(<RevealMediaInstance content={element.content} data={element.data} size={baseImageWidth} />);
|
||||
}
|
||||
}
|
||||
if (element.type === "invoice") {
|
||||
|
@ -1,14 +1,15 @@
|
||||
import "@webscopeio/react-textarea-autocomplete/style.css";
|
||||
import "./Textarea.css";
|
||||
|
||||
import { CachedMetadata, NostrPrefix } from "@snort/system";
|
||||
import { NostrPrefix } from "@snort/system";
|
||||
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
||||
import { useIntl } from "react-intl";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
|
||||
import { UserCache } from "@/Cache";
|
||||
import Avatar from "@/Components/User/Avatar";
|
||||
import Nip05 from "@/Components/User/Nip05";
|
||||
import { FuzzySearchResult } from "@/Db/FuzzySearch";
|
||||
import { userSearch } from "@/Hooks/useProfileSearch";
|
||||
import { hexToBech32 } from "@/Utils";
|
||||
import searchEmoji from "@/Utils/emoji-search";
|
||||
|
||||
@ -28,7 +29,7 @@ const EmojiItem = ({ entity: { name, char } }: { entity: EmojiItemProps }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const UserItem = (metadata: CachedMetadata) => {
|
||||
const UserItem = (metadata: FuzzySearchResult) => {
|
||||
const { pubkey, display_name, nip05, ...rest } = metadata;
|
||||
return (
|
||||
<div key={pubkey} className="user-item">
|
||||
@ -45,7 +46,7 @@ const UserItem = (metadata: CachedMetadata) => {
|
||||
|
||||
interface TextareaProps {
|
||||
autoFocus: boolean;
|
||||
className: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
onChange(ev: React.ChangeEvent<HTMLTextAreaElement>): void;
|
||||
value: string;
|
||||
@ -59,8 +60,8 @@ interface TextareaProps {
|
||||
const Textarea = (props: TextareaProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const userDataProvider = async (token: string) => {
|
||||
return await UserCache.search(token);
|
||||
const userDataProvider = (token: string) => {
|
||||
return userSearch(token).slice(0, 10);
|
||||
};
|
||||
|
||||
const emojiDataProvider = async (token: string) => {
|
||||
@ -84,7 +85,7 @@ const Textarea = (props: TextareaProps) => {
|
||||
"@": {
|
||||
afterWhitespace: true,
|
||||
dataProvider: userDataProvider,
|
||||
component: (props: { entity: CachedMetadata }) => <UserItem {...props.entity} />,
|
||||
component: (props: { entity: FuzzySearchResult }) => <UserItem {...props.entity} />,
|
||||
output: (item: { pubkey: string }) => `@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
|
||||
},
|
||||
}}
|
||||
|
@ -17,7 +17,7 @@ import useLogin from "@/Hooks/useLogin";
|
||||
import useModeration from "@/Hooks/useModeration";
|
||||
import { System } from "@/system";
|
||||
|
||||
export default function TrendingNotes({ count = Infinity, small = false }: { count: number; small: boolean }) {
|
||||
export default function TrendingNotes({ count = Infinity, small = false }: { count?: number; small?: boolean }) {
|
||||
const api = new NostrBandApi();
|
||||
const { lang } = useLocale();
|
||||
const trendingNotesUrl = api.trendingNotesUrl(lang);
|
||||
@ -35,7 +35,7 @@ export default function TrendingNotes({ count = Infinity, small = false }: { cou
|
||||
console.error(`Event with invalid sig\n\n${ev}\n\nfrom ${trendingNotesUrl}`);
|
||||
return;
|
||||
}
|
||||
System.HandleEvent(ev as TaggedNostrEvent);
|
||||
System.HandleEvent("*", ev as TaggedNostrEvent);
|
||||
return ev;
|
||||
}),
|
||||
);
|
||||
|
@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
|
||||
const animals = [
|
||||
|
@ -4,7 +4,6 @@ import type { UserMetadata } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
|
||||
import Icon from "@/Components/Icons/Icon";
|
||||
import { ProxyImg } from "@/Components/ProxyImg";
|
||||
import { defaultAvatar, getDisplayName } from "@/Utils";
|
||||
|
||||
@ -60,7 +59,6 @@ const Avatar = ({
|
||||
size={s}
|
||||
alt={getDisplayName(user, "")}
|
||||
promptToLoadDirectly={false}
|
||||
missingImageElement={<Icon name="x" className="warning" />}
|
||||
/>
|
||||
{icons && <div className="icons">{icons}</div>}
|
||||
{imageOverlay && <div className="overlay">{imageOverlay}</div>}
|
||||
|
12
packages/app/src/Components/User/AvatarGroup.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { HexKey } from "@snort/system";
|
||||
import React from "react";
|
||||
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
|
||||
export function AvatarGroup({ ids, onClick }: { ids: HexKey[]; onClick?: () => void }) {
|
||||
return ids.map((a, index) => (
|
||||
<div className={`inline-block ${index > 0 ? "-ml-5" : ""}`} key={a} style={{ zIndex: ids.length - index }}>
|
||||
<ProfileImage link="" onClick={onClick} showFollowDistance={false} pubkey={a} size={24} showUsername={false} />
|
||||
</div>
|
||||
));
|
||||
}
|