Compare commits

...

166 Commits

Author SHA1 Message Date
ce4d99dc88 updated contributors list 2024-01-31 01:50:59 +00:00
74d6cc9932
fix: lockfile 2024-01-30 23:02:39 +00:00
07510d92ca
refactor: include relays in kind3 2024-01-30 22:38:23 +00:00
ad8d0af976
chore: bump pacakges 2024-01-30 22:04:29 +00:00
2ef1b591e2 chore: Update translations 2024-01-30 20:38:48 +00:00
a7c0cf7397
chore: add worker-relay readme 2024-01-30 20:32:43 +00:00
a14a5fa96b
chore: publish worker-relay 2024-01-30 20:09:38 +00:00
8c19f4de68
chore: Remove here map 2024-01-30 20:09:38 +00:00
5fc844b911 chore: Update translations 2024-01-29 14:46:38 +00:00
14c8c9a080
feat: render NIP-107 data 2024-01-29 14:38:31 +00:00
470e5b31ce update preview api url in sw 2024-01-27 10:26:27 +02:00
82d5b9fb64 note translation sw & lru cache 2024-01-27 10:20:19 +02:00
dc99d2a653 SearchBox: ask relays 2024-01-26 20:36:05 +02:00
e343c5cb9b add headers to iris 2024-01-26 19:58:48 +02:00
b07f9abe16
fix: infinite scroll 2024-01-26 17:22:02 +00:00
404a07f45a
fix: docker build 2024-01-26 16:55:30 +00:00
3fb7b7adc4
fix: ci buildx push 2024-01-26 15:42:00 +00:00
28f7133236
fix: ci add builx builder 2024-01-26 15:37:51 +00:00
c18f8eddbb
fix: ci build command 2024-01-26 15:29:40 +00:00
d55c9ad122
fix: ci try node:current img 2024-01-26 15:23:27 +00:00
fea7a9a63a
chore: update vite 2024-01-26 15:17:29 +00:00
5d9b306d41
fix: drone build 2024-01-26 15:05:14 +00:00
8061410333
fix: NWC wrong method names 2024-01-26 13:37:49 +00:00
52b52deb72
chore: remove unused 2024-01-26 13:34:31 +00:00
68583e24b8
chore: always show deck link 2024-01-26 13:30:48 +00:00
88766c6c08
chore: formatting 2024-01-26 13:29:59 +00:00
c8c0cc2ac5
fix: dm chat list hidden on mobile 2024-01-26 13:29:15 +00:00
3355822bcd
chore: release notes 2024-01-26 13:14:18 +00:00
0fd8cf3f49
fix: profile page notes 2024-01-26 12:17:47 +00:00
1aaee2a2cb
chore: cleanup 2024-01-26 11:47:25 +00:00
dae96109b8 chore: Update translations 2024-01-26 11:26:24 +00:00
f9a0516718
fix: null relay tag in event reply 2024-01-26 11:24:58 +00:00
d3e6ddc64c
chore: formatting 2024-01-26 11:18:23 +00:00
7a6637a86f
feat: query save/restore 2024-01-26 11:18:23 +00:00
22863a289d
fix: notification avatar overflow
refactor: sort notification group avatar by WoT score
2024-01-26 11:18:23 +00:00
f10ad6dd53
fix: profile mentions 2024-01-26 11:18:22 +00:00
d3873ea281
fix: preload 2024-01-26 11:18:22 +00:00
4f4649da2c
feat: price chart 2024-01-26 11:18:22 +00:00
9a220fafd5
refactor: revert LocalSearch 2024-01-26 11:18:22 +00:00
e72f779ab7 chore: Update translations 2024-01-25 16:24:41 +00:00
9a3207bfa3
feat: add fallback sync 2024-01-25 16:02:16 +00:00
d7460651c8
feat: negentropy 2024-01-25 15:21:42 +00:00
9a0bbb8b74
refactor: hashtags timeline weaver to worker relay 2024-01-24 11:43:51 +00:00
f9d08267a6 chore: Update translations 2024-01-23 22:24:52 +00:00
e9d9bf34d8
refactor: migrate chats to relay worker cache 2024-01-23 22:16:53 +00:00
c968fa43a6 chore: Update translations 2024-01-23 15:47:12 +00:00
982f4df0a3
chore: formatting 2024-01-23 15:36:48 +00:00
5cea096067
feat: @snort/system CacheRelay 2024-01-23 15:35:28 +00:00
d6c578fafc
fix: config 2024-01-22 17:21:46 +00:00
e9cf2e141b
chore: update _headers 2024-01-22 16:58:15 +00:00
02ec637266
chore: remove unused 2024-01-22 16:48:48 +00:00
e7f9b5e2ea
refactor: improve whitelabel config 2024-01-22 16:41:50 +00:00
4aa00405ee chore: Update translations 2024-01-22 15:06:57 +00:00
65a96eb77b
refactor: more config options for generic config 2024-01-22 15:00:17 +00:00
6fd02cffbb
feat: generic nostr.com client config 2024-01-22 13:52:18 +00:00
ef8a5c29bf
chore: update hostname 2024-01-22 12:58:15 +00:00
381a849a11 chore: Update translations 2024-01-22 11:38:58 +00:00
45fbd06bff
feat: play stream with zap.stream embed 2024-01-22 11:33:38 +00:00
d1ebd49d56 chore: Update translations 2024-01-20 11:09:15 +00:00
6354472d05 "view as user" button in profile 2024-01-20 13:00:47 +02:00
6a1a990e57 chore: Update translations 2024-01-20 00:00:44 +00:00
d1972542b7
fix: iframe credentialless 2024-01-19 23:59:01 +00:00
9ceb3c705f chore: Update translations 2024-01-19 23:23:53 +00:00
9be57a6e84
configure sqlite relay 2024-01-19 23:16:45 +00:00
6722ad5f8e lrucache fix 2024-01-20 00:42:31 +02:00
29cb9a61b4 lrucache fix 2024-01-20 00:26:02 +02:00
a66f7f5fd8 chore: Update translations 2024-01-19 22:18:19 +00:00
2033137ae2
refactor: return fuzzy profile search 2024-01-19 22:16:48 +00:00
3d2f11f206 correct LRUCache size param 2024-01-20 00:07:12 +02:00
08bfd38563 add profile events from db to fuzzy search 2024-01-20 00:01:13 +02:00
8fb127b347 initial state from LRUCache 2024-01-20 00:00:50 +02:00
5ddc5ee8df
fix: delete from search 2024-01-19 20:05:46 +00:00
53c8ccbd0f
feat: local releay search 2024-01-19 19:56:14 +00:00
9654f70c22 chore: Update translations 2024-01-19 13:40:42 +00:00
bf66f273e3
chore: include sourcemap 2024-01-19 13:38:37 +00:00
da6fa415dd
fix: use command queue for batch event write 2024-01-19 13:25:14 +00:00
72b98a4ab5
fix: set coop/coep in function mw 2024-01-19 13:12:39 +00:00
7e88d96ddb
fix: COEP header 2024-01-19 13:01:38 +00:00
cb0b75c652
feat: add fallback memory relay 2024-01-19 11:56:18 +00:00
8e33d10319 chore: Update translations 2024-01-19 10:37:24 +00:00
0b307ae691
refactor: delay batches until req's finish 2024-01-19 10:26:37 +00:00
2b1cf34424
refactor: move inMemoryDb hook 2024-01-19 10:09:36 +00:00
0307bacd30
fixes 2024-01-18 22:47:48 +00:00
aa9d5d72be chore: Update translations 2024-01-18 22:44:43 +00:00
ba3e901e9b
refactor: fix followgraph / add indexes 2024-01-18 22:39:27 +00:00
6eef8c7fef
feat: profile cache worker relay 2024-01-18 22:39:27 +00:00
084558b3e7 chore: Update translations 2024-01-18 21:17:38 +00:00
32a6d56cf5
feat: use worker relay for events cache 2024-01-18 21:13:32 +00:00
c2f78dad1e chore: Update translations 2024-01-18 16:08:18 +00:00
0239db393f
chore: formatting 2024-01-18 16:01:19 +00:00
f147edd03c
fix: relay-worker insert replacable events 2024-01-18 16:01:19 +00:00
e3f8d48ddb logging 2024-01-18 16:40:01 +02:00
d019544053 chore: Update translations 2024-01-18 14:19:35 +00:00
712848a129
feat: skip internal query store 2024-01-18 14:06:13 +00:00
3ff651ec37
feat: request builder option fillStore 2024-01-18 13:06:52 +00:00
f20cd8a119
chore: remove unused 2024-01-18 12:40:59 +00:00
2d4c323cf7
feat: emit updates from relay-worker 2024-01-18 12:28:16 +00:00
6d8c0325e4
feat: process worker messages in queue 2024-01-18 12:27:05 +00:00
2ea516e636 useSubscribe, handle emitted requests in sqlite 2024-01-18 13:47:29 +02:00
b7e61ebde5
fix: remove semicolon 2024-01-17 23:01:24 +00:00
2e27c1b41a
chore: fix strings 2024-01-17 23:00:53 +00:00
34b2d9b743 chore: Update translations 2024-01-17 21:17:55 +00:00
d990e9ffad
chore: move headers file 2024-01-17 21:10:07 +00:00
62ff3df30d chore: Update translations 2024-01-17 16:39:35 +00:00
f043a9ee96
chore: add extra headers 2024-01-17 16:34:44 +00:00
adb9fe5c2e
chore: formatting 2024-01-17 15:48:30 +00:00
aa58ec4185
feat: upgrade caches to worker 2024-01-17 15:47:01 +00:00
3c808688f8 fix mobile footer sized padding 2024-01-16 20:08:27 +02:00
aa430de168 rm nip 113 2024-01-16 20:08:07 +02:00
fe46959424 chore: Update translations 2024-01-15 19:59:25 +00:00
9ae097907a
fix: config preferences 2024-01-15 19:49:38 +00:00
a7ac246a43
feat: worker-relay pkg 2024-01-15 16:57:20 +00:00
6899e46622
chore: bump pkgs 2024-01-15 12:04:59 +00:00
e45d6ffa52 chore: Update translations 2024-01-15 11:34:15 +00:00
3b6e194ded chore: Update translations 2024-01-15 11:29:06 +00:00
21d7df0eac
refactor: use link preview from nostr-services api 2024-01-15 11:16:41 +00:00
ad79091356 chore: Update translations 2024-01-15 08:25:32 +00:00
148acc764c add event ids from q.feed.takeSnapshot() to filter.not 2024-01-15 10:21:16 +02:00
1f90b2fe90 add some search relays 2024-01-15 01:57:27 +02:00
bc22ee7d56
fix: wallet pay plain invoice 2024-01-14 17:01:10 +00:00
eb2601448c fix global tab 2024-01-14 14:58:52 +02:00
773db5dea6 no walletbalance in narrow sidebar 2024-01-14 12:12:36 +02:00
4fe2554d9d enable subscriptions on iris 2024-01-14 12:10:25 +02:00
d4233a818e lint 2024-01-13 23:06:25 +02:00
13b7a16dc7 system.HandleEvent -> querymanager -> matching queries 2024-01-13 22:45:30 +02:00
736c2577db chore: Update translations 2024-01-13 18:54:14 +00:00
7935d3d86a memoize Timeline subject 2024-01-13 20:45:17 +02:00
57d4d6b2c6 chore: Update translations 2024-01-13 13:33:53 +00:00
47fc8e1414 fix polls 2024-01-13 15:25:21 +02:00
a98bbd65b5 fix profile notes tab 2024-01-12 17:51:23 +02:00
ef673c2a05 tab selector vs content naming, refactoring 2024-01-12 17:33:02 +02:00
ffa4a192f6 split ProfilePage 2024-01-12 17:02:44 +02:00
5ab39aafe8 chore: Update translations 2024-01-12 13:55:39 +00:00
43ed484bcc
chore: generate docs 2024-01-12 13:53:42 +00:00
2cadab20b4 fix feed padding bottom 2024-01-12 15:37:15 +02:00
1cef1e0187 light theme 2024-01-12 15:28:16 +02:00
e0c4b64865 light theme notecreator style 2024-01-12 15:22:14 +02:00
a9405388c0 light theme primary button bg 2024-01-12 15:13:46 +02:00
775ee6423f chore: Update translations 2024-01-12 12:40:53 +00:00
1aaff4f553 extend default preferences from config 2024-01-12 14:37:13 +02:00
6657161a32 open zaps tab 2024-01-12 14:06:13 +02:00
7ee210da16 open reactions modal from note footer zappers list 2024-01-12 14:01:26 +02:00
26e12d1c0b chore: Update translations 2024-01-11 23:08:03 +00:00
703da5389a add +n after zappers 2024-01-12 01:04:41 +02:00
ad2029d1d7 no hover bg color in notecreator 2024-01-12 00:09:44 +02:00
20d3fdaa6e chore: Update translations 2024-01-11 21:51:15 +00:00
69d6dfd5d6 rename SendSats -> ZapModal 2024-01-11 23:47:25 +02:00
c8dae9fae6 split SendSats 2024-01-11 23:33:24 +02:00
649bab228b split notefooter into smaller components, CONFIG.showPowIcon 2024-01-11 23:04:43 +02:00
edca8a9636 chore: Update translations 2024-01-11 14:40:01 +00:00
9bdf60a24f extract zap button component from note footer 2024-01-11 16:32:52 +02:00
dffb33bfda notefooter icons 2024-01-11 15:46:13 +02:00
de6685ade3 show zapper avatars on the same notefooter row 2024-01-11 15:46:13 +02:00
536f8ddc5b
refactor: adjust response headers 2024-01-11 11:18:26 +00:00
d45d601712 chore: Update translations 2024-01-11 10:00:36 +00:00
c75ab861b5
fix: muted note styles
closes #721
2024-01-11 09:54:11 +00:00
3fe6ed952c
Merge remote-tracking branch 'kamal/enhancements/snort#702' 2024-01-11 09:38:50 +00:00
8090bb1718
chore: add jeremy 2024-01-11 09:32:43 +00:00
501ad41fff chore: Update translations 2024-01-11 09:24:36 +00:00
ee01623bf1 memoize proxyimg 2024-01-11 11:19:16 +02:00
eb9cf7f361 fuzzy search on search page 2024-01-11 10:54:27 +02:00
45f66fd139 gallery img sizing 2024-01-11 09:21:30 +02:00
8043f1034f chore: Update translations 2024-01-11 07:14:00 +00:00
76d3c78c0a replied note auto height, resized feed imgs 2024-01-11 08:56:23 +02:00
3c97d73536 remove custom style and use tailwind 2023-12-18 08:05:04 +00:00
77925e6647 hiddennote style changes, new preference to hide muted notes 2023-12-18 08:05:02 +00:00
264 changed files with 6142 additions and 3672 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
**/node_modules
**/.pnp.*
**/.yarn/*
!**/.yarn/patches
!**/.yarn/plugins
!**/.yarn/releases
!**/.yarn/sdks
!**/.yarn/versions
**/.idea
**/target

View File

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

@ -11,4 +11,5 @@ dist/
*.tgz
*.log
.DS_Store
.pnp*
.pnp*
docs/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" }
]
}

View File

@ -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" }]
}

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

View File

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

View File

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

View File

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

View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { processWorkQueue, WorkQueueItem } from "@snort/shared";
export const ZapperQueue: Array<WorkQueueItem> = [];
processWorkQueue(ZapperQueue);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}&nbsp;
{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>
);
}

View File

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

View File

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

View File

@ -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)}`,
},
}}

View File

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

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines */
import { sha256 } from "@noble/hashes/sha256";
const animals = [

View File

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

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

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