Compare commits

...

240 Commits
nak ... main

Author SHA1 Message Date
6977f80652
chore: zapstore release 2025-02-13 15:18:50 +00:00
e3a8495c01
chore: upgrade wasm lib 2025-01-20 16:34:11 +00:00
ea07f91651
fix: import 2025-01-20 14:32:21 +00:00
fb600afabc
Revert "chore: disable caches"
This reverts commit 1f0f45e3f95ed2b44dbd03b2dae848e94b93412d.
2025-01-20 14:27:48 +00:00
68790a4fbb
fix: search box 2025-01-20 14:25:55 +00:00
723abea3d9
fix: lock file 2025-01-12 10:08:38 +00:00
d46b5a052b
chore: change config 2025-01-12 10:07:02 +00:00
Sandwich
048fdf463b fix PRE and RE query/insert 2025-01-07 17:06:13 +07:00
f8f07a02bb
chore: bump pkgs 2024-12-26 14:35:30 +00:00
e9fd593468
fix: note creator tagging 2024-12-21 14:06:06 +00:00
f425830678
fix: build 2024-12-21 13:56:27 +00:00
1f0f45e3f9
chore: disable caches 2024-12-21 13:55:28 +00:00
609361d40c
feat: worker-relay: add logging to interface 2024-12-21 13:41:55 +00:00
201f194f02
chore: zapstore.yaml 2024-12-21 12:44:41 +00:00
38b8103281
fix: build 2024-12-18 11:35:43 +00:00
70fcf955e3 chore: Update translations 2024-12-18 09:51:37 +00:00
9e59a2d693
fix: nip96 upload 2024-12-18 09:50:15 +00:00
3e25743cdc
feat: show usd price 2024-12-18 09:47:35 +00:00
3c4fe85694 chore: Update translations 2024-12-16 15:14:16 +00:00
53b64165db
fix: birthday 2024-12-16 15:12:58 +00:00
c13cf88ce2
Merge remote-tracking branch 'dskvr/main' 2024-12-12 10:21:24 +00:00
Kroese
782135f90d chore: Update translations 2024-12-10 15:53:59 +00:00
Kroese
5f9678ad50
feat: Add config for soloco.nl 2024-12-10 15:52:24 +00:00
0901e54ab6
fix: small performance tweaks
Some checks failed
Release / app (push) Has been cancelled
2024-12-06 13:22:22 +00:00
897bb46706 chore: Update translations 2024-12-06 12:03:52 +00:00
0f256f5628
fix: lock 2024-12-06 12:02:36 +00:00
2a33033f0a
chore: bump wallet 2024-12-06 12:00:23 +00:00
5338f3acab
feat: nip55 2024-12-06 11:59:15 +00:00
892b00810d
fix: use nip44 for nip46 comms 2024-12-06 11:07:28 +00:00
6eafdc1367
feat: expose signer via UserState 2024-11-25 12:30:49 +00:00
f081405e3c chore: Update translations 2024-11-22 21:41:46 +00:00
63a1f53567
fix: client tag 2024-11-22 21:40:31 +00:00
25958b53c2
fix: tag 2024-11-22 17:01:13 +00:00
4c238a159f chore: Update translations 2024-11-22 16:57:35 +00:00
4bf02d243e
fix: lockfile 2024-11-22 16:56:03 +00:00
e1f3a5703e
chore: bump pkg 2024-11-22 16:43:25 +00:00
104091d31b
chore: bump pkgs 2024-11-22 16:36:44 +00:00
a37a396ed1
chore: bump pkgs 2024-11-22 16:31:46 +00:00
d91ff62aeb
chore: formatting 2024-11-22 16:30:30 +00:00
d16df8086a
feat: client tag
feat: attach relays to link for event
2024-11-22 16:30:09 +00:00
Sandwich
497e12c644 add sql AND tags filter 2024-11-14 13:14:11 +01:00
Sandwich
7002647ffc emulate code style 2024-11-13 13:39:21 +01:00
Sandwich
556ea69ddd add NIP-119 compat 2024-11-13 13:25:13 +01:00
edc59fbf79
chore: bump pkg 2024-10-25 18:05:20 +01:00
8d3de80e1e
fix: relays tag missing 2024-10-25 18:04:20 +01:00
eaaa7edc78
feat: upgrade bot 2024-10-20 22:11:34 +01:00
b417ff27d7
chore: formatting 2024-10-20 20:03:47 +01:00
ed8aec1008
feat: @snort/bot 2024-10-20 20:03:20 +01:00
07a0d3ce57
fix: remove global link 2024-09-25 11:12:20 +01:00
dd3767b7d7
chore: remove NDK 2024-09-25 11:11:05 +01:00
b73fcca239
fix: note creator 2024-09-24 09:42:32 +01:00
5a8bbdc60b chore: Update translations 2024-09-22 15:01:34 +00:00
e1a5292c0a
fix: floating point created_at 2024-09-22 16:00:10 +01:00
baed390beb
fix: use inner created_at timestamp nip17 2024-09-22 15:46:46 +01:00
a6e6e0a632 chore: Update translations 2024-09-21 22:26:06 +00:00
8a3d9a3d83
chore: fix lock 2024-09-21 23:24:45 +01:00
415e4e3705
refactor: use nostr-social-graph 2024-09-21 23:22:31 +01:00
304976d66d chore: Update translations 2024-09-20 21:16:58 +00:00
c274c0a842
refactor: polish 2024-09-20 22:15:34 +01:00
4b3e7710e0 chore: Update translations 2024-09-20 09:28:21 +00:00
ed694e5f5d
fix: negentropy version check 2024-09-20 10:26:41 +01:00
153a76a855 chore: Update translations 2024-09-19 18:41:09 +00:00
6bc882fd41
feat: basic livekit setup 2024-09-19 19:39:55 +01:00
1121d8d56a
chore: bump pkg 2024-09-19 13:56:39 +01:00
7350acce95
refactor: move zapper to @snort/wallet 2024-09-19 13:55:26 +01:00
38b9d132d5
chore: fix build 2024-09-19 12:08:01 +01:00
032294456e
chore: refactor tabs 2024-09-19 12:08:01 +01:00
0aff59b274 chore: Update translations 2024-09-19 10:53:27 +00:00
2f37885e08
refactor: cleanup reactions modal 2024-09-19 11:52:00 +01:00
8c2833684b
feat: latest articles widget 2024-09-19 11:51:59 +01:00
026aaed5a1 chore: Update translations 2024-09-19 09:38:41 +00:00
41c0047f5b
chore: cleanup right widget styles & NIP-89 handler 2024-09-19 10:37:11 +01:00
17e8955f54 chore: Update translations 2024-09-18 14:40:01 +00:00
290dedb333
feat: modular right bar 2024-09-18 15:37:59 +01:00
b38b6b27ef
refactor: use optimizer compress 2024-09-18 13:39:26 +01:00
b26eb5007f
chore: enable WoT for existing sessions 2024-09-18 13:12:31 +01:00
f8f1b70787 chore: Update translations 2024-09-18 12:09:39 +00:00
b457b7b536
feat: WoT filter 2024-09-18 13:07:53 +01:00
0d5dcd43a4
fix: remove cached html response 2024-09-16 17:51:48 +01:00
54dbef9d45
refactor: improve rebroadcaster 2024-09-16 17:46:23 +01:00
3b929c31ac
chore: fix build 2024-09-16 17:24:45 +01:00
49bed6a198 chore: Update translations 2024-09-16 11:31:22 +00:00
0fc99a2a53
fix: tlv max length 2024-09-16 12:30:00 +01:00
b0d2081b45
feat: show quote note id when loading
feat: try load quote note from other relays
2024-09-16 11:31:22 +01:00
21e88b06cb
feat: track event seen on relays
fix: dump/clear commands
2024-09-16 10:55:15 +01:00
b49144399c
chore: upgrade tauri 2024-09-16 09:26:29 +01:00
5be02ca62f
chore: bump version 2024-09-13 15:31:20 +01:00
7a97efb8c4
chore: bump pkgs 2024-09-13 12:09:02 +01:00
a3803a8612 chore: Update translations 2024-09-13 11:00:23 +00:00
0faf903ef0
fix: manually connect to local cache relay
closes #751
2024-09-13 11:58:54 +01:00
ae943ce173
fix: display name 2024-09-13 11:44:05 +01:00
1ef968e184
feat: show not footer for reposts
closes #464
2024-09-13 11:28:40 +01:00
753336cb16
fix: relay icon highlight 2024-09-13 11:25:46 +01:00
d7e22f1207 chore: Update translations 2024-09-13 10:23:17 +00:00
f335d48e69
feat: profile tab top zappers
closes #662
2024-09-13 11:21:50 +01:00
267ebe2ed9
fix: copy text from note
closes #663
2024-09-13 10:59:49 +01:00
b5c106d579
feat: replace http nostr links with native nostr links
closes #666
refactor: embeds
2024-09-13 10:52:32 +01:00
dd7424f616 chore: Update translations 2024-09-13 09:02:17 +00:00
580751568d
feat: remove summary chart
closes #747
2024-09-13 10:00:51 +01:00
b97633d3a3
chore: rebuild wasm 2024-09-12 22:31:20 +01:00
a44698c1d7 chore: Update translations 2024-09-12 21:25:11 +00:00
59278ef8cc
fix: errors 2024-09-12 22:24:02 +01:00
5c37edc2c2 chore: Update translations 2024-09-12 21:17:17 +00:00
ca4170be4f
feat: nip-89 2024-09-12 22:15:54 +01:00
c74af0159c
refactor: live streams 2024-09-12 19:46:23 +01:00
9049f337b0 chore: Update translations 2024-09-12 15:29:12 +00:00
35a4aba3b8
chore: fix build 2024-09-12 16:28:03 +01:00
e877625923
refactor: useRequestBuilder 2024-09-12 16:26:40 +01:00
9eb97701b4 chore: Update translations 2024-09-12 14:04:43 +00:00
cde0d8305b
fix: outline overflow 2024-09-12 15:03:24 +01:00
18ffbb5204 chore: Update translations 2024-09-12 13:57:44 +00:00
ce10d920f4
fix: default nip96 2024-09-12 14:56:15 +01:00
736189d0d2 chore: Update translations 2024-09-12 13:01:44 +00:00
7b6a813c8a
refactor: move new users page to task 2024-09-12 14:00:00 +01:00
b238da4024 chore: Update translations 2024-09-12 12:19:48 +00:00
a1e99c8830
feat: revamp note creator 2024-09-12 13:18:28 +01:00
f217ca7e64 chore: Update translations 2024-09-11 20:43:07 +00:00
4c80fb78ec
feat: close relays 2024-09-11 21:41:37 +01:00
623e5f68a0 chore: Update translations 2024-09-11 12:52:12 +00:00
6fafae67aa
feat: relay info page 2024-09-11 13:50:52 +01:00
9660c07633 chore: Update translations 2024-09-11 11:23:48 +00:00
040519a097
feat: reliable relays 2024-09-11 12:22:32 +01:00
0ea1f2d81a chore: Update translations 2024-09-10 15:18:03 +00:00
4816dae99b
feat: reommended relays description 2024-09-10 16:16:50 +01:00
bcabf41793 chore: Update translations 2024-09-10 15:14:45 +00:00
1db1e7fb5f
feat: relay uptime 2024-09-10 16:12:24 +01:00
a3b53de456
fix: build 2024-09-09 21:10:28 +01:00
88a53a0c06
fix: nprofile login 2024-09-09 16:25:05 +01:00
b5c8f39446 chore: Update translations 2024-09-09 15:21:19 +00:00
68b5cd60a6
feat: simple relay page 2024-09-09 16:19:39 +01:00
40ffebb0c2
fix: nip6
closes #729
2024-09-09 15:03:24 +01:00
7822f6a62b chore: Update translations 2024-09-09 13:30:16 +00:00
18a75a601b
chore: formatting 2024-09-09 14:29:02 +01:00
f2319b074f
fix: mute buttons
closes #748
2024-09-09 14:27:59 +01:00
c515e9837d
feat: npub/nprofile QR selector
closes #749
2024-09-09 13:48:04 +01:00
8f0b1d3f66
chore: cleanup chat links 2024-08-27 14:11:23 +01:00
2c58239e2c
chore: defer social graph loading 2024-08-26 16:40:51 +03:00
001d2c415c
fix: relay loader bug 2024-08-26 16:40:35 +03:00
eec52efc3e
fix: hide unknown event json 2024-08-26 16:22:26 +03:00
1f3e048f10
fix: remove extra spacing 2024-08-26 16:09:22 +03:00
644bd57094
feat: nip44 v2 2024-08-26 15:57:46 +03:00
1ed36352a8 chore: Update translations 2024-08-25 16:21:25 +00:00
a3bc479b1f
fix: build 2024-08-25 19:20:08 +03:00
d9abcd0101
refactor: drop nip4 2024-08-25 19:17:22 +03:00
44a014b8c6
feat: replaceable query filters 2024-07-17 16:44:35 +01:00
24a72529c2
chore: bump pkgs 2024-07-08 10:06:03 +01:00
c39811e700
fix: load social graph for follows only 2024-07-04 12:23:23 +01:00
69f6694f5c
chore: extract isRequestSatisfied 2024-07-04 12:19:02 +01:00
dc4758d302
fix: reduce reloads for timeline 2024-07-04 12:01:01 +01:00
64ad548d75
refactor: query cache relay once per query emit 2024-07-04 11:45:20 +01:00
4a48f4f340
feat: remove buildDiff 2024-07-04 11:25:26 +01:00
8e84dc6a89
fix: apk signer
Some checks failed
Release / app (push) Has been cancelled
2024-07-01 14:57:46 +01:00
f362389aef chore: Update translations 2024-07-01 13:48:48 +00:00
06fae1d916
chore: remove tauri build 2024-07-01 14:47:07 +01:00
a027a4d90a
fix: lockfile 2024-07-01 14:43:45 +01:00
1e40c1f71d
fix: remove mute list shit 2024-07-01 14:31:04 +01:00
40aa21cb4b
chore: bump pkgs 2024-06-25 16:16:53 +01:00
bdc94cec8b
refactor: tweak profile loader 2024-06-25 16:14:53 +01:00
1a405f7796
fix: tweak re-connect 2024-06-18 12:47:47 +01:00
d8e0c935b9
fix: handle disconnect as connect failure 2024-06-18 12:26:15 +01:00
5ea7cd17d8
fix: fail after local relay 2024-06-18 12:05:28 +01:00
338c4eb18a
fix: remove cache-relay on connect failed 2024-06-18 11:24:23 +01:00
e9adb6c724 chore: Update translations 2024-06-18 10:12:55 +00:00
f2b08ce3a7
fix: startup 2024-06-18 11:11:45 +01:00
3486f0f8fb
fix: disable version mark script 2024-06-18 11:06:02 +01:00
66172d18e6
fix: remove inline sw registration (CSP) 2024-06-18 11:02:48 +01:00
dd45d1f428
fix: then null prop 2024-06-18 10:52:23 +01:00
87a2742c26 chore: Update translations 2024-06-18 09:51:58 +00:00
229c34eab7
fix: disable for you feed using localhost relay 2024-06-18 10:50:49 +01:00
37000ecc7a
feat: use localhost relay over worker-relay 2024-06-18 10:49:29 +01:00
44435db21d
fix: fetch results 2024-06-18 10:09:41 +01:00
64a34e43f0
fix: build 2024-06-17 14:28:41 +01:00
e26b881297
chore: formatting 2024-06-17 14:26:46 +01:00
5bd00491f6
chore: cleanup worker relay 2024-06-17 14:26:46 +01:00
471c10feb0 chore: Update translations 2024-06-17 13:21:50 +00:00
a31c054326
chore: cache compressed filters 2024-06-17 14:20:26 +01:00
240c3ba1c3 chore: Update translations 2024-06-05 14:39:17 +00:00
ebc45ae9c1
chore: move sqlite files
fix: debug print for deletes
2024-06-05 15:33:11 +01:00
b2b25377cd
chore: bump pkgs 2024-06-05 14:26:13 +01:00
c47d8e7dcd
feat: dev-docs 2024-06-05 13:25:34 +01:00
57bf51c41c
refactor: move connection sync module 2024-06-05 13:08:55 +01:00
4185f117cb
chore: adjust sync method 2024-06-04 13:16:27 +01:00
de1504b4bf chore: Update translations 2024-06-03 08:04:37 +00:00
5eb5255ed8
fix: lockfile 2024-06-03 09:03:34 +01:00
8feb178ed5
Merge pull request #585 from enjikaka/feature/tidal-encrypted-media
[TIDAL Embeds] Add allow and sandbox attributes
2024-06-03 08:59:36 +01:00
Jeremy Karlsson
0c90f248fd
Add allow and sandbox attributes to TIDAL Embeds. 2024-06-03 09:53:53 +02:00
f2f8b6b225
chore: ToNostrEventTag.equals 2024-05-28 13:09:34 +01:00
bc2169a186
chore: bump packages 2024-05-23 12:06:23 +01:00
b764cc1535
feat: worker-relay delete
fix: worker-relay insert replacable events duplicate
2024-05-23 11:59:48 +01:00
bb5bf34fe9
chore: bump system-wasm 2024-05-22 20:14:40 +01:00
1c29e3b1c6 chore: Update translations 2024-05-22 17:38:06 +00:00
6cbc3aeb7f
fix: lazy init nip46, no pubkey 2024-05-22 18:24:51 +01:00
9e896c5c27 chore: Update translations 2024-05-15 14:54:03 +00:00
1512f38e7c
chore: bump pkgs 2024-05-15 15:52:55 +01:00
bb2b0901c4 chore: Update translations 2024-05-14 12:17:32 +00:00
5763d91e8a
feat: NIP-96 server list 2024-05-14 13:16:03 +01:00
7d0d3030f4
fix: attach file extension to nip96 uploads 2024-05-14 12:16:27 +01:00
f8f54a4e50
fix: service worker bug 2024-05-13 14:54:03 +01:00
9b99d0e2ea
chore: add cache-control headers for service-worker.js 2024-05-13 14:45:58 +01:00
8daadb47c2 chore: Update translations 2024-05-13 13:34:11 +00:00
1060122263
fix: service worker 2024-05-13 14:33:14 +01:00
9feb98f277 chore: Update translations 2024-05-13 13:20:49 +00:00
b5ca5327db
feat: custom nip96 server 2024-05-13 14:19:28 +01:00
ecd3876287 chore: Update translations 2024-05-09 10:40:29 +00:00
7b781f38df
fix: lockfile 2024-05-09 11:38:53 +01:00
ef9efb0422
chore: default uploads to nostr.build 2024-05-09 11:32:32 +01:00
eef43d68d7
chore: bump 2024-05-01 18:23:15 +01:00
746021d40a
fix: isReplyToThis 2024-05-01 18:22:11 +01:00
e4f611834e
fix: nip96 bugs 2024-05-01 17:58:40 +01:00
1c44eeabf2
chore: bump pkgs 2024-05-01 11:13:18 +01:00
2ceddf40b9
fix: simplify reply check 2024-05-01 11:11:04 +01:00
d3bcb75f3a
fix: select state.version for selector hook 2024-05-01 10:52:24 +01:00
6398e470ef
fix: follow graph / readonly login 2024-05-01 10:34:28 +01:00
778ce1a24f
chore: delete files 2024-05-01 09:50:50 +01:00
4873e0ad72
chore: deploy:notestr 2024-04-30 10:22:45 +01:00
ddd1b3e83f
fix: startup bad migration 2024-04-29 17:45:25 +01:00
87144d9395
refactor: upgrade nip17 2024-04-29 17:41:11 +01:00
c775236438
chore: enable nip44 on nip7 signer 2024-04-29 15:55:35 +01:00
a6707c111f
chore: some notification fixes 2024-04-29 13:52:07 +01:00
b199d1a366
fix: sync contacts 2024-04-29 11:39:26 +01:00
a17c98ad25
fix: muted words 2024-04-29 11:22:26 +01:00
08d4a73ca6
fix: unknown thread context 2024-04-29 11:01:55 +01:00
76a561624c
fix: missing words 2024-04-29 10:54:06 +01:00
a591a3c176
chore: meku fixes 2024-04-29 10:21:28 +01:00
9abb236ede
chore: add meku config 2024-04-29 10:06:08 +01:00
e6faf5d3ad
chore: use babel transformer for formatted message id 2024-04-29 09:54:41 +01:00
b642f13d36
fix: displayAs 2024-04-26 14:28:24 +01:00
85261eaeab
chore: formatting 2024-04-26 14:06:53 +01:00
b7d2c599e1
feat: new timeline render flow 2024-04-26 14:06:52 +01:00
38af05edb8
fix: connection race 2024-04-26 14:06:52 +01:00
5af182c1bd
chore: reduce notifications view size 2024-04-26 14:06:51 +01:00
d0d30cb94f
fix: reconnect spam 2024-04-26 14:06:51 +01:00
52688e6389
fix: reactions loading 2024-04-26 14:06:51 +01:00
a6a1198f04 chore: Update translations 2024-04-24 11:09:57 +00:00
9ad5c2fa70
chore: setup ngit 2024-04-24 12:08:15 +01:00
986a7afd42
chore: update tauri 2024-04-23 16:05:13 +01:00
a3299ab29a
fix: various 2024-04-23 15:43:07 +01:00
9ddd8fc6c2
fix: connection props 2024-04-23 13:39:53 +01:00
eee76e64e5
feat: NDK (WIP) 2024-04-23 13:08:45 +01:00
ea54ee2b00 chore: Update translations 2024-04-22 20:17:01 +00:00
432 changed files with 21884 additions and 9138 deletions

View File

@ -8,42 +8,6 @@ env:
DOCKER_CLI_EXPERIMENTAL: enabled
TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$"
jobs:
tauri_release:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-20.04, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
- name: Rust setup
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
- name: Sync node version and setup cache
uses: actions/setup-node@v3
with:
node-version: "16"
cache: "yarn"
- name: Install frontend dependencies
run: yarn install
- name: Build the app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
app:
runs-on: ubuntu-latest
permissions:
@ -92,7 +56,8 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "33.0.0"
- name: Sign APK
uses: r0adkll/sign-android-release@v1
with:
@ -101,6 +66,8 @@ jobs:
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "33.0.0"
- name: Rename files
run: |-
mkdir -p snort_android/app/release

1
.gitignore vendored
View File

@ -13,3 +13,4 @@ dist/
.DS_Store
.pnp*
docs/
.wrangler/

View File

@ -38,13 +38,14 @@ Snort supports the following NIP's:
- [x] NIP-50: Search
- [x] NIP-51: Lists
- [x] NIP-53: Live Events
- [x] NIP-55: Android signer application
- [x] NIP-57: Zaps
- [x] NIP-58: Badges
- [x] NIP-59: Gift Wrap
- [x] NIP-65: Relay List Metadata
- [x] NIP-75: Zap Goals
- [x] NIP-78: App specific data
- [ ] NIP-89: App handlers
- [x] NIP-89: App handlers
- [x] NIP-94: File Metadata
- [x] NIP-96: HTTP File Storage Integration (Draft)
- [x] NIP-98: HTTP Auth

30
dev-docs/query.md Normal file
View File

@ -0,0 +1,30 @@
# Reactions
## Problem
When presented with a feed of notes, either a timeline (social) or a live chat log (live stream chat)
how do you fetch the reactions to such notes and maintain realtime updates.
## Current solution
When a list of reactions is requested we use the expensive `buildDiff` operation to compute a
list of new (added) filters and send them to relays.
Usually if `leaveOpen` is specified (as it should be for realtime updates) this new trace will be sent
as a separate subscription causing exhasution.
Another side effect of this this approach is that over time (especially in live chat) the number of filters that get passed to `buildDiff` increases and so the computation time takes longer and causes jank (https://git.v0l.io/Kieran/zap.stream/issues/126).
There is also the question of updating the "root" query, since this is not updated, each independant query trace receives its own set of updates which is a problem of its own.
## Proposed solution (Live chat)
The ideal solution is to update only the "root" query as new filters are detected along with appending the current timestamp as the `since` value.
In this way only 1 subscription is maintained, the "root" query trace.
Each time a new set of filters is created from `buildDiff` we push the same `REQ` again with the new filters which **should** result in no new results from the relays as we expect there to be none `since` the current time is the time of the latest message.
## Proposed solution (Timeline)
TBD

View File

@ -37,7 +37,7 @@ export const onRequest: PagesFunction<Env> = async context => {
return new Response(body, {
headers: {
...Object.fromEntries(rsp.headers.entries()),
"cache-control": "public, max-age=60",
"cache-control": "no-cache",
},
});
}

8
maintainers.yaml Normal file
View File

@ -0,0 +1,8 @@
maintainers:
- npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk
- npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49
relays:
- wss://relay.snort.social/
- wss://pyramid.fiatjaf.com/
- wss://nos.lol/
- ws://skzzn6cimfdv5e2phjc4yr5v7ikbxtn5f7dkwn5c7v47tduzlbosqmqd.onion/

11
nap.yaml Normal file
View File

@ -0,0 +1,11 @@
id: "social.snort.app"
name: "Snort"
description: ""
icon: "https://snort.social/nostrich_256.png"
images:
- "https://snort.social/nostrich_512.png"
repository: "https://github.com/v0l/snort"
license: "MIT"
tags:
- "social"
- "twitter"

View File

@ -21,12 +21,12 @@
"packageManager": "yarn@4.1.1",
"dependencies": {
"@cloudflare/workers-types": "^4.20230307.0",
"@tauri-apps/cli": "^1.2.3",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0-rc.14",
"typedoc": "^0.25.7"
}
}

View File

@ -4,12 +4,6 @@ module.exports = {
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "formatjs", "react-refresh", "simple-import-sort"],
rules: {
"formatjs/enforce-id": [
"error",
{
idInterpolationPattern: "[sha512:contenthash:base64:6]",
},
],
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react-refresh/only-export-components": "error",

View File

@ -26,3 +26,4 @@ yarn-error.log*
dist/
dev-dist/
.wrangler/

View File

@ -0,0 +1,11 @@
{
"plugins": [
[
"formatjs",
{
"idInterpolationPattern": "[sha512:contenthash:base64:6]",
"ast": true
}
]
]
}

View File

@ -12,11 +12,10 @@
"defaultZapPoolFee": 1,
"features": {
"analytics": true,
"subscriptions": true,
"deck": true,
"zapPool": true,
"notificationGraph": true,
"communityLeaders": true,
"subscriptions": false,
"deck": false,
"zapPool": false,
"communityLeaders": false,
"nostrAddress": true,
"pushNotifications": true
},
@ -36,23 +35,32 @@
"list": "naddr1qq4xc6tnw3ez6vp58y6rywpjxckngdtyxukngwr9vckkze33vcknzcnrxcenje35xqmn2cczyp3lucccm3v9s087z6qslpkap8schltk427zfgqgrn3g2menq5zw6qcyqqq82vqprpmhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv7rajfl"
},
"noteCreatorToast": false,
"hideFromNavbar": ["/graph"],
"hideFromNavbar": [],
"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://relay.damus.io/": { "read": true, "write": true },
"wss://nos.lol/": { "read": true, "write": true }
"wss://relay.snort.social/": {
"read": true,
"write": true
},
"wss://nostr.wine/": {
"read": true,
"write": false
},
"wss://relay.damus.io/": {
"read": true,
"write": true
},
"wss://nos.lol/": {
"read": true,
"write": true
}
},
"alby": {
"clientId": "pohiJjPhQR",
"clientSecret": "GAl1YKLA3FveK1gLBYok"
},
"chatChannels": [
{ "type": "telegram", "value": "https://t.me/irismessenger" },
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
]
"chatChannels": []
}

View File

@ -15,7 +15,6 @@
"subscriptions": true,
"deck": true,
"zapPool": true,
"notificationGraph": false,
"communityLeaders": true
},
"defaultPreferences": {

View File

@ -0,0 +1,49 @@
{
"appName": "めく",
"appNameCapitalized": "めく",
"appTitle": "めく",
"hostname": "meku.app",
"nip05Domain": "meku.app",
"icon": "/nostr.jpg",
"navLogo": null,
"publicDir": "public/nostr",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 0,
"features": {
"analytics": true,
"subscriptions": false,
"deck": false,
"zapPool": false,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": true
},
"signUp": {
"quickStart": false,
"defaultFollows": []
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following",
"language": "ja"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": null,
"noteCreatorToast": false,
"hideFromNavbar": [],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.nostr.wirednet.jp/": { "read": true, "write": true },
"wss://yabu.me/": { "read": true, "write": true },
"wss://nos.lol/": { "read": true, "write": true }
},
"alby": null,
"chatChannels": null
}

View File

@ -15,7 +15,6 @@
"subscriptions": false,
"deck": false,
"zapPool": false,
"notificationGraph": true,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": false
@ -34,7 +33,7 @@
},
"communityLeaders": null,
"noteCreatorToast": true,
"hideFromNavbar": ["/graph"],
"hideFromNavbar": [],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",

View File

@ -0,0 +1,49 @@
{
"appName": "Soloco",
"appNameCapitalized": "Soloco",
"appTitle": "Soloco",
"hostname": "soloco.nl",
"nip05Domain": "soloco.nl",
"icon": "/nostrich_512.png",
"favicon": "public/favicon.ico",
"appleTouchIconUrl": "/nostrich_512.png",
"navLogo": null,
"publicDir": "public/snort",
"httpCache": "",
"animalNamePlaceholders": false,
"defaultZapPoolFee": 0,
"features": {
"analytics": false,
"subscriptions": false,
"deck": false,
"zapPool": false,
"communityLeaders": false,
"nostrAddress": false,
"pushNotifications": true
},
"signUp": {
"quickStart": false,
"defaultFollows": []
},
"defaultPreferences": {
"hideMutedNotes": false,
"defaultRootTab": "following",
"language": "nl"
},
"media": {
"bypassImgProxyError": false,
"preferLargeMedia": true
},
"communityLeaders": null,
"noteCreatorToast": true,
"hideFromNavbar": [],
"deckSubKind": 1,
"showPowIcon": true,
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://soloco.nl/": { "read": true, "write": false }
},
"alby": null,
"chatChannels": null
}

View File

@ -57,7 +57,6 @@ declare const CONFIG: {
subscriptions: boolean;
deck: boolean;
zapPool: boolean;
notificationGraph: boolean;
communityLeaders: boolean;
nostrAddress: boolean;
pushNotifications: boolean;

View File

@ -4,11 +4,13 @@
"dependencies": {
"@cashu/cashu-ts": "^1.0.0-rc.3",
"@here/maps-api-for-javascript": "^1.50.0",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.3.3",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.3.0",
"@scure/bip39": "^1.1.1",
"@livekit/components-react": "^2.5.4",
"@livekit/protocol": "^1.22.0",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.6",
"@scure/bip32": "^1.5.0",
"@scure/bip39": "^1.4.0",
"@snort/shared": "workspace:*",
"@snort/system": "workspace:*",
"@snort/system-react": "workspace:*",
@ -16,7 +18,7 @@
"@snort/system-web": "workspace:*",
"@snort/wallet": "workspace:*",
"@snort/worker-relay": "workspace:*",
"@szhsin/react-menu": "^3.3.1",
"@szhsin/react-menu": "^3.5.3",
"@uidotdev/usehooks": "^2.4.1",
"@void-cat/api": "^1.0.12",
"classnames": "^2.3.2",
@ -29,6 +31,7 @@
"highlight.js": "^11.8.0",
"latlon-geohash": "^2.0.0",
"light-bolt11-decoder": "^2.1.0",
"livekit-client": "^2.5.2",
"lottie-react": "^2.4.0",
"marked": "^9.1.0",
"marked-footnote": "^1.0.0",
@ -44,6 +47,7 @@
"react-textarea-autosize": "^8.4.0",
"recharts": "^2.8.0",
"three": "^0.157.0",
"tslib": "^2.7.0",
"typescript-lru-cache": "^2.0.0",
"use-long-press": "^3.2.0",
"use-sync-external-store": "^1.2.0",
@ -63,7 +67,9 @@
"test:watch": "vitest watch",
"intl-extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --flatten true",
"intl-compile": "formatjs compile src/lang.json --out-file src/translations/en.json",
"eslint": "eslint ."
"eslint": "eslint .",
"deploy:meku": "NODE_CONFIG_ENV=meku yarn build && npx wrangler pages deploy --project-name meku build/",
"deploy:notestr": "NODE_CONFIG_ENV=nostr yarn build && npx wrangler pages deploy --project-name nostr-generic build/"
},
"eslintConfig": {
"extends": [
@ -99,11 +105,13 @@
"@types/webtorrent": "^0.109.3",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"@vitejs/plugin-react": "^4.2.0",
"@webbtc/webln-types": "^2.1.0",
"@webbtc/webln-types": "^3.0.0",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"@welldone-software/why-did-you-render": "^8.0.1",
"autoprefixer": "^10.4.16",
"babel-plugin-formatjs": "^10.5.14",
"config": "^3.3.9",
"eslint": "^8.48.0",
"eslint-config-react-app": "^7.0.1",

View File

@ -1,2 +1,4 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
/service-worker.js
Cache-Control: max-age=604800, must-revalidate;

View File

@ -1,2 +1,4 @@
/*
Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com;
/service-worker.js
Cache-Control: max-age=604800, must-revalidate;

View File

@ -27,4 +27,8 @@ export class ChatCache extends FeedCache<NostrEvent> {
takeSnapshot(): Array<NostrEvent> {
return [...this.cache.values()];
}
async search() {
return <Array<NostrEvent>>[];
}
}

View File

@ -1,14 +1,13 @@
import { CachedTable, CacheEvents } from "@snort/shared";
import { NostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import { CacheRelay, NostrEvent } from "@snort/system";
import { EventEmitter } from "eventemitter3";
export class EventCacheWorker extends EventEmitter<CacheEvents> implements CachedTable<NostrEvent> {
#relay: WorkerRelayInterface;
#relay: CacheRelay;
#keys = new Set<string>();
#cache = new Map<string, NostrEvent>();
constructor(relay: WorkerRelayInterface) {
constructor(relay: CacheRelay) {
super();
this.#relay = relay;
}
@ -24,6 +23,17 @@ export class EventCacheWorker extends EventEmitter<CacheEvents> implements Cache
this.#keys = new Set<string>(ids as unknown as Array<string>);
}
async search(q: string) {
const results = await this.#relay.query([
"REQ",
"events-search",
{
search: q,
},
]);
return results;
}
keysOnTable(): string[] {
return [...this.#keys];
}

View File

@ -1,10 +1,9 @@
import { EventKind, EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { EventKind, EventPublisher, TaggedNostrEvent } from "@snort/system";
import { db, UnwrappedGift } from "@/Db";
import { findTag, unwrap } from "@/Utils";
import { LoginSession, LoginSessionType } from "@/Utils/Login";
import { RefreshFeedCache } from "./RefreshFeedCache";
import { RefreshFeedCache, TWithCreated } from "./RefreshFeedCache";
export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
constructor() {
@ -15,11 +14,8 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
return of.id;
}
buildSub(session: LoginSession, rb: RequestBuilder): void {
const pubkey = session.publicKey;
if (pubkey && session.type === LoginSessionType.PrivateKey) {
rb.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubkey]).since(this.newest());
}
buildSub(): void {
// not used
}
takeSnapshot(): Array<UnwrappedGift> {
@ -57,4 +53,8 @@ export class GiftWrapCache extends RefreshFeedCache<UnwrappedGift> {
}
await this.bulkSet(unwrapped);
}
search(): Promise<TWithCreated<UnwrappedGift>[]> {
throw new Error("Method not implemented.");
}
}

View File

@ -1,16 +1,15 @@
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { CachedMetadata, mapEventToProfile, NostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import { CachedMetadata, CacheRelay, mapEventToProfile, NostrEvent } from "@snort/system";
import debug from "debug";
import { EventEmitter } from "eventemitter3";
export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implements CachedTable<CachedMetadata> {
#relay: WorkerRelayInterface;
#relay: CacheRelay;
#keys = new Set<string>();
#cache = new Map<string, CachedMetadata>();
#log = debug("ProfileCacheRelayWorker");
constructor(relay: WorkerRelayInterface) {
constructor(relay: CacheRelay) {
super();
this.#relay = relay;
}
@ -29,6 +28,18 @@ export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implement
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
}
async search(q: string) {
const profiles = await this.#relay.query([
"REQ",
"profiles-search",
{
kinds: [0],
search: q,
},
]);
return removeUndefined(profiles.map(mapEventToProfile));
}
keysOnTable(): string[] {
return [...this.#keys];
}

View File

@ -1,16 +1,15 @@
import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { EventKind, NostrEvent, UsersFollows } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import { CacheRelay, EventKind, NostrEvent, UsersFollows } from "@snort/system";
import debug from "debug";
import { EventEmitter } from "eventemitter3";
export class UserFollowsWorker extends EventEmitter<CacheEvents> implements CachedTable<UsersFollows> {
#relay: WorkerRelayInterface;
#relay: CacheRelay;
#keys = new Set<string>();
#cache = new Map<string, UsersFollows>();
#log = debug("UserFollowsWorker");
constructor(relay: WorkerRelayInterface) {
constructor(relay: CacheRelay) {
super();
this.#relay = relay;
}
@ -29,6 +28,18 @@ export class UserFollowsWorker extends EventEmitter<CacheEvents> implements Cach
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
}
async search(q: string) {
const results = await this.#relay.query([
"REQ",
"contacts-search",
{
kinds: [3],
search: q,
},
]);
return removeUndefined(results.map(mapEventToUserFollows));
}
keysOnTable(): string[] {
return [...this.#keys];
}

View File

@ -1,4 +1,4 @@
import { RelayMetricCache, UserRelaysCache } from "@snort/system";
import { CacheRelay, Connection, ConnectionCacheRelay, RelayMetricCache, UserRelaysCache } from "@snort/system";
import { SnortSystemDb } from "@snort/system-web";
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerVite from "@snort/worker-relay/src/worker?worker";
@ -8,12 +8,52 @@ import { GiftWrapCache } from "./GiftWrapCache";
import { ProfileCacheRelayWorker } from "./ProfileWorkerCache";
import { UserFollowsWorker } from "./UserFollowsWorker";
export const Relay = new WorkerRelayInterface(
const cacheRelay = localStorage.getItem("cache-relay");
const workerRelay = new WorkerRelayInterface(
import.meta.env.DEV ? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url) : new WorkerVite(),
);
export const Relay: CacheRelay = cacheRelay
? new ConnectionCacheRelay(new Connection(cacheRelay, { read: true, write: true }))
: workerRelay;
async function tryUseCacheRelay(url: string) {
try {
const conn = new Connection(url, { read: true, write: true });
await conn.connect(true);
localStorage.setItem("cache-relay", url);
return conn;
} catch (e) {
console.warn(e);
}
}
export async function tryUseLocalRelay() {
let conn = await tryUseCacheRelay("ws://localhost:4869");
if (!conn) {
conn = await tryUseCacheRelay("ws://umbrel:4848");
}
return conn;
}
export async function initRelayWorker() {
try {
await Relay.init({
if (Relay instanceof ConnectionCacheRelay) {
await Relay.connection.connect(true);
return;
}
} catch (e) {
localStorage.removeItem("cache-relay");
console.error(e);
if (cacheRelay) {
window.location.reload();
}
}
try {
await workerRelay.debug("*");
await workerRelay.init({
databasePath: "relay.db",
insertBatchSize: 100,
});

View File

@ -12,7 +12,12 @@ interface IconButtonProps {
const IconButton = ({ onClick, icon, children, className }: IconButtonProps) => {
return (
<button className={classNames("icon", className)} type="button" onClick={onClick}>
<button
className={classNames(
"flex items-center justify-center aspect-square w-10 h-10 !p-0 !m-0 bg-gray-dark text-white",
className,
)}
onClick={onClick}>
<Icon {...icon} />
{children}
</button>

View File

@ -4,7 +4,7 @@ import { ReactNode, useState } from "react";
import Icon from "@/Components/Icons/Icon";
interface CollapsedProps {
text?: string;
text?: ReactNode;
children: ReactNode;
collapsed: boolean;
setCollapsed(b: boolean): void;
@ -33,10 +33,11 @@ interface CollapsedSectionProps {
title: ReactNode;
children: ReactNode;
className?: string;
startClosed?: boolean;
}
export const CollapsedSection = ({ title, children, className }: CollapsedSectionProps) => {
const [collapsed, setCollapsed] = useState(true);
export const CollapsedSection = ({ title, children, className, startClosed }: CollapsedSectionProps) => {
const [collapsed, setCollapsed] = useState(startClosed ?? true);
const icon = (
<div className={classNames("collapse-icon", { flip: !collapsed })}>
<Icon name="arrowFront" />

View File

@ -19,7 +19,7 @@ export function LeaderBadge() {
}}>
<AwardIcon size={16} />
<div className="text-xs font-medium text-[#AC88FF]">
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
<FormattedMessage defaultMessage="Community Leader" />
</div>
</div>
{showModal && (
@ -28,7 +28,7 @@ export function LeaderBadge() {
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
<AwardIcon size={80} />
<div className="text-3xl font-semibold">
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
<FormattedMessage defaultMessage="Community Leader" />
</div>
<p className="text-secondary">
<FormattedMessage
@ -38,7 +38,7 @@ export function LeaderBadge() {
</p>
<Link to="/settings/invite">
<button className="primary">
<FormattedMessage defaultMessage="Become a leader" id="M6C/px" />
<FormattedMessage defaultMessage="Become a leader" />
</button>
</Link>
</div>

View File

@ -22,7 +22,12 @@ export default function Copy({ text, maxSize = 32, className, showText, mask }:
: displayText;
return (
<div className={classNames("copy flex pointer g8 items-center", className)} onClick={() => copy(text)}>
<div
className={classNames("copy flex pointer g8 items-center", className)}
onClick={e => {
e.stopPropagation();
copy(text);
}}>
{(showText ?? true) && <span className="copy-body">{trimmed}</span>}
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}

View File

@ -3,21 +3,15 @@ const AppleMusicEmbed = ({ link }: { link: string }) => {
const isSongLink = /\?i=\d+$/.test(convertedUrl);
return (
<>
<iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
// eslint-disable-next-line react/no-unknown-property
credentialless=""
height={isSongLink ? 175 : 450}
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -74,7 +74,7 @@ export default function CashuNuts({ token }: { token: string }) {
<Icon name="copy" />
</AsyncButton>
<AsyncButton onClick={() => redeemToken(token)}>
<FormattedMessage defaultMessage="Redeem" id="XrSk2j" description="Button: Redeem Cashu token" />
<FormattedMessage defaultMessage="Redeem" />
</AsyncButton>
</div>
</div>

View File

@ -1,3 +1,4 @@
import { Bech32Regex } from "@snort/shared";
import { ReactNode } from "react";
import AppleMusicEmbed from "@/Components/Embed/AppleMusicEmbed";
@ -10,11 +11,11 @@ import SpotifyEmbed from "@/Components/Embed/SpotifyEmbed";
import TidalEmbed from "@/Components/Embed/TidalEmbed";
import TwitchEmbed from "@/Components/Embed/TwitchEmbed";
import WavlakeEmbed from "@/Components/Embed/WavlakeEmbed";
import YoutubeEmbed from "@/Components/Embed/YoutubeEmbed";
import { magnetURIDecode } from "@/Utils";
import {
AppleMusicRegex,
MixCloudRegex,
NostrNestsRegex,
SoundCloudRegex,
SpotifyRegex,
TidalRegex,
@ -34,57 +35,23 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
const a = link;
try {
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const isSpotifyLink = SpotifyRegex.test(a);
const isTwitchLink = TwitchRegex.test(a);
const isAppleMusicLink = AppleMusicRegex.test(a);
const isNostrNestsLink = NostrNestsRegex.test(a);
const isWavlakeLink = WavlakeRegex.test(a);
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"
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
</>
);
} else if (tidalId) {
let m = null;
if (a.match(YoutubeUrlRegex)) {
return <YoutubeEmbed link={a} />;
} else if (a.match(TidalRegex)) {
return <TidalEmbed link={a} />;
} else if (soundcloundId) {
} else if (a.match(SoundCloudRegex)) {
return <SoundCloudEmbed link={a} />;
} else if (mixcloudId) {
} else if (a.match(MixCloudRegex)) {
return <MixCloudEmbed link={a} />;
} else if (isSpotifyLink) {
} else if (a.match(SpotifyRegex)) {
return <SpotifyEmbed link={a} />;
} else if (isTwitchLink) {
} else if (a.match(TwitchRegex)) {
return <TwitchEmbed link={a} />;
} else if (isAppleMusicLink) {
} else if (a.match(AppleMusicRegex)) {
return <AppleMusicEmbed link={a} />;
} else if (isNostrNestsLink) {
return (
<>
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{children ?? a}
</a>
{/*<NostrNestsEmbed link={a} />,*/}
</>
);
} else if (isWavlakeLink) {
} else if (a.match(WavlakeRegex)) {
return <WavlakeEmbed link={a} />;
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <NostrLink link={a} depth={depth} />;
@ -93,6 +60,8 @@ export default function HyperText({ link, depth, showLinkPreview, children }: Hy
if (parsed) {
return <MagnetLink magnet={parsed} />;
}
} else if ((m = a.match(Bech32Regex)) != null) {
return <NostrLink link={`nostr:${m[1]}`} depth={depth} />;
} else if (showLinkPreview ?? true) {
return <LinkPreview url={a} />;
}

View File

@ -77,7 +77,7 @@ export default function Invoice(props: InvoiceProps) {
{description && <p>{description}</p>}
{isPaid ? (
<div className="paid">
<FormattedMessage defaultMessage="Paid" id="u/vOPu" />
<FormattedMessage defaultMessage="Paid" />
</div>
) : (
<button disabled={isExpired} type="button" onClick={payInvoice}>

View File

@ -10,7 +10,7 @@ const MagnetLink = ({ magnet }: MagnetLinkProps) => {
return (
<div className="note-invoice">
<h4>
<FormattedMessage defaultMessage="Magnet Link" id="Gcn9NQ" />
<FormattedMessage defaultMessage="Magnet Link" />
</h4>
<a href={magnet.raw} rel="noreferrer">
{magnet.dn ?? magnet.infoHash}

View File

@ -2,24 +2,21 @@ import usePreferences from "@/Hooks/usePreferences";
import { MixCloudRegex } from "@/Utils/Const";
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const match = link.match(MixCloudRegex);
if (!match) return;
const feedPath = match[1] + "%2F" + match[2];
const theme = usePreferences(s => s.theme);
const lightParams = theme === "light" ? "light=1" : "light=0";
return (
<>
<br />
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer">
{link}
</a>
</>
);
};

View File

@ -65,7 +65,6 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
return (
<FollowListBase
pubkeys={ids}
showAbout={true}
className={className}
title={findTag(ev, "title") ?? findTag(ev, "d")}
actions={
@ -81,6 +80,11 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
</AsyncButton>
</>
}
profilePreviewProps={{
options: {
about: true,
},
}}
/>
);
}

View File

@ -1,19 +1,12 @@
const SoundCloudEmbed = ({ link }: { link: string }) => {
return (
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
width="100%"
height="166"
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -2,10 +2,7 @@ const SpotifyEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
return (
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
@ -14,10 +11,6 @@ const SpotifyEmbed = ({ link }: { link: string }) => {
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
);
};

View File

@ -53,17 +53,17 @@ const TidalEmbed = ({ link }: { link: string }) => {
</a>
);
}
const iframe = (
// eslint-disable-next-line react/no-unknown-property
<iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} credentialless="" />
);
return (
<>
{iframe}
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
<iframe
src={source}
style={extraStyles}
width="100%"
allow="encrypted-media *; clipboard-write *; clipboard-read *"
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
title="TIDAL Embed"
frameBorder={0}
loading="lazy"
/>
);
};

View File

@ -3,12 +3,12 @@ const TwitchEmbed = ({ link }: { link: string }) => {
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
return (
<>
<iframe src={`https://player.twitch.tv/${args}`} className="w-max" allowFullScreen={true} />
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
<iframe
src={`https://player.twitch.tv/${args}`}
className="aspect-video w-full"
allowFullScreen={true}
loading="lazy"
/>
);
};

View File

@ -2,21 +2,7 @@ const WavlakeEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
return (
<>
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="380"
frameBorder="0"
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
<iframe style={{ borderRadius: 12 }} src={convertedUrl} width="100%" height="380" frameBorder="0" loading="lazy" />
);
};

View File

@ -0,0 +1,17 @@
import { YoutubeUrlRegex } from "@/Utils/Const";
export default function YoutubeEmbed({ link }: { link: string }) {
const m = link.match(YoutubeUrlRegex);
if (!m) return;
return (
<iframe
className="-mx-4 md:mx-0 w-max my-2"
src={`https://www.youtube.com/embed/${m[1]}${m[3] ? `?list=${m[3].slice(6)}` : ""}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
);
}

View File

@ -32,7 +32,7 @@ export default function ZapstrEmbed({ ev }: { ev: NostrEvent }) {
</div>
<Link to={`https://zapstr.live/?track=${link}`} target="_blank">
<button>
<FormattedMessage defaultMessage="Open on Zapstr" id="Lu5/Bj" />
<FormattedMessage defaultMessage="Open on Zapstr" />
</button>
</Link>
</>

View File

@ -1,101 +0,0 @@
.note-creator-modal .modal-body > div {
display: flex;
flex-direction: column;
gap: 16px;
}
.note-creator-modal .note.card {
padding: 0;
border: none;
min-height: unset;
}
.note-creator-modal .note.card.note-quote {
border: 1px solid var(--gray);
padding: 8px 12px;
}
.note-creator-modal h4 {
font-size: 11px;
font-weight: 600;
letter-spacing: 1.21px;
text-transform: uppercase;
color: var(--gray-light);
margin: 0;
}
.note-creator-relay {
background-color: var(--gray-dark);
border-radius: 12px;
}
.note-creator textarea {
border: none;
outline: none;
resize: none;
padding: 0;
border-radius: 0;
margin: 8px 12px;
min-height: 100px;
width: stretch;
width: -webkit-fill-available;
width: -moz-available;
max-height: 210px;
}
.note-creator textarea::placeholder {
color: var(--font-secondary-color);
font-size: var(--font-size);
line-height: 24px;
}
.note-creator.poll textarea {
min-height: 120px;
}
.note-creator .error {
position: absolute;
left: 16px;
bottom: 12px;
color: var(--error);
margin-right: 12px;
font-size: 16px;
}
.note-creator-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
}
.note-creator-icon.pfp .avatar {
width: 32px;
height: 32px;
}
.note-creator-modal .rti--container {
background-color: unset !important;
box-shadow: unset !important;
border: 2px solid var(--border-color) !important;
border-radius: 12px !important;
padding: 4px 8px !important;
}
.note-creator-modal .rti--tag {
color: black !important;
padding: 4px 10px !important;
border-radius: 12px !important;
display: unset !important;
}
.note-creator-modal .rti--input {
width: 100% !important;
border: unset !important;
}
.note-creator-modal .rti--tag button {
padding: 0 0 0 var(--rti-s);
}

View File

@ -1,8 +1,9 @@
/* eslint-disable max-lines */
import "./NoteCreator.css";
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
import { EventBuilder, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { ZapTarget } from "@snort/wallet";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import { ClipboardEventHandler, DragEvent, useEffect } from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -10,25 +11,26 @@ import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import CloseButton from "@/Components/Button/CloseButton";
import IconButton from "@/Components/Button/IconButton";
import { sendEventToRelays } from "@/Components/Event/Create/util";
import Note from "@/Components/Event/EventComponent";
import Flyout from "@/Components/flyout";
import Icon from "@/Components/Icons/Icon";
import { ToggleSwitch } from "@/Components/Icons/Toggle";
import Modal from "@/Components/Modal/Modal";
import Textarea from "@/Components/Textarea/Textarea";
import { Toastore } from "@/Components/Toaster/Toaster";
import ProfileImage from "@/Components/User/ProfileImage";
import { MediaServerFileList } from "@/Components/Upload/file-picker";
import Avatar from "@/Components/User/Avatar";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import useRelays from "@/Hooks/useRelays";
import { useNoteCreator } from "@/State/NoteCreator";
import { openFile, trackEvent } from "@/Utils";
import useFileUpload from "@/Utils/Upload";
import useFileUpload, { addExtensionToNip94Url, nip94TagsToIMeta, readNip94Tags } from "@/Utils/Upload";
import { GetPowWorker } from "@/Utils/wasm";
import { ZapTarget } from "@/Utils/Zapper";
import FileUploadProgress from "../FileUpload";
import { OkResponseRow } from "./OkResponseRow";
const previewNoteOptions = {
@ -59,6 +61,7 @@ export function NoteCreator() {
const { formatMessage } = useIntl();
const uploader = useFileUpload();
const publicKey = useLogin(s => s.publicKey);
const profile = useUserProfile(publicKey);
const pow = usePreferences(s => s.pow);
const relays = useRelays();
const { system, publisher: pub } = useEventPublisher();
@ -145,6 +148,18 @@ export function NoteCreator() {
extraTags ??= [];
extraTags.push(...note.hashTags.map(a => ["t", a.toLowerCase()]));
}
for (const ex of note.otherEvents ?? []) {
const meta = readNip94Tags(ex.tags);
if (!meta.url) continue;
if (!note.note.endsWith("\n")) {
note.note += "\n";
}
note.note += addExtensionToNip94Url(meta);
extraTags ??= [];
extraTags.push(nip94TagsToIMeta(meta));
}
// add quote repost
if (note.quote) {
if (!note.note.endsWith("\n")) {
@ -211,9 +226,7 @@ export function NoteCreator() {
}
trackEvent("PostNote", props);
const events = (note.otherEvents ?? []).concat(ev);
events.map(a =>
sendEventToRelays(system, a, note.selectedCustomRelays, r => {
sendEventToRelays(system, ev, note.selectedCustomRelays, r => {
if (CONFIG.noteCreatorToast) {
r.forEach(rr => {
Toastore.push({
@ -222,8 +235,7 @@ export function NoteCreator() {
});
});
}
}),
);
});
note.update(n => n.reset());
localStorage.removeItem("msgDraft");
}
@ -248,29 +260,17 @@ export function NoteCreator() {
async function uploadFile(file: File) {
try {
if (file) {
if (file && uploader) {
const rx = await uploader.upload(file, file.name);
note.update(v => {
if (rx.header) {
const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode(
CONFIG.eventLinkPrefix,
)}`;
v.note = `${v.note ? `${v.note}\n` : ""}${link}`;
v.otherEvents = [...(v.otherEvents ?? []), rx.header];
v.otherEvents ??= [];
v.otherEvents.push(rx.header);
} else if (rx.url) {
v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
if (rx.metadata) {
v.extraTags ??= [];
const imeta = ["imeta", `url ${rx.url}`];
if (rx.metadata.blurhash) {
imeta.push(`blurhash ${rx.metadata.blurhash}`);
}
if (rx.metadata.width && rx.metadata.height) {
imeta.push(`dim ${rx.metadata.width}x${rx.metadata.height}`);
}
if (rx.metadata.hash) {
imeta.push(`x ${rx.metadata.hash}`);
}
const imeta = nip94TagsToIMeta(rx.metadata);
v.extraTags.push(imeta);
}
} else if (rx?.error) {
@ -330,12 +330,12 @@ export function NoteCreator() {
return (
<>
<h4>
<FormattedMessage defaultMessage="Poll Options" id="vhlWFg" />
<FormattedMessage defaultMessage="Poll Options" />
</h4>
{note.pollOptions?.map((a, i) => (
<div className="form-group w-max" key={`po-${i}`}>
<div>
<FormattedMessage defaultMessage="Option: {n}" id="mfe8RW" values={{ n: i + 1 }} />
<FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} />
</div>
<div>
<input type="text" value={a} onChange={e => changePollOption(i, e.target.value)} />
@ -369,12 +369,12 @@ export function NoteCreator() {
function renderRelayCustomisation() {
return (
<div className="flex flex-col g8">
<div className="flex flex-col gap-2">
{Object.entries(relays)
.filter(el => el[1].write)
.map(a => a[0])
.map((r, i, a) => (
<div className="p flex justify-between note-creator-relay" key={r}>
<div className="p flex items-center justify-between bg-gray br" key={r}>
<div>{r}</div>
<div>
<input
@ -422,24 +422,24 @@ export function NoteCreator() {
<>
<div>
<h4>
<FormattedMessage defaultMessage="Custom Relays" id="EcZF24" />
<FormattedMessage defaultMessage="Custom Relays" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" id="th5lxp" />
<FormattedMessage defaultMessage="Send note to a subset of your write relays" />
</p>
{renderRelayCustomisation()}
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Zap Splits" id="5CB6zB" />
<FormattedMessage defaultMessage="Zap Splits" />
</h4>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." id="LwYmVi" />
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." />
<div className="flex flex-col g8">
{[...(note.zapSplits ?? [])].map((v: ZapTarget, i, arr) => (
<div className="flex items-center g8" key={`${v.name}-${v.value}`}>
<div className="flex flex-col flex-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" id="8Rkoyb" />
<FormattedMessage defaultMessage="Recipient" />
</h4>
<input
type="text"
@ -454,7 +454,7 @@ export function NoteCreator() {
</div>
<div className="flex flex-col flex-1 g4">
<h4>
<FormattedMessage defaultMessage="Weight" id="zCb8fX" />
<FormattedMessage defaultMessage="Weight" />
</h4>
<input
type="number"
@ -470,7 +470,7 @@ export function NoteCreator() {
}
/>
</div>
<div className="flex flex-col s g4">
<div className="flex flex-col g4">
<div>&nbsp;</div>
<Icon
name="close"
@ -484,24 +484,18 @@ export function NoteCreator() {
onClick={() =>
note.update(v => (v.zapSplits = [...(v.zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
}>
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
<FormattedMessage defaultMessage="Add" />
</button>
</div>
<span className="warning">
<FormattedMessage
defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured"
id="6bgpn+"
/>
<FormattedMessage defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured" />
</span>
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Sensitive Content" id="bQdA2k" />
<FormattedMessage defaultMessage="Sensitive Content" />
</h4>
<FormattedMessage
defaultMessage="Users must accept the content warning to show the content of your note."
id="UUPFlt"
/>
<FormattedMessage defaultMessage="Users must accept the content warning to show the content of your note." />
<input
className="w-max"
type="text"
@ -515,7 +509,7 @@ export function NoteCreator() {
})}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" id="gXgY3+" />
<FormattedMessage defaultMessage="Not all clients support this yet" />
</span>
</div>
</>
@ -525,32 +519,46 @@ export function NoteCreator() {
function noteCreatorFooter() {
return (
<div className="flex justify-between">
<div className="flex items-center g8">
<ProfileImage
pubkey={publicKey ?? ""}
className="note-creator-icon"
link=""
showUsername={false}
showFollowDistance={false}
showProfileCard={false}
/>
<div className="flex items-center gap-4 text-gray-light cursor-pointer">
<Avatar pubkey={publicKey ?? ""} user={profile} size={28} showTitle={true} />
<Menu
menuButton={
<AsyncIcon iconName="attachment" iconSize={24} className="hover:text-gray-superlight transition" />
}
menuClassName="ctx-menu no-icons">
<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={() => note.update(s => (s.filePicker = "compact"))}>
<FormattedMessage defaultMessage="From Server" />
</MenuItem>
<MenuItem onClick={() => attachFile()}>
<FormattedMessage defaultMessage="From File" />
</MenuItem>
</Menu>
{note.pollOptions === undefined && !note.replyTo && (
<AsyncIcon
iconName="list"
iconName="bar-chart"
iconSize={24}
onClick={() => note.update(v => (v.pollOptions = ["A", "B"]))}
className={classNames("note-creator-icon", { active: note.pollOptions !== undefined })}
className={classNames("hover:text-gray-superlight transition", {
"text-white": note.pollOptions !== undefined,
})}
/>
)}
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
<AsyncIcon
iconName="settings-04"
iconName="settings-outline"
iconSize={24}
onClick={() => note.update(v => (v.advanced = !v.advanced))}
className={classNames("note-creator-icon", { active: note.advanced })}
className={classNames("hover:text-gray-superlight transition", { "text-white": note.advanced })}
/>
<span className="sm:inline hidden">
<FormattedMessage defaultMessage="Preview" id="TJo5E6" />
<FormattedMessage defaultMessage="Preview" />
</span>
<ToggleSwitch
onClick={() => loadPreview()}
@ -558,19 +566,10 @@ export function NoteCreator() {
className={classNames({ active: Boolean(note.preview) })}
/>
</div>
<div className="flex g8">
<button className="secondary" onClick={cancel}>
<FormattedMessage defaultMessage="Cancel" id="47FYwb" />
</button>
<AsyncButton onClick={onSubmit} className="primary">
{note.replyTo ? (
<FormattedMessage defaultMessage="Reply" id="9HU8vw" />
) : (
<FormattedMessage defaultMessage="Send" id="9WRlF4" />
)}
{note.replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
</AsyncButton>
</div>
</div>
);
}
@ -617,7 +616,7 @@ export function NoteCreator() {
{note.replyTo && (
<>
<h4>
<FormattedMessage defaultMessage="Reply To" id="8ED/4u" />
<FormattedMessage defaultMessage="Reply To" />
</h4>
<div className="max-h-64 overflow-y-auto">
<Note className="hover:bg-transparent" data={note.replyTo} options={replyToNoteOptions} />
@ -628,7 +627,7 @@ export function NoteCreator() {
{note.quote && (
<>
<h4>
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
<FormattedMessage defaultMessage="Quote Repost" />
</h4>
<div className="max-h-64 overflow-y-auto">
<Note className="hover:bg-transparent" data={note.quote} options={quoteNoteOptions} />
@ -638,13 +637,22 @@ export function NoteCreator() {
)}
{note.preview && getPreviewNote()}
{!note.preview && (
<>
<div onPaste={handlePaste} className={classNames("note-creator", { poll: Boolean(note.pollOptions) })}>
<div className="flex flex-col gap-4">
<div className="font-medium flex justify-between items-center">
<FormattedMessage defaultMessage="Compose a note" />
<AsyncIcon
iconName="x"
className="bg-gray rounded-full items-center justify-center flex p-1 cursor-pointer"
onClick={cancel}
/>
</div>
<div onPaste={handlePaste} className={classNames({ poll: Boolean(note.pollOptions) })}>
<Textarea
className="!border-none !resize-none !p-0 !rounded-none !text-sm"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
autoFocus
autoFocus={true}
onChange={c => onChange(c)}
value={note.note}
onFocus={() => note.update(v => (v.active = true))}
@ -656,12 +664,74 @@ export function NoteCreator() {
/>
{renderPollOptions()}
</div>
</>
</div>
)}
{(note.otherEvents?.length ?? 0) > 0 && !note.preview && (
<div className="flex gap-2 flex-wrap">
{note.otherEvents
?.map(a => ({
event: a,
tags: readNip94Tags(a.tags),
}))
.filter(a => a.tags.url)
.map(a => (
<div key={a.tags.url} className="relative">
<img
className="object-cover w-[80px] h-[80px] !mt-0 rounded-lg"
src={addExtensionToNip94Url(a.tags)}
/>
<Icon
name="x"
className="absolute -top-[0.25rem] -right-[0.25rem] bg-gray rounded-full cursor-pointer"
onClick={() =>
note.update(
n => (n.otherEvents = n.otherEvents?.filter(b => readNip94Tags(b.tags).url !== a.tags.url)),
)
}
/>
</div>
))}
</div>
)}
{uploader.progress.length > 0 && <FileUploadProgress progress={uploader.progress} />}
{noteCreatorFooter()}
{note.error && <span className="error">{note.error}</span>}
{note.advanced && noteCreatorAdvanced()}
<Flyout
show={note.filePicker !== "hidden"}
width={note.filePicker !== "compact" ? "70vw" : undefined}
onClose={() => note.update(v => (v.filePicker = "hidden"))}
side="right"
title={
<div className="text-xl font-medium">
<FormattedMessage defaultMessage="Attach Media" />
</div>
}
actions={
<>
<IconButton
className="max-lg:!hidden"
icon={{
name: "expand",
}}
onClick={() => note.update(n => (n.filePicker = n.filePicker === "wide" ? "compact" : "wide"))}
/>
</>
}>
<div className="overflow-y-auto h-[calc(100%-2rem)]">
{note.filePicker !== "hidden" && (
<MediaServerFileList
onPicked={files => {
note.update(n => {
n.otherEvents ??= [];
n.otherEvents?.push(...files);
n.filePicker = "hidden";
});
}}
cols={note.filePicker === "compact" ? 2 : 6}
/>
)}
</div>
</Flyout>
</>
);
}
@ -674,11 +744,7 @@ export function NoteCreator() {
if (!note.show) return null;
return (
<Modal
id="note-creator"
bodyClassName="modal-body flex flex-col gap-4"
className="note-creator-modal"
onClose={reset}>
<Modal id="note-creator" bodyClassName="modal-body gap-4" onClose={reset}>
{noteCreatorForm()}
</Modal>
);

View File

@ -15,10 +15,12 @@ export const NoteCreatorButton = ({
className,
alwaysShow,
showText,
withModal,
}: {
className?: string;
alwaysShow?: boolean;
showText?: boolean;
withModal: boolean;
}) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const location = useLocation();
@ -74,12 +76,12 @@ export const NoteCreatorButton = ({
<Icon name="plus" size={16} />
{showText && (
<span className="ml-2 hidden xl:inline">
<FormattedMessage defaultMessage="New Note" id="2mcwT8" />
<FormattedMessage defaultMessage="New Note" />
</span>
)}
</button>
)}
<NoteCreator key="global-note-creator" />
{withModal && <NoteCreator key="global-note-creator" />}
</>
);
};

View File

@ -13,10 +13,10 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
) : (
<div className="bb p flex items-center justify-between">
<div className="text-sm text-secondary">
<FormattedMessage defaultMessage="This note has been muted" id="qfmMQh" />
<FormattedMessage defaultMessage="This note has been muted" />
</div>
<button className="btn btn-sm btn-neutral" onClick={() => setShow(true)}>
<FormattedMessage defaultMessage="Show" id="K7AkdL" />
<FormattedMessage defaultMessage="Show" />
</button>
</div>
);

View File

@ -14,7 +14,7 @@ interface ShowMoreProps {
const LoadMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
return (
<button type="button" className={className} onClick={onClick}>
{text || <FormattedMessage defaultMessage="Load more" id="00LcfG" />}
{text || <FormattedMessage defaultMessage="Load more" />}
</button>
);
};

View File

@ -1,9 +1,8 @@
import "./LongFormText.css";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import { TaggedNostrEvent } from "@snort/system";
import classNames from "classnames";
import React, { CSSProperties, useCallback, useRef, useState } from "react";
import { CSSProperties, useCallback, useRef, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import Text from "@/Components/Text/Text";
@ -32,8 +31,6 @@ export function LongFormText(props: LongFormTextProps) {
const [reading, setReading] = useState(false);
const [showMore, setShowMore] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const related = useReactions("note:reactions", [NostrLink.fromEvent(props.ev)], undefined, false);
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), related);
function previewText() {
return (
@ -100,11 +97,7 @@ export function LongFormText(props: LongFormTextProps) {
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
{showMore ? <FormattedMessage defaultMessage="Show less" /> : <FormattedMessage defaultMessage="Show more" />}
</a>
);
@ -114,7 +107,7 @@ export function LongFormText(props: LongFormTextProps) {
function fullText() {
return (
<>
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
<NoteFooter ev={props.ev} />
<hr />
<div className="flex g8">
<div>
@ -129,12 +122,12 @@ export function LongFormText(props: LongFormTextProps) {
<div></div>
{!reading && (
<div className="pointer" onClick={() => readArticle()}>
<FormattedMessage defaultMessage="Listen to this article" id="nihgfo" />
<FormattedMessage defaultMessage="Listen to this article" />
</div>
)}
{reading && (
<div className="pointer" onClick={() => stopReading()}>
<FormattedMessage defaultMessage="Stop listening" id="U1aPPi" />
<FormattedMessage defaultMessage="Stop listening" />
</div>
)}
</div>
@ -143,7 +136,7 @@ export function LongFormText(props: LongFormTextProps) {
<Markdown content={content} tags={props.ev.tags} ref={ref} />
{shouldTruncate && !showMore && <ToggleShowMore />}
<hr />
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
<NoteFooter ev={props.ev} />
</>
);
}

View File

@ -26,10 +26,7 @@ export function NostrFileElement({ ev }: { ev: NostrEvent }) {
if (u && m) {
return (
<Reveal
message={
<FormattedMessage defaultMessage="Click to load content from {link}" id="lsNFM1" values={{ link: u }} />
}>
<Reveal message={<FormattedMessage defaultMessage="Click to load content from {link}" values={{ link: u }} />}>
<MediaElement
mime={m}
url={u}
@ -44,7 +41,7 @@ export function NostrFileElement({ ev }: { ev: NostrEvent }) {
} else {
return (
<b className="error">
<FormattedMessage defaultMessage="Unknown file header: {name}" id="PamNxw" values={{ name: ev.content }} />
<FormattedMessage defaultMessage="Unknown file header: {name}" values={{ name: ev.content }} />
</b>
);
}

View File

@ -1,6 +1,7 @@
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import classNames from "classnames";
import React, { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
@ -14,14 +15,12 @@ import { TranslationInfo } from "@/Components/Event/Note/TranslationInfo";
import { NoteTranslation } from "@/Components/Event/Note/types";
import Username from "@/Components/User/Username";
import useModeration from "@/Hooks/useModeration";
import { findTag } from "@/Utils";
import { chainKey } from "@/Utils/Thread/ChainKey";
import messages from "../../messages";
import Text from "../../Text/Text";
import { NoteProps } from "../EventComponent";
import { NoteProps, NotePropsOptions } from "../EventComponent";
import HiddenNote from "../HiddenNote";
import Poll from "../Poll";
import NoteAppHandler from "./NoteAppHandler";
import NoteFooter from "./NoteFooter/NoteFooter";
const defaultOptions = {
@ -40,10 +39,10 @@ export function Note(props: NoteProps) {
const { data: ev, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className ?? "");
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { ref, inView } = useInView({ triggerOnce: true });
const { ref: setSeenAtRef, inView: setSeenAtInView } = useInView({ rootMargin: "0px", threshold: 1 });
const [showTranslation, setShowTranslation] = useState(true);
const [translated, setTranslated] = useState<NoteTranslation>(translationCache.get(ev.id));
const [translated, setTranslated] = useState<NoteTranslation | null>(translationCache.get(ev.id));
const cachedSetTranslated = useCallback(
(translation: NoteTranslation) => {
translationCache.set(ev.id, translation);
@ -56,7 +55,9 @@ export function Note(props: NoteProps) {
let timeout: ReturnType<typeof setTimeout>;
if (setSeenAtInView) {
timeout = setTimeout(() => {
if (Relay instanceof WorkerRelayInterface) {
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
}
}, 1000);
}
return () => clearTimeout(timeout);
@ -99,7 +100,7 @@ export function Note(props: NoteProps) {
<div
className={classNames(baseClassName, {
active: highlight,
"hover:bg-nearly-bg-color cursor-pointer": !opt?.isRoot,
"hover:bg-nearly-bg-background cursor-pointer": !opt?.isRoot,
})}
onClick={e => goToEvent(e, ev)}
ref={ref}>
@ -110,10 +111,10 @@ export function Note(props: NoteProps) {
return !ignoreModeration && isEventMuted(ev) ? <HiddenNote>{noteElement}</HiddenNote> : noteElement;
}
function useGoToEvent(props, options) {
function useGoToEvent(props: NoteProps, options: NotePropsOptions) {
const navigate = useNavigate();
return useCallback(
(e, eTarget) => {
(e: React.MouseEvent, eTarget: TaggedNostrEvent) => {
if (options?.canClick === false) {
return;
}
@ -132,11 +133,20 @@ function useGoToEvent(props, options) {
}
e.stopPropagation();
// prevent navigation if selecting text
const cellText = document.getSelection();
if (cellText?.type === "Range") {
return;
}
// custom onclick handler
if (props.onClick) {
props.onClick(eTarget);
return;
}
// link to event
const link = NostrLink.fromEvent(eTarget);
if (e.metaKey) {
window.open(`/${link.encode(CONFIG.eventLinkPrefix)}`, "_blank");
@ -160,7 +170,7 @@ function Reaction({ ev }: { ev: TaggedNostrEvent }) {
<div className="text-gray-medium font-bold">
<Username pubkey={ev.pubkey} onLinkVisit={() => {}} />
<span> </span>
<FormattedMessage defaultMessage="liked" id="TvKqBp" />
<FormattedMessage defaultMessage="liked" />
</div>
<NoteQuote link={link} />
</div>
@ -168,23 +178,9 @@ function Reaction({ ev }: { ev: TaggedNostrEvent }) {
}
function handleNonTextNote(ev: TaggedNostrEvent) {
const alt = findTag(ev, "alt");
if (alt) {
return (
<div className="note-quote">
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else if (ev.kind === EventKind.Reaction) {
if (ev.kind === EventKind.Reaction) {
return <Reaction ev={ev} />;
} else {
return (
<>
<h4>
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.kind }} />
</h4>
<pre>{JSON.stringify(ev, undefined, " ")}</pre>
</>
);
return <NoteAppHandler ev={ev} />;
}
}

View File

@ -0,0 +1,63 @@
import { mapEventToProfile, NostrLink, TaggedNostrEvent } from "@snort/system";
import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import NostrIcon from "@/Components/Icons/Nostrich";
import KindName from "@/Components/kind-name";
import Avatar from "@/Components/User/Avatar";
import DisplayName from "@/Components/User/DisplayName";
import useAppHandler from "@/Hooks/useAppHandler";
export default function NoteAppHandler({ ev }: { ev: TaggedNostrEvent }) {
const handlers = useAppHandler(ev.kind);
const link = NostrLink.fromEvent(ev);
const profiles = handlers.apps
.filter(a => a.tags.find(b => b[0] === "web" && b[2] === "nevent"))
.map(a => ({ profile: mapEventToProfile(a), event: a }))
.filter(a => a.profile)
.slice(0, 5);
return (
<div className="card flex flex-col gap-3">
<small>
<FormattedMessage
defaultMessage="Sorry, we dont understand this event kind ({name}), please try one of the following apps instead!"
values={{
name: <KindName kind={ev.kind} />,
}}
/>
</small>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => {
window.open(`nostr:${link.encode()}`, "_blank");
}}>
<div className="flex items-center gap-2">
<NostrIcon width={40} />
<FormattedMessage defaultMessage="Native App" />
</div>
<Icon name="link" />
</div>
{profiles.map(a => (
<div
className="flex justify-between items-center cursor-pointer"
key={a.event.id}
onClick={() => {
const webHandler = a.event.tags.find(a => a[0] === "web" && a[2] === "nevent")?.[1];
if (webHandler) {
window.open(webHandler.replace("<bech32>", link.encode()), "_blank");
}
}}>
<div className="flex items-center gap-2">
<Avatar size={40} pubkey={a.event.pubkey} user={a.profile} />
<div>
<DisplayName pubkey={a.event.pubkey} user={a.profile} />
</div>
</div>
<Icon name="link" />
</div>
))}
</div>
);
}

View File

@ -148,7 +148,7 @@ export function NoteContextMenu({ ev, ...props }: NoteContextMenuProps) {
)}
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
<FormattedMessage defaultMessage="Broadcast Event" />
</MenuItem>
<MenuItem onClick={() => translate()}>
<Icon name="translate" />

View File

@ -1,6 +1,7 @@
import { barrierQueue } from "@snort/shared";
import { NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { Zapper, ZapTarget } from "@snort/wallet";
import React, { useEffect, useState } from "react";
import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
@ -13,7 +14,6 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import usePreferences from "@/Hooks/usePreferences";
import { getDisplayName } from "@/Utils";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { useWallet } from "@/Wallet";
@ -140,13 +140,7 @@ export const FooterZapButton = ({ ev, zaps, onClickZappers }: ZapIconProps) => {
<ZapsSummary zaps={zaps} onClick={onClickZappers ?? (() => {})} />
</div>
{showZapModal && (
<ZapModal
targets={getZapTarget()}
onClose={() => setShowZapModal(false)}
note={ev.id}
show={true}
allocatePool={true}
/>
<ZapModal targets={getZapTarget()} onClose={() => setShowZapModal(false)} show={true} allocatePool={true} />
)}
</>
)}

View File

@ -9,6 +9,7 @@ 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";
import useModeration from "@/Hooks/useModeration";
import usePreferences from "@/Hooks/usePreferences";
export interface NoteFooterProps {
@ -19,11 +20,14 @@ export interface NoteFooterProps {
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 { isMuted } = useModeration();
const related = useReactions("reactions", ids, undefined, false);
const { replies, reactions, zaps, reposts } = useEventReactions(link, related);
const related = useReactions("reactions", link);
const { replies, reactions, zaps, reposts } = useEventReactions(
link,
related.filter(a => !isMuted(a.pubkey)),
);
const { positive } = reactions;
const readonly = useLogin(s => s.readonly);

View File

@ -58,7 +58,7 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
</div>
<MenuItem onClick={repost} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
<FormattedMessage defaultMessage="Repost" />
</MenuItem>
<MenuItem
onClick={() =>
@ -69,7 +69,7 @@ export const RepostButton = ({ ev, reposts }: { ev: TaggedNostrEvent; reposts: T
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
<FormattedMessage defaultMessage="Quote Repost" />
</MenuItem>
</Menu>
);

View File

@ -1,21 +1,15 @@
import "../EventComponent.css";
import ProfileImage from "@/Components/User/ProfileImage";
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
interface NoteGhostProps {
className?: string;
children: React.ReactNode;
link: string;
}
export default function NoteGhost(props: NoteGhostProps) {
const className = `note card ${props.className ?? ""}`;
return (
<div className={className}>
<div className="header">
<ProfileImage pubkey="" />
</div>
<div className="body">{props.children}</div>
<div className="footer"></div>
<div className={classNames("p bb", props.className)}>
<FormattedMessage defaultMessage="Loading note: {id}" values={{ id: props.link }} />
</div>
);
}

View File

@ -1,8 +1,13 @@
import { NostrLink } from "@snort/system";
import { dedupe, sanitizeRelayUrl } from "@snort/shared";
import { NostrLink, NostrPrefix } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import Copy from "@/Components/Copy/Copy";
import Note from "@/Components/Event/EventComponent";
import PageSpinner from "@/Components/PageSpinner";
import Spinner from "@/Components/Icons/Spinner";
const options = {
showFooter: false,
@ -10,11 +15,52 @@ const options = {
};
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
const ev = useEventFeed(link);
const [tryLink, setLink] = useState<NostrLink>(link);
const [tryRelay, setTryRelay] = useState("");
const { formatMessage } = useIntl();
const ev = useEventFeed(tryLink);
if (!ev)
return (
<div className="note-quote flex items-center justify-center h-[110px]">
<PageSpinner />
<div className="note-quote flex flex-col gap-2">
<Spinner />
<div>
<FormattedMessage
defaultMessage="Looking for: {noteId}"
values={{
noteId: <Copy text={tryLink.encode()} />,
}}
/>
</div>
<div className="flex gap-2">
<input
type="text"
value={tryRelay}
onChange={e => setTryRelay(e.target.value)}
placeholder={formatMessage({ defaultMessage: "Try another relay" })}
/>
<AsyncButton
onClick={() => {
const u = sanitizeRelayUrl(tryRelay);
if (u) {
const relays = tryLink.relays ?? [];
relays.push(u);
setLink(
new NostrLink(
tryLink.type !== NostrPrefix.Address ? NostrPrefix.Event : NostrPrefix.Address,
tryLink.id,
tryLink.kind,
tryLink.author,
dedupe(relays),
tryLink.marker,
),
);
setTryRelay("");
}
}}>
<FormattedMessage defaultMessage="Add" />
</AsyncButton>
</div>
</div>
);
return <Note data={ev} className="note-quote" depth={(depth ?? 0) + 1} options={options} />;

View File

@ -27,11 +27,7 @@ export const NoteText = memo(function InnerContent(
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
{showMore ? <FormattedMessage defaultMessage="Show less" /> : <FormattedMessage defaultMessage="Show more" />}
</a>
);
@ -80,10 +76,10 @@ export const NoteText = memo(function InnerContent(
/>
</>
)}
. <FormattedMessage defaultMessage="Click here to load anyway" id="IoQq+a" />.{" "}
. <FormattedMessage defaultMessage="Click here to load anyway" />.{" "}
<Link to="/settings/moderation">
<i>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
<FormattedMessage defaultMessage="Settings" />
</i>
</Link>
</>

View File

@ -4,19 +4,20 @@ import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
from: number;
fallback?: string;
className?: string;
}
const secondsInAMinute = 60;
const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback, className }) => {
const calcTime = useCallback((fromTime: number) => {
const currentTime = new Date();
const timeDifference = Math.floor((currentTime.getTime() - fromTime) / 1000);
if (timeDifference < secondsInAMinute) {
return <FormattedMessage defaultMessage="now" id="kaaf1E" />;
return <FormattedMessage defaultMessage="now" />;
} else if (timeDifference < secondsInAnHour) {
return `${Math.floor(timeDifference / secondsInAMinute)}m`;
} else if (timeDifference < secondsInADay) {
@ -52,7 +53,7 @@ const NoteTime: React.FC<NoteTimeProps> = ({ from, fallback }) => {
const isoDate = useMemo(() => new Date(from).toISOString(), [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
<time dateTime={isoDate} title={absoluteTime} className={className}>
{time || fallback}
</time>
);

View File

@ -1,16 +1,14 @@
import "./ReactionsModal.css";
import { NostrLink, socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { FormattedMessage, MessageDescriptor, useIntl } from "react-intl";
import CloseButton from "@/Components/Button/CloseButton";
import Icon from "@/Components/Icons/Icon";
import Modal from "@/Components/Modal/Modal";
import TabSelectors, { Tab } from "@/Components/TabSelectors/TabSelectors";
import ProfileImage from "@/Components/User/ProfileImage";
import { formatShort } from "@/Utils/Number";
import ZapAmount from "@/Components/zap-amount";
import useWoT from "@/Hooks/useWoT";
import messages from "../../messages";
@ -25,14 +23,11 @@ const ReactionsModal = ({ onClose, event, initialTab = 0 }: ReactionsModalProps)
const link = NostrLink.fromEvent(event);
const related = useReactions("note:reactions", [link], undefined, false);
const related = useReactions("reactions", link, undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive, negative } = reactions;
const sortEvents = (events: Array<TaggedNostrEvent>) =>
events.sort(
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
);
const { sortEvents } = useWoT();
const likes = useMemo(() => sortEvents([...positive]), [positive]);
const dislikes = useMemo(() => sortEvents([...negative]), [negative]);
@ -60,49 +55,43 @@ const ReactionsModal = ({ onClose, event, initialTab = 0 }: ReactionsModalProps)
const [tab, setTab] = useState(tabs[initialTab]);
const renderReactionItem = (ev: TaggedNostrEvent, icon: string, iconClass?: string, size?: number) => (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Fragment key={ev.id}>
<div className="mx-auto">
<Icon name={icon} size={size} className={iconClass} />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
</Fragment>
);
return (
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
<Modal id="reactions" onClose={onClose}>
<div className="text-lg font-semibold mb-2">
<FormattedMessage defaultMessage="Reactions ({n})" values={{ n: total }} />
</div>
<TabSelectors tabs={tabs} tab={tab} setTab={setTab} />
<div className="reactions-body" key={tab.value}>
<div className="h-[30vh] overflow-y-auto">
<div className="grid grid-cols-[100px_auto] gap-y-2 items-center py-2" key={tab.value}>
{tab.value === 0 && likes.map(ev => renderReactionItem(ev, "heart-solid", "text-heart"))}
{tab.value === 1 &&
zaps.map(
z =>
z.sender && (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<Icon name="zap-solid" size={20} className="text-zap" />
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<Fragment key={z.id}>
<ZapAmount n={z.amount} />
<ProfileImage
showProfileCard={true}
pubkey={z.anonZap ? "" : z.sender}
subHeader={<div title={z.content}>{z.content}</div>}
link={z.anonZap ? "" : undefined}
overrideUsername={
z.anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined
}
overrideUsername={z.anonZap ? formatMessage({ defaultMessage: "Anonymous" }) : undefined}
/>
</div>
</Fragment>
),
)}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", "text-repost", 16))}
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
</div>
</div>
</Modal>
);
};

View File

@ -12,6 +12,8 @@ import Icon from "@/Components/Icons/Icon";
import useModeration from "@/Hooks/useModeration";
import { eventLink, getDisplayName, hexToBech32 } from "@/Utils";
import NoteFooter from "./Note/NoteFooter/NoteFooter";
export interface NoteReactionProps {
data: TaggedNostrEvent;
root?: TaggedNostrEvent;
@ -85,13 +87,13 @@ export default function NoteReaction(props: NoteReactionProps) {
<Icon name="repeat" size={18} />
<FormattedMessage
defaultMessage="{name} reposted"
id="+xliwN"
values={{
name: getDisplayName(profile, ev.pubkey),
}}
/>
</div>
{root ? <Note data={root} options={opt} depth={props.depth} /> : null}
<NoteFooter ev={ev} />
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>

View File

@ -135,9 +135,9 @@ export default function Poll(props: PollProps) {
values={{
type:
tallyBy === "zaps" ? (
<FormattedMessage defaultMessage="zap" id="5BVs2e" />
<FormattedMessage defaultMessage="zap" />
) : (
<FormattedMessage defaultMessage="user" id="sUNhQE" />
<FormattedMessage defaultMessage="user" />
),
}}
/>

View File

@ -42,7 +42,7 @@
top: 48px;
border-left: 1px solid var(--border-color);
height: 100%;
z-index: 1;
z-index: -1;
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:before {
@ -52,7 +52,7 @@
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
z-index: 1;
z-index: -1;
}
.subthread-container.subthread-last .line-container:before {
@ -62,7 +62,7 @@
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
z-index: 1;
z-index: -1;
}
.divider {

View File

@ -57,8 +57,6 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
waitUntilInView={false}
/>
);
} else {
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
}
}
@ -75,16 +73,18 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
function renderCurrent() {
if (thread.current) {
const note = thread.data.find(n => n.id === thread.current);
if (note) {
return (
note && (
<Note
data={note}
options={{ showReactionsLink: true, showMediaSpotlight: true }}
threadChains={thread.chains}
onClick={navigateThread}
/>
)
);
} else {
return <NoteGhost link={thread.current} />;
}
}
}
@ -100,7 +100,6 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
const parentText = formatMessage({
defaultMessage: "Parent",
id: "ADmfQT",
description: "Link to parent note in thread",
});
@ -134,10 +133,15 @@ export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean
{thread.root && renderRoot(thread.root)}
{thread.root && renderChain(chainKey(thread.root))}
{!thread.root && renderCurrent()}
{!thread.root && !thread.current && (
<NoteGhost>
<FormattedMessage defaultMessage="Looking up thread..." id="JA+tz3" />
</NoteGhost>
{thread.mutedData.length > 0 && (
<div className="p br b mx-2 my-3 bg-gray-ultradark text-gray-light font-medium cursor-pointer">
<FormattedMessage
defaultMessage="{n} notes have been muted"
values={{
n: thread.mutedData.length,
}}
/>
</div>
)}
</div>
</>

View File

@ -1,12 +1,12 @@
import "./ZapButton.css";
import { HexKey } from "@snort/system";
import { HexKey, NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { ZapTarget } from "@snort/wallet";
import { useState } from "react";
import Icon from "@/Components/Icons/Icon";
import ZapModal from "@/Components/ZapModal/ZapModal";
import { ZapTarget } from "@/Utils/Zapper";
const ZapButton = ({
pubkey,
@ -17,7 +17,7 @@ const ZapButton = ({
pubkey: HexKey;
lnurl?: string;
children?: React.ReactNode;
event?: string;
event?: NostrLink;
}) => {
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
@ -37,12 +37,11 @@ const ZapButton = ({
value: service,
weight: 1,
name: profile?.display_name || profile?.name,
zap: { pubkey: pubkey },
zap: { pubkey: pubkey, event },
} as ZapTarget,
]}
show={zap}
onClose={() => setZap(false)}
note={event}
/>
</>
);

View File

@ -1,6 +1,7 @@
import "./ZapGoal.css";
import { NostrEvent, NostrLink } from "@snort/system";
import { Zapper } from "@snort/wallet";
import { useState } from "react";
import { FormattedNumber } from "react-intl";
@ -10,7 +11,6 @@ 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";
export function ZapGoal({ ev }: { ev: NostrEvent }) {
const [zap, setZap] = useState(false);

View File

@ -26,13 +26,9 @@ export const ZapsSummary = ({ zaps, onClick }: ZapsSummaryProps) => {
};
return (
<div className="zaps-summary" onClick={myOnClick}>
<div className={`top-zap`}>
<div className="summary">
<div className="flex items-center cursor-pointer" onClick={myOnClick}>
<AvatarGroup ids={sortedZappers} onClick={() => {}} />
{zaps.length > 3 && <div className="hidden md:flex -ml-2">+{zaps.length - 3}</div>}
</div>
</div>
{zaps.length > 3 && <div className="hidden md:inline-flex">+{zaps.length - 3}</div>}
</div>
);
};

View File

@ -13,7 +13,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="For you" id="xEjBS7" />
<FormattedMessage defaultMessage="For you" />
</>
),
},
@ -24,7 +24,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Following" id="cPIKU2" />
<FormattedMessage defaultMessage="Following" />
</>
),
},
@ -35,7 +35,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="fire" />
<FormattedMessage defaultMessage="Trending Notes" id="Ix8l+B" />
<FormattedMessage defaultMessage="Trending Notes" />
</>
),
},
@ -46,7 +46,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="message-chat-circle" />
<FormattedMessage defaultMessage="Conversations" id="1udzha" />
<FormattedMessage defaultMessage="Conversations" />
</>
),
},
@ -57,7 +57,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Followed by friends" id="voxBKC" />
<FormattedMessage defaultMessage="Followed by friends" />
</>
),
},
@ -68,7 +68,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Suggested Follows" id="C8HhVE" />
<FormattedMessage defaultMessage="Suggested Follows" />
</>
),
},
@ -79,18 +79,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="hash" />
<FormattedMessage defaultMessage="Trending Hashtags" id="XXm7jJ" />
</>
),
},
{
tab: "global",
path: `${base}/global`,
show: true,
element: (
<>
<Icon name="globe" />
<FormattedMessage defaultMessage="Global" id="EWyQH5" />
<FormattedMessage defaultMessage="Trending Hashtags" />
</>
),
},
@ -101,7 +90,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="hash" />
<FormattedMessage defaultMessage="Topics" id="kc79d3" />
<FormattedMessage defaultMessage="Topics" />
</>
),
},

View File

@ -1,6 +1,7 @@
import "./Timeline.css";
import { socialGraphInstance, TaggedNostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { TaggedNostrEvent } from "@snort/system";
import { useCallback, useMemo, useState } from "react";
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
@ -8,6 +9,7 @@ import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineFeed";
import useHistoryState from "@/Hooks/useHistoryState";
import useLogin from "@/Hooks/useLogin";
import useWoT from "@/Hooks/useWoT";
import { dedupeByPubkey } from "@/Utils";
export interface TimelineProps {
@ -28,7 +30,7 @@ export interface TimelineProps {
*/
const Timeline = (props: TimelineProps) => {
const login = useLogin();
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
const [openedAt] = useHistoryState(unixNow(), "openedAt");
const feedOptions = useMemo(
() => ({
method: props.method,
@ -40,6 +42,7 @@ const Timeline = (props: TimelineProps) => {
const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions);
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const wot = useWoT();
const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => {
@ -47,7 +50,7 @@ const Timeline = (props: TimelineProps) => {
if (props.followDistance === undefined) {
return true;
}
const followDistance = socialGraphInstance.getFollowDistance(a.pubkey);
const followDistance = wot.followDistance(a.pubkey);
return followDistance === props.followDistance;
};
return nts

View File

@ -0,0 +1,48 @@
import { NostrEvent, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { ReactNode, useMemo } from "react";
import { WindowChunk } from "@/Hooks/useTimelineChunks";
import { DisplayAs } from "./DisplayAsSelector";
import { TimelineRenderer } from "./TimelineRenderer";
export interface TimelineChunkProps {
id: string;
chunk: WindowChunk;
builder: (rb: RequestBuilder) => void;
noteFilter?: (ev: NostrEvent) => boolean;
noteRenderer?: (ev: NostrEvent) => ReactNode;
noteOnClick?: (ev: NostrEvent) => void;
displayAs?: DisplayAs;
}
/**
* Simple chunk of a timeline using absoliute time ranges
*/
export default function TimelineChunk(props: TimelineChunkProps) {
const sub = useMemo(() => {
const rb = new RequestBuilder(`timeline-chunk:${props.id}:${props.chunk.since}-${props.chunk.until}`);
props.builder(rb);
for (const f of rb.filterBuilders) {
f.since(props.chunk.since).until(props.chunk.until);
}
return rb;
}, [props.id, props.chunk.until, props.builder]);
const feed = useRequestBuilder(sub);
return (
<TimelineRenderer
frags={{
events: feed.filter(a => props.noteFilter?.(a) ?? true),
refTime: props.chunk.until,
}}
noteOnClick={props.noteOnClick}
noteRenderer={props.noteRenderer}
displayAs={props.displayAs}
latest={[]}
showLatest={() => {}}
/>
);
}

View File

@ -1,16 +1,18 @@
import "./Timeline.css";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { ReactNode, useCallback, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { unixNow } from "@snort/shared";
import { EventKind, NostrEvent, RequestBuilder } from "@snort/system";
import { ReactNode, useCallback, useState } from "react";
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
import useFollowsControls from "@/Hooks/useFollowControls";
import useHistoryState from "@/Hooks/useHistoryState";
import useLogin from "@/Hooks/useLogin";
import { dedupeByPubkey } from "@/Utils";
import useTimelineChunks from "@/Hooks/useTimelineChunks";
import { Hour } from "@/Utils/Const";
import { AutoLoadMore } from "../Event/LoadMore";
import TimelineChunk from "./TimelineChunk";
export interface TimelineFollowsProps {
postsOnly: boolean;
@ -19,11 +21,10 @@ export interface TimelineFollowsProps {
noteRenderer?: (ev: NostrEvent) => ReactNode;
noteOnClick?: (ev: NostrEvent) => void;
displayAs?: DisplayAs;
showDisplayAsSelector?: boolean;
}
/**
* A list of notes by "subject"
* A list of notes by your follows
*/
const TimelineFollows = (props: TimelineFollowsProps) => {
const login = useLogin(s => ({
@ -33,81 +34,44 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
}));
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const [openedAt] = useHistoryState(Math.floor(Date.now() / 1000), "openedAt");
const [openedAt] = useHistoryState(unixNow(), "openedAt");
const { isFollowing, followList } = useFollowsControls();
const subject = useMemo(
() =>
({
type: "pubkey",
items: followList,
discriminator: login.publicKey?.slice(0, 12),
extra: rb => {
if (login.tags.length > 0) {
rb.withFilter().kinds([EventKind.TextNote, EventKind.Repost]).tags(login.tags);
}
const { chunks, showMore } = useTimelineChunks({
now: openedAt,
firstChunkSize: Hour * 2,
});
const builder = useCallback(
(rb: RequestBuilder) => {
rb.withFilter().authors(followList).kinds([EventKind.TextNote, EventKind.Repost, EventKind.Polls]);
},
}) as TimelineSubject,
[login.publicKey, followList, login.tags],
);
const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
// TODO allow reposts:
const postsOnly = useCallback(
(a: NostrEvent) => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true),
[props.postsOnly],
[followList],
);
const filterPosts = useCallback(
(nts: Array<TaggedNostrEvent>) => {
const a = nts.filter(a => a.kind !== EventKind.LiveEvent);
return a
?.filter(postsOnly)
.filter(a => props.noteFilter?.(a) ?? true)
.filter(a => isFollowing(a.pubkey) || a.tags.filter(a => a[0] === "t").length < 5);
},
[postsOnly, props.noteFilter, isFollowing],
const filterEvents = useCallback(
(a: NostrEvent) =>
(props.noteFilter?.(a) ?? true) &&
(props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true) &&
(isFollowing(a.pubkey) || a.tags.filter(a => a[0] === "t").length < 5),
[props.noteFilter, props.postsOnly, followList],
);
const mainFeed = useMemo(() => {
return filterPosts(feed.main ?? []);
}, [feed.main, filterPosts]);
const latestFeed = useMemo(() => {
return filterPosts(feed.latest ?? []);
}, [feed.latest]);
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
function onShowLatest(scrollToTop = false) {
feed.showLatest();
if (scrollToTop) {
window.scrollTo(0, 0);
}
}
return (
<>
<DisplayAsSelector
show={props.showDisplayAsSelector}
activeSelection={displayAs}
onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)}
/>
<TimelineRenderer
frags={[{ events: mainFeed, refTime: 0 }]}
latest={latestAuthors}
showLatest={t => onShowLatest(t)}
<DisplayAsSelector activeSelection={displayAs} onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)} />
{chunks.map(c => (
<TimelineChunk
key={c.until}
id="follows"
chunk={c}
builder={builder}
noteFilter={filterEvents}
noteOnClick={props.noteOnClick}
noteRenderer={props.noteRenderer}
noteContext={e => {
if (typeof e.context === "string") {
return <Link to={`/t/${e.context}`}>{`#${e.context}`}</Link>;
}
}}
displayAs={displayAs}
loadMore={() => feed.loadMore()}
/>
))}
<AutoLoadMore onClick={() => showMore()} />
</>
);
};

View File

@ -14,7 +14,7 @@ import ProfileImage from "@/Components/User/ProfileImage";
import getEventMedia from "@/Utils/getEventMedia";
export interface TimelineRendererProps {
frags: Array<TimelineFragment>;
frags: Array<TimelineFragment> | TimelineFragment;
/**
* List of pubkeys who have posted recently
*/
@ -29,10 +29,10 @@ export interface TimelineRendererProps {
}
// filter frags[0].events that have media
function Grid({ frags }: { frags: Array<TimelineFragment> }) {
function Grid({ frags }: { frags: Array<TimelineFragment> | TimelineFragment }) {
const [modalEventIndex, setModalEventIndex] = useState<number | undefined>(undefined);
const allEvents = useMemo(() => {
return frags.flatMap(frag => frag.events);
return (Array.isArray(frags) ? frags : [frags]).flatMap(frag => frag.events);
}, [frags]);
const mediaEvents = useMemo(() => {
return allEvents.filter(event => getEventMedia(event).length > 0);
@ -99,7 +99,8 @@ export function TimelineRenderer(props: TimelineRendererProps) {
}, [inView, props.latest]);
const renderNotes = () => {
return props.frags.map((frag, index) => (
const frags = Array.isArray(props.frags) ? props.frags : [props.frags];
return frags.map((frag, index) => (
<ErrorBoundary key={frag.events[0]?.id + index}>
<TimelineFragment
frag={frag}
@ -113,7 +114,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
};
return (
<div ref={containerRef} className="pb-[10vh]">
<div ref={containerRef}>
{props.latest.length > 0 && (
<>
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>

View File

@ -27,5 +27,14 @@ export default function UsersFeed({ keyword, sortPopular = true }: { keyword: st
if (!usersFeed) return <PageSpinner />;
return <FollowListBase pubkeys={usersFeed} showAbout={true} />;
return (
<FollowListBase
pubkeys={usersFeed}
profilePreviewProps={{
options: {
about: true,
},
}}
/>
);
}

View File

@ -9,8 +9,8 @@
<symbol id="arrowUp" viewBox="0 0 12 12" fill="none">
<path d="M5.99992 10.6673V1.33398M5.99992 1.33398L1.33325 6.00065M5.99992 1.33398L10.6666 6.00065" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="attachment" viewBox="0 0 21 22" fill="none">
<path d="M19.1525 9.89945L10.1369 18.9151C8.08662 20.9653 4.7625 20.9653 2.71225 18.9151C0.661997 16.8648 0.661998 13.5407 2.71225 11.4904L11.7279 2.47483C13.0947 1.108 15.3108 1.108 16.6776 2.47483C18.0444 3.84167 18.0444 6.05775 16.6776 7.42458L8.01555 16.0866C7.33213 16.7701 6.22409 16.7701 5.54068 16.0866C4.85726 15.4032 4.85726 14.2952 5.54068 13.6118L13.1421 6.01037" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<symbol id="attachment" viewBox="0 0 24 25" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1667 3.5C8.69391 3.5 7.5 4.69391 7.5 6.16667V17C7.5 19.4853 9.51472 21.5 12 21.5C14.4853 21.5 16.5 19.4853 16.5 17V5.75581C16.5 5.20353 16.9477 4.75581 17.5 4.75581C18.0523 4.75581 18.5 5.20353 18.5 5.75581V17C18.5 20.5899 15.5899 23.5 12 23.5C8.41015 23.5 5.5 20.5899 5.5 17V6.16667C5.5 3.58934 7.58934 1.5 10.1667 1.5C12.744 1.5 14.8333 3.58934 14.8333 6.16667V16.9457C14.8333 18.5105 13.5648 19.7791 12 19.7791C10.4352 19.7791 9.16667 18.5105 9.16667 16.9457V7.15116C9.16667 6.59888 9.61438 6.15116 10.1667 6.15116C10.719 6.15116 11.1667 6.59888 11.1667 7.15116V16.9457C11.1667 17.406 11.5398 17.7791 12 17.7791C12.4602 17.7791 12.8333 17.406 12.8333 16.9457V6.16667C12.8333 4.69391 11.6394 3.5 10.1667 3.5Z" fill="currentColor"/>
</symbol>
<symbol id="badge" viewBox="0 0 16 15" fill="none">
<path d="M6.00004 7.50065L7.33337 8.83398L10.3334 5.83398M11.9342 2.83299C12.0714 3.16501 12.3349 3.42892 12.6667 3.5667L13.8302 4.04864C14.1622 4.18617 14.426 4.44998 14.5636 4.78202C14.7011 5.11407 14.7011 5.48715 14.5636 5.81919L14.082 6.98185C13.9444 7.31404 13.9442 7.6875 14.0824 8.01953L14.5632 9.18185C14.6313 9.34631 14.6664 9.52259 14.6665 9.70062C14.6665 9.87865 14.6315 10.0549 14.5633 10.2194C14.4952 10.3839 14.3953 10.5333 14.2694 10.6592C14.1435 10.7851 13.9941 10.8849 13.8296 10.953L12.6669 11.4346C12.3349 11.5718 12.071 11.8354 11.9333 12.1672L11.4513 13.3307C11.3138 13.6627 11.05 13.9265 10.718 14.0641C10.3859 14.2016 10.0129 14.2016 9.68085 14.0641L8.51823 13.5825C8.18619 13.4453 7.81326 13.4455 7.48143 13.5832L6.31797 14.0645C5.98612 14.2017 5.61338 14.2016 5.28162 14.0642C4.94986 13.9267 4.68621 13.6632 4.54858 13.3316L4.06652 12.1677C3.92924 11.8357 3.66574 11.5718 3.33394 11.434L2.17048 10.9521C1.8386 10.8146 1.57488 10.5509 1.4373 10.2191C1.29971 9.88724 1.29953 9.51434 1.43678 9.18235L1.91835 8.01968C2.05554 7.68763 2.05526 7.31469 1.91757 6.98284L1.43669 5.81851C1.36851 5.65405 1.3334 5.47777 1.33337 5.29974C1.33335 5.12171 1.3684 4.94542 1.43652 4.78094C1.50465 4.61646 1.60452 4.46702 1.73042 4.34115C1.85632 4.21529 2.00579 4.11546 2.17028 4.04739L3.33291 3.5658C3.66462 3.42863 3.92836 3.16545 4.06624 2.83402L4.54816 1.67052C4.68569 1.33848 4.94949 1.07467 5.28152 0.937137C5.61355 0.7996 5.98662 0.7996 6.31865 0.937137L7.48127 1.41873C7.81331 1.55593 8.18624 1.55565 8.51808 1.41795L9.68202 0.937884C10.014 0.800424 10.387 0.800452 10.719 0.937962C11.0509 1.07547 11.3147 1.3392 11.4522 1.67116L11.9343 2.835L11.9342 2.83299Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
@ -402,18 +402,15 @@
<path d="M7.39516 18.3711L7.97961 19.6856C8.15335 20.0768 8.43689 20.4093 8.79583 20.6426C9.15478 20.8759 9.57372 21.0001 10.0018 21C10.4299 21.0001 10.8489 20.8759 11.2078 20.6426C11.5668 20.4093 11.8503 20.0768 12.0241 19.6856L12.6085 18.3711C12.8165 17.9047 13.1665 17.5159 13.6085 17.26C14.0533 17.0034 14.5678 16.8941 15.0785 16.9478L16.5085 17.1C16.9342 17.145 17.3638 17.0656 17.7452 16.8713C18.1266 16.6771 18.4435 16.3763 18.6574 16.0056C18.8716 15.635 18.9736 15.2103 18.9511 14.7829C18.9286 14.3555 18.7826 13.9438 18.5307 13.5978L17.6841 12.4344C17.3826 12.0171 17.2215 11.5148 17.2241 11C17.224 10.4866 17.3866 9.98635 17.6885 9.57111L18.5352 8.40778C18.787 8.06175 18.9331 7.65007 18.9556 7.22267C18.978 6.79528 18.876 6.37054 18.6618 6C18.4479 5.62923 18.1311 5.32849 17.7496 5.13423C17.3682 4.93997 16.9386 4.86053 16.5129 4.90556L15.0829 5.05778C14.5723 5.11141 14.0577 5.00212 13.6129 4.74556C13.1701 4.48825 12.82 4.09736 12.6129 3.62889L12.0241 2.31444C11.8503 1.92317 11.5668 1.59072 11.2078 1.3574C10.8489 1.12408 10.4299 0.99993 10.0018 1C9.57372 0.99993 9.15478 1.12408 8.79583 1.3574C8.43689 1.59072 8.15335 1.92317 7.97961 2.31444L7.39516 3.62889C7.18809 4.09736 6.83804 4.48825 6.39516 4.74556C5.95038 5.00212 5.43583 5.11141 4.92516 5.05778L3.49072 4.90556C3.06505 4.86053 2.63546 4.93997 2.25403 5.13423C1.87261 5.32849 1.55574 5.62923 1.34183 6C1.12765 6.37054 1.02561 6.79528 1.0481 7.22267C1.07058 7.65007 1.21662 8.06175 1.4685 8.40778L2.31516 9.57111C2.61711 9.98635 2.7797 10.4866 2.77961 11C2.7797 11.5134 2.61711 12.0137 2.31516 12.4289L1.4685 13.5922C1.21662 13.9382 1.07058 14.3499 1.0481 14.7773C1.02561 15.2047 1.12765 15.6295 1.34183 16C1.55595 16.3706 1.87286 16.6712 2.25423 16.8654C2.6356 17.0596 3.06508 17.1392 3.49072 17.0944L4.92072 16.9422C5.43139 16.8886 5.94594 16.9979 6.39072 17.2544C6.83525 17.511 7.18693 17.902 7.39516 18.3711Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 14C11.6569 14 13 12.6569 13 11C13 9.34315 11.6569 8 10 8C8.34319 8 7.00004 9.34315 7.00004 11C7.00004 12.6569 8.34319 14 10 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="home-solid" viewBox="0 0 18 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.43553 0.113589C9.15028 0.0363557 8.84962 0.0363557 8.56438 0.113589C8.23324 0.203247 7.95445 0.422125 7.73194 0.596817L7.66975 0.645498L1.95307 5.09173C1.63921 5.3353 1.3627 5.54988 1.15665 5.82847C0.975827 6.07295 0.841122 6.34837 0.759154 6.64121C0.665754 6.97489 0.666132 7.3249 0.666561 7.72218L0.666619 13.8654C0.666604 14.3047 0.666592 14.6837 0.692095 14.9958C0.719012 15.3253 0.778442 15.6529 0.939104 15.9683C1.17879 16.4387 1.56124 16.8211 2.03164 17.0608C2.34696 17.2215 2.67464 17.2809 3.0041 17.3078C3.31624 17.3333 3.6952 17.3333 4.13448 17.3333H13.8654C14.3047 17.3333 14.6837 17.3333 14.9958 17.3078C15.3253 17.2809 15.6529 17.2215 15.9683 17.0608C16.4387 16.8211 16.8211 16.4387 17.0608 15.9683C17.2215 15.6529 17.2809 15.3253 17.3078 14.9958C17.3333 14.6837 17.3333 14.3047 17.3333 13.8654L17.3333 7.72219C17.3338 7.3249 17.3342 6.97489 17.2408 6.64121C17.1588 6.34837 17.0241 6.07295 16.8433 5.82847C16.6372 5.54988 16.3607 5.33529 16.0468 5.09172L10.3302 0.645498L10.268 0.59682C10.0455 0.422128 9.76667 0.203248 9.43553 0.113589ZM6.57867 10.4589C6.46395 10.0132 6.00963 9.74487 5.56392 9.85959C5.11821 9.97431 4.84989 10.4286 4.9646 10.8743C5.4271 12.6713 7.05731 14 8.99995 14C10.9426 14 12.5728 12.6713 13.0353 10.8743C13.15 10.4286 12.8817 9.97431 12.436 9.85959C11.9903 9.74487 11.536 10.0132 11.4212 10.4589C11.1437 11.5374 10.1637 12.3333 8.99995 12.3333C7.8362 12.3333 6.85624 11.5374 6.57867 10.4589Z" fill="currentColor"/>
</symbol>
<symbol id="home-outline" viewBox="0 0 18 19" fill="none">
<path d="M5.77168 11.6668C6.14172 13.1045 7.4468 14.1668 9 14.1668C10.5532 14.1668 11.8583 13.1045 12.2283 11.6668M8.18141 2.30345L2.52949 6.69939C2.15168 6.99324 1.96278 7.14017 1.82669 7.32417C1.70614 7.48716 1.61633 7.67077 1.56169 7.866C1.5 8.08639 1.5 8.3257 1.5 8.80433V14.8334C1.5 15.7669 1.5 16.2336 1.68166 16.5901C1.84144 16.9037 2.09641 17.1587 2.41002 17.3185C2.76654 17.5001 3.23325 17.5001 4.16667 17.5001H13.8333C14.7668 17.5001 15.2335 17.5001 15.59 17.3185C15.9036 17.1587 16.1586 16.9037 16.3183 16.5901C16.5 16.2336 16.5 15.7669 16.5 14.8334V8.80433C16.5 8.3257 16.5 8.08639 16.4383 7.866C16.3837 7.67077 16.2939 7.48716 16.1733 7.32417C16.0372 7.14017 15.8483 6.99324 15.4705 6.69939L9.81859 2.30345C9.52582 2.07574 9.37943 1.96189 9.21779 1.91812C9.07516 1.87951 8.92484 1.87951 8.78221 1.91812C8.62057 1.96189 8.47418 2.07574 8.18141 2.30345Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="sign-in" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 3C6.44772 3 6 3.44772 6 4C6 4.55228 6.44772 5 7 5H18C18.5523 5 19 5.44772 19 6V18C19 18.5523 18.5523 19 18 19H7C6.44772 19 6 19.4477 6 20C6 20.5523 6.44772 21 7 21H18C19.6569 21 21 19.6569 21 18V6C21 4.34315 19.6569 3 18 3H7ZM12.7071 7.29289C12.3166 6.90237 11.6834 6.90237 11.2929 7.29289C10.9024 7.68342 10.9024 8.31658 11.2929 8.70711L13.5858 11H4C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H13.5858L11.2929 15.2929C10.9024 15.6834 10.9024 16.3166 11.2929 16.7071C11.6834 17.0976 12.3166 17.0976 12.7071 16.7071L16.7071 12.7071C17.0976 12.3166 17.0976 11.6834 16.7071 11.2929L12.7071 7.29289Z" fill="currentColor"/>
</symbol>
<symbol id="deck-solid" viewBox="0 0 20 20" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.3011 1.66602H14.8653C15.3046 1.666 15.6836 1.66599 15.9957 1.69149C16.3251 1.71841 16.6528 1.77784 16.9681 1.9385C17.4386 2.17818 17.821 2.56064 18.0607 3.03104C18.2213 3.34636 18.2808 3.67404 18.3077 4.00349C18.3332 4.31564 18.3332 4.69462 18.3332 5.13392V14.8648C18.3332 15.3041 18.3332 15.6831 18.3077 15.9952C18.2808 16.3247 18.2213 16.6523 18.0607 16.9677C17.821 17.4381 17.4386 17.8205 16.9681 18.0602C16.6528 18.2209 16.3251 18.2803 15.9957 18.3072C15.6836 18.3327 15.3046 18.3327 14.8653 18.3327H14.301C13.8618 18.3327 13.4828 18.3327 13.1706 18.3072C12.8412 18.2803 12.5135 18.2209 12.1982 18.0602C11.7278 17.8205 11.3453 17.4381 11.1057 16.9677C10.945 16.6523 10.8856 16.3247 10.8586 15.9952C10.8331 15.6831 10.8332 15.3041 10.8332 14.8648V5.1339C10.8332 4.69461 10.8331 4.31564 10.8586 4.00349C10.8856 3.67404 10.945 3.34636 11.1057 3.03104C11.3453 2.56064 11.7278 2.17818 12.1982 1.9385C12.5135 1.77784 12.8412 1.71841 13.1706 1.69149C13.4828 1.66599 13.8618 1.666 14.3011 1.66602Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.13439 1.66602H5.69863C6.13792 1.666 6.51689 1.66599 6.82903 1.69149C7.15848 1.71841 7.48617 1.77784 7.80148 1.9385C8.27189 2.17818 8.65434 2.56064 8.89402 3.03104C9.05468 3.34636 9.11411 3.67404 9.14103 4.00349C9.16653 4.31564 9.16652 4.69462 9.16651 5.13392V14.8648C9.16652 15.3041 9.16653 15.6831 9.14103 15.9952C9.11411 16.3247 9.05468 16.6523 8.89402 16.9677C8.65434 17.4381 8.27189 17.8205 7.80148 18.0602C7.48617 18.2209 7.15848 18.2803 6.82903 18.3072C6.51689 18.3327 6.13793 18.3327 5.69864 18.3327H5.13437C4.69508 18.3327 4.31612 18.3327 4.00398 18.3072C3.67453 18.2803 3.34685 18.2209 3.03153 18.0602C2.56112 17.8205 2.17867 17.4381 1.93899 16.9677C1.77833 16.6523 1.7189 16.3247 1.69198 15.9952C1.66648 15.6831 1.66649 15.3041 1.6665 14.8648V5.1339C1.66649 4.69461 1.66648 4.31564 1.69198 4.00349C1.7189 3.67404 1.77833 3.34636 1.93899 3.03104C2.17867 2.56064 2.56112 2.17818 3.03153 1.9385C3.34685 1.77784 3.67453 1.71841 4.00398 1.69149C4.31613 1.66599 4.69509 1.666 5.13439 1.66602Z" fill="currentColor"/>
@ -422,28 +419,24 @@
<path d="M4.66667 1.5H4.16667C3.23325 1.5 2.76654 1.5 2.41002 1.68166C2.09641 1.84144 1.84144 2.09641 1.68166 2.41002C1.5 2.76654 1.5 3.23325 1.5 4.16667V13.8333C1.5 14.7668 1.5 15.2335 1.68166 15.59C1.84144 15.9036 2.09641 16.1586 2.41002 16.3183C2.76654 16.5 3.23325 16.5 4.16667 16.5H4.66667C5.60009 16.5 6.0668 16.5 6.42332 16.3183C6.73692 16.1586 6.99189 15.9036 7.15168 15.59C7.33333 15.2335 7.33333 14.7668 7.33333 13.8333V4.16667C7.33333 3.23325 7.33333 2.76654 7.15168 2.41002C6.99189 2.09641 6.73692 1.84144 6.42332 1.68166C6.0668 1.5 5.60009 1.5 4.66667 1.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.8333 1.5H13.3333C12.3999 1.5 11.9332 1.5 11.5767 1.68166C11.2631 1.84144 11.0081 2.09641 10.8483 2.41002C10.6667 2.76654 10.6667 3.23325 10.6667 4.16667V13.8333C10.6667 14.7668 10.6667 15.2335 10.8483 15.59C11.0081 15.9036 11.2631 16.1586 11.5767 16.3183C11.9332 16.5 12.3999 16.5 13.3333 16.5H13.8333C14.7668 16.5 15.2335 16.5 15.59 16.3183C15.9036 16.1586 16.1586 15.9036 16.3183 15.59C16.5 15.2335 16.5 14.7668 16.5 13.8333V4.16667C16.5 3.23325 16.5 2.76654 16.3183 2.41002C16.1586 2.09641 15.9036 1.84144 15.59 1.68166C15.2335 1.5 14.7668 1.5 13.8333 1.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="graph-outline" viewBox="0 0 20 22" fill="none">
<path d="M6.59 12.51L13.42 16.49M13.41 5.51L6.59 9.49M19 4C19 5.65685 17.6569 7 16 7C14.3431 7 13 5.65685 13 4C13 2.34315 14.3431 1 16 1C17.6569 1 19 2.34315 19 4ZM7 11C7 12.6569 5.65685 14 4 14C2.34315 14 1 12.6569 1 11C1 9.34315 2.34315 8 4 8C5.65685 8 7 9.34315 7 11ZM19 18C19 19.6569 17.6569 21 16 21C14.3431 21 13 19.6569 13 18C13 16.3431 14.3431 15 16 15C17.6569 15 19 16.3431 19 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="graph-solid" viewBox="0 0 24 24" fill="none">
<path d="M14 5C14 2.79086 15.7909 1 18 1C20.2091 1 22 2.79086 22 5C22 7.20914 20.2091 9 18 9C16.8885 9 15.883 8.54668 15.1581 7.81485L9.85034 10.9123C9.94784 11.2581 10 11.623 10 12C10 12.3768 9.9479 12.7415 9.8505 13.0871L15.1613 16.1819C15.886 15.452 16.8902 15 18 15C20.2091 15 22 16.7909 22 19C22 21.2091 20.2091 23 18 23C15.7909 23 14 21.2091 14 19C14 18.6214 14.0526 18.255 14.1509 17.9079L8.84235 14.8144C8.11742 15.5465 7.11167 16 6 16C3.79086 16 2 14.2091 2 12C2 9.79086 3.79086 8 6 8C7.11146 8 8.11703 8.45332 8.84193 9.18514L14.1497 6.08767C14.0522 5.74185 14 5.37701 14 5Z" fill="currentColor"/>
</symbol>
<symbol id="media" viewBox="0 0 18 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.80659 0.666994C3.87988 0.667127 4.95525 0.667127 6.02642 0.666994C6.23674 0.666969 6.44157 0.666945 6.61503 0.681118C6.80553 0.696682 7.03031 0.733404 7.25649 0.848652C7.5701 1.00844 7.82506 1.26341 7.98485 1.57701C8.1001 1.8032 8.13682 2.02798 8.15239 2.21847C8.16656 2.39194 8.16654 2.59677 8.16651 2.8071V6.0269C8.16654 6.23722 8.16656 6.44205 8.15239 6.61552C8.13682 6.80602 8.1001 7.03079 7.98485 7.25698C7.82506 7.57058 7.5701 7.82555 7.25649 7.98534C7.03031 8.10059 6.80553 8.13731 6.61503 8.15288C6.44156 8.16705 6.23673 8.16702 6.02641 8.167H2.80661C2.59628 8.16702 2.39145 8.16705 2.21798 8.15288C2.02749 8.13731 1.80271 8.10059 1.57652 7.98534C1.26292 7.82555 1.00795 7.57058 0.848164 7.25698C0.732916 7.03079 0.696193 6.80602 0.680629 6.61552C0.666457 6.44206 0.666481 6.23723 0.666506 6.02691C0.666507 6.01806 0.666508 6.0092 0.666508 6.00033V2.83366C0.666508 2.82479 0.666507 2.81593 0.666506 2.80708C0.666481 2.59676 0.666457 2.39194 0.680629 2.21847C0.696193 2.02798 0.732916 1.8032 0.848164 1.57701C1.00795 1.26341 1.26292 1.00844 1.57652 0.848652C1.80271 0.733404 2.02749 0.696682 2.21798 0.681118C2.39145 0.666945 2.59627 0.666969 2.80659 0.666994Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.80659 9.83366C3.87988 9.83379 4.95525 9.83379 6.02642 9.83366C6.23674 9.83364 6.44157 9.83361 6.61503 9.84778C6.80553 9.86335 7.03031 9.90007 7.25649 10.0153C7.5701 10.1751 7.82506 10.4301 7.98485 10.7437C8.1001 10.9699 8.13682 11.1946 8.15239 11.3851C8.16656 11.5586 8.16654 11.7634 8.16651 11.9738V15.1936C8.16654 15.4039 8.16656 15.6087 8.15239 15.7822C8.13682 15.9727 8.1001 16.1975 7.98485 16.4236C7.82506 16.7373 7.5701 16.9922 7.25649 17.152C7.03031 17.2673 6.80553 17.304 6.61503 17.3195C6.44156 17.3337 6.23673 17.3337 6.02641 17.3337H2.80661C2.59628 17.3337 2.39145 17.3337 2.21798 17.3195C2.02749 17.304 1.80271 17.2673 1.57652 17.152C1.26292 16.9922 1.00795 16.7373 0.848164 16.4236C0.732916 16.1975 0.696193 15.9727 0.680629 15.7822C0.666457 15.6087 0.666481 15.4039 0.666506 15.1936C0.666507 15.1847 0.666508 15.1759 0.666508 15.167V12.0003C0.666508 11.9915 0.666507 11.9826 0.666506 11.9737C0.666481 11.7634 0.666457 11.5586 0.680629 11.3851C0.696193 11.1946 0.732916 10.9699 0.848164 10.7437C1.00795 10.4301 1.26292 10.1751 1.57652 10.0153C1.80271 9.90007 2.02749 9.86335 2.21798 9.84778C2.39145 9.83361 2.59627 9.83364 2.80659 9.83366Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9733 0.666994C13.0465 0.667127 14.1219 0.667127 15.1931 0.666994C15.4034 0.666969 15.6082 0.666945 15.7817 0.681118C15.9722 0.696682 16.197 0.733404 16.4232 0.848652C16.7368 1.00844 16.9917 1.26341 17.1515 1.57701C17.2668 1.8032 17.3035 2.02798 17.3191 2.21847C17.3332 2.39194 17.3332 2.59677 17.3332 2.8071V6.0269C17.3332 6.23722 17.3332 6.44205 17.3191 6.61552C17.3035 6.80602 17.2668 7.03079 17.1515 7.25698C16.9917 7.57058 16.7368 7.82555 16.4232 7.98534C16.197 8.10059 15.9722 8.13731 15.7817 8.15288C15.6082 8.16705 15.4034 8.16702 15.1931 8.167H11.9733C11.763 8.16702 11.5581 8.16705 11.3847 8.15288C11.1942 8.13731 10.9694 8.10059 10.7432 7.98534C10.4296 7.82555 10.1746 7.57058 10.0148 7.25698C9.89958 7.03079 9.86286 6.80602 9.8473 6.61552C9.83312 6.44206 9.83315 6.23723 9.83317 6.02691C9.83317 6.01806 9.83317 6.0092 9.83317 6.00033V2.83366C9.83317 2.82479 9.83317 2.81593 9.83317 2.80708C9.83315 2.59676 9.83312 2.39194 9.8473 2.21847C9.86286 2.02798 9.89958 1.8032 10.0148 1.57701C10.1746 1.26341 10.4296 1.00844 10.7432 0.848652C10.9694 0.733404 11.1942 0.696682 11.3847 0.681118C11.5581 0.666945 11.7629 0.666969 11.9733 0.666994Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9733 9.83366C13.0465 9.83379 14.1219 9.83379 15.1931 9.83366C15.4034 9.83364 15.6082 9.83361 15.7817 9.84778C15.9722 9.86335 16.197 9.90007 16.4232 10.0153C16.7368 10.1751 16.9917 10.4301 17.1515 10.7437C17.2668 10.9699 17.3035 11.1946 17.3191 11.3851C17.3332 11.5586 17.3332 11.7634 17.3332 11.9738V15.1936C17.3332 15.4039 17.3332 15.6087 17.3191 15.7822C17.3035 15.9727 17.2668 16.1975 17.1515 16.4236C16.9917 16.7373 16.7368 16.9922 16.4232 17.152C16.197 17.2673 15.9722 17.304 15.7817 17.3195C15.6082 17.3337 15.4034 17.3337 15.1931 17.3337H11.9733C11.763 17.3337 11.5581 17.3337 11.3847 17.3195C11.1942 17.304 10.9694 17.2673 10.7432 17.152C10.4296 16.9922 10.1746 16.7373 10.0148 16.4236C9.89958 16.1975 9.86286 15.9727 9.8473 15.7822C9.83312 15.6087 9.83315 15.4039 9.83317 15.1936C9.83317 15.1847 9.83317 15.1759 9.83317 15.167V12.0003C9.83317 11.9915 9.83317 11.9826 9.83317 11.9737C9.83315 11.7634 9.83312 11.5586 9.8473 11.3851C9.86286 11.1946 9.89958 10.9699 10.0148 10.7437C10.1746 10.4301 10.4296 10.1751 10.7432 10.0153C10.9694 9.90007 11.1942 9.86335 11.3847 9.84778C11.5581 9.83361 11.7629 9.83364 11.9733 9.83366Z" fill="currentColor"/>
</symbol>
<symbol id="info-solid" viewBox="0 0 22 22" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 0C4.92487 0 0 4.92487 0 11C0 17.0751 4.92487 22 11 22C17.0751 22 22 17.0751 22 11C22 4.92487 17.0751 0 11 0ZM11 6C10.4477 6 10 6.44772 10 7C10 7.55228 10.4477 8 11 8H11.01C11.5623 8 12.01 7.55228 12.01 7C12.01 6.44772 11.5623 6 11.01 6H11ZM12 11C12 10.4477 11.5523 10 11 10C10.4477 10 10 10.4477 10 11V15C10 15.5523 10.4477 16 11 16C11.5523 16 12 15.5523 12 15V11Z" fill="currentColor"/>
</symbol>
<symbol id="info-outline" viewBox="0 0 22 22" fill="none">
<path d="M11 15V11M11 7H11.01M21 11C21 16.5228 16.5228 21 11 21C5.47715 21 1 16.5228 1 11C1 5.47715 5.47715 1 11 1C16.5228 1 21 5.47715 21 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="play-square-outline" viewBox="0 0 20 20" fill="none">
<path d="M7.5 6.96533C7.5 6.48805 7.5 6.24941 7.59974 6.11618C7.68666 6.00007 7.81971 5.92744 7.96438 5.9171C8.13038 5.90525 8.33112 6.03429 8.73261 6.29239L13.4532 9.32706C13.8016 9.55102 13.9758 9.663 14.0359 9.80539C14.0885 9.9298 14.0885 10.0702 14.0359 10.1946C13.9758 10.337 13.8016 10.449 13.4532 10.6729L8.73261 13.7076C8.33112 13.9657 8.13038 14.0948 7.96438 14.0829C7.81971 14.0726 7.68666 13.9999 7.59974 13.8838C7.5 13.7506 7.5 13.512 7.5 13.0347V6.96533Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 5.8C1 4.11984 1 3.27976 1.32698 2.63803C1.6146 2.07354 2.07354 1.6146 2.63803 1.32698C3.27976 1 4.11984 1 5.8 1H14.2C15.8802 1 16.7202 1 17.362 1.32698C17.9265 1.6146 18.3854 2.07354 18.673 2.63803C19 3.27976 19 4.11984 19 5.8V14.2C19 15.8802 19 16.7202 18.673 17.362C18.3854 17.9265 17.9265 18.3854 17.362 18.673C16.7202 19 15.8802 19 14.2 19H5.8C4.11984 19 3.27976 19 2.63803 18.673C2.07354 18.3854 1.6146 17.9265 1.32698 17.362C1 16.7202 1 15.8802 1 14.2V5.8Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@ -460,6 +453,16 @@
<symbol id="sats" viewBox="0 0 24 25" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21 12.5C21 13.6819 20.7672 14.8522 20.3149 15.9442C19.8626 17.0361 19.1997 18.0282 18.364 18.864C17.5282 19.6997 16.5361 20.3626 15.4442 20.8149C14.3522 21.2672 13.1819 21.5 12 21.5C10.8181 21.5 9.64778 21.2672 8.55585 20.8149C7.46392 20.3626 6.47177 19.6997 5.63604 18.864C4.80031 18.0282 4.13738 17.0361 3.68508 15.9442C3.23279 14.8522 3 13.6819 3 12.5C3 10.1131 3.94821 7.82387 5.63604 6.13604C7.32387 4.44821 9.61305 3.5 12 3.5C14.3869 3.5 16.6761 4.44821 18.364 6.13604C20.0518 7.82387 21 10.1131 21 12.5ZM8.693 9.242L16.33 11.305L16.667 9.843L9.029 7.78L8.693 9.242ZM14.219 6.192L13.813 7.966L12.365 7.574L12.772 5.8L14.219 6.192ZM11.227 19.2L11.635 17.426L10.187 17.035L9.779 18.809L11.227 19.2ZM15.648 14.266L8.011 12.2L8.347 10.738L15.984 12.804L15.648 14.266ZM7.332 15.156L14.97 17.22L15.306 15.758L7.668 13.694L7.332 15.156Z" fill="currentColor"/>
</symbol>
<symbol id="face-smile" viewBox="0 0 24 25" fill="none">
<path d="M8 14.5C8 14.5 9.5 16.5 12 16.5C14.5 16.5 16 14.5 16 14.5M15 9.5H15.01M9 9.5H9.01M22 12.5C22 18.0228 17.5228 22.5 12 22.5C6.47715 22.5 2 18.0228 2 12.5C2 6.97715 6.47715 2.5 12 2.5C17.5228 2.5 22 6.97715 22 12.5ZM15.5 9.5C15.5 9.77614 15.2761 10 15 10C14.7239 10 14.5 9.77614 14.5 9.5C14.5 9.22386 14.7239 9 15 9C15.2761 9 15.5 9.22386 15.5 9.5ZM9.5 9.5C9.5 9.77614 9.27614 10 9 10C8.72386 10 8.5 9.77614 8.5 9.5C8.5 9.22386 8.72386 9 9 9C9.27614 9 9.5 9.22386 9.5 9.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="bar-chart" viewBox="0 0 24 25" fill="none" >
<path d="M9 7.5H4.6C4.03995 7.5 3.75992 7.5 3.54601 7.60899C3.35785 7.70487 3.20487 7.85785 3.10899 8.04601C3 8.25992 3 8.53995 3 9.1V19.9C3 20.4601 3 20.7401 3.10899 20.954C3.20487 21.1422 3.35785 21.2951 3.54601 21.391C3.75992 21.5 4.03995 21.5 4.6 21.5H9M9 21.5H15M9 21.5L9 5.1C9 4.53995 9 4.25992 9.10899 4.04601C9.20487 3.85785 9.35785 3.70487 9.54601 3.60899C9.75992 3.5 10.0399 3.5 10.6 3.5L13.4 3.5C13.9601 3.5 14.2401 3.5 14.454 3.60899C14.6422 3.70487 14.7951 3.85785 14.891 4.04601C15 4.25992 15 4.53995 15 5.1V21.5M15 11.5H19.4C19.9601 11.5 20.2401 11.5 20.454 11.609C20.6422 11.7049 20.7951 11.8578 20.891 12.046C21 12.2599 21 12.5399 21 13.1V19.9C21 20.4601 21 20.7401 20.891 20.954C20.7951 21.1422 20.6422 21.2951 20.454 21.391C20.2401 21.5 19.9601 21.5 19.4 21.5H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="expand" viewBox="0 0 24 24" fill="none">
<path d="M14 3C14 2.44772 14.4477 2 15 2H21C21.5523 2 22 2.44772 22 3V9C22 9.55229 21.5523 10 21 10C20.4477 10 20 9.55229 20 9V5.41421L14.7071 10.7071C14.3166 11.0976 13.6834 11.0976 13.2929 10.7071C12.9024 10.3166 12.9024 9.68342 13.2929 9.29289L18.5858 4H15C14.4477 4 14 3.55228 14 3Z" fill="currentColor" />
<path d="M5.41421 20L10.7071 14.7071C11.0976 14.3166 11.0976 13.6834 10.7071 13.2929C10.3166 12.9024 9.68342 12.9024 9.29289 13.2929L4 18.5858L4 15C4 14.4477 3.55229 14 3 14C2.44772 14 2 14.4477 2 15V21C2 21.5523 2.44772 22 3 22H9C9.55228 22 10 21.5523 10 21C10 20.4477 9.55228 20 9 20H5.41421Z" fill="currentColor" />
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 132 KiB

View File

@ -3,6 +3,15 @@ export const DefaultLocale = "en-US";
export const getLocale = () => {
return (navigator.languages && navigator.languages[0]) ?? navigator.language ?? DefaultLocale;
};
export const getCurrency = () => {
const locale = navigator.language || navigator.languages[0];
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
currencyDisplay: "code",
});
return formatter.formatToParts(1.2345).find(a => a.type === "currency")?.value ?? "USD";
};
export const AllLanguageCodes = [
"en",
"ja",

View File

@ -42,7 +42,7 @@ const InviteModal = () => {
</p>
<Link to="/login/sign-up">
<button className="primary">
<FormattedMessage defaultMessage="Sign Up" id="39AHJm" />
<FormattedMessage defaultMessage="Sign Up" />
</button>
</Link>
</div>

View File

@ -11,10 +11,10 @@ export default function AccountName({ name = "", link = true }: AccountNameProps
return (
<>
<div>
<FormattedMessage defaultMessage="Username" id="JCIgkj" />: <b>{name}</b>
<FormattedMessage defaultMessage="Username" />: <b>{name}</b>
</div>
<div>
<FormattedMessage defaultMessage="Short link" id="rx1i0i" />:{" "}
<FormattedMessage defaultMessage="Short link" />:{" "}
{link ? (
<a
href={`https://iris.to/${name}`}
@ -29,7 +29,7 @@ export default function AccountName({ name = "", link = true }: AccountNameProps
)}
</div>
<div>
<FormattedMessage defaultMessage="Nostr address (nip05)" id="BjNwZW" />: <b>{name}@iris.to</b>
<FormattedMessage defaultMessage="Nostr address (nip05)" />: <b>{name}@iris.to</b>
</div>
</>
);

View File

@ -67,12 +67,12 @@ export default function ActiveAccount({ name = "", setAsPrimary = () => {} }: Ac
return (
<div>
<div className="negative">
<FormattedMessage defaultMessage="You have an active iris.to account" id="UrKTqQ" />:
<FormattedMessage defaultMessage="You have an active iris.to account" />:
<AccountName name={name} />
</div>
<p>
<button type="button" onClick={onClick}>
<FormattedMessage defaultMessage="Set as primary Nostr address (nip05)" id="MiMipu" />
<FormattedMessage defaultMessage="Set as primary Nostr address (nip05)" />
</button>
</p>
</div>

View File

@ -72,7 +72,7 @@ class IrisAccount extends Component<Props> {
view = (
<div>
<p>
<FormattedMessage defaultMessage="Register an Iris username" id="kEZUR8" /> (iris.to/username)
<FormattedMessage defaultMessage="Register an Iris username" /> (iris.to/username)
</p>
<form onSubmit={e => this.showChallenge(e)}>
<div className="flex g8">
@ -84,14 +84,14 @@ class IrisAccount extends Component<Props> {
onInput={e => this.onNewUserNameChange(e)}
/>
<button type="submit">
<FormattedMessage defaultMessage="Register" id="deEeEI" />
<FormattedMessage defaultMessage="Register" />
</button>
</div>
<div>
{this.state.newUserNameValid ? (
<>
<span className="success">
<FormattedMessage defaultMessage="Username is available" id="EcfIwB" />
<FormattedMessage defaultMessage="Username is available" />
</span>
<AccountName name={this.state.newUserName} link={false} />
</>
@ -107,7 +107,7 @@ class IrisAccount extends Component<Props> {
return (
<>
<h3>
<FormattedMessage defaultMessage="Iris.to account" id="Mzizei" />
<FormattedMessage defaultMessage="Iris.to account" />
</h3>
{view}
<p>

View File

@ -25,12 +25,12 @@ export default function ReservedAccount({
<AccountName name={name} link={false} />
<p>
<button className="btn btn-sm btn-primary" onClick={() => enableReserved()}>
<FormattedMessage defaultMessage="Yes please" id="VcwrfF" />
<FormattedMessage defaultMessage="Yes please" />
</button>
</p>
<p>
<button className="btn btn-sm btn-neutral" onClick={() => declineReserved()}>
<FormattedMessage defaultMessage="No thanks" id="c+JYNI" />
<FormattedMessage defaultMessage="No thanks" />
</button>
</p>
</div>

View File

@ -1,18 +1,36 @@
import { NostrEvent, NostrLink } from "@snort/system";
import { useState } from "react";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { lazy, Suspense, useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import Icon from "@/Components/Icons/Icon";
import { findTag } from "@/Utils";
import { extractStreamInfo } from "@/Utils/stream";
import NoteAppHandler from "../Event/Note/NoteAppHandler";
import ProfileImage from "../User/ProfileImage";
const LiveKitRoom = lazy(() => import("./livekit"));
export function LiveEvent({ ev }: { ev: NostrEvent }) {
const title = findTag(ev, "title");
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;
export function LiveEvent({ ev }: { ev: TaggedNostrEvent }) {
const service = ev.tags.find(a => a[0] === "streaming")?.at(1);
function inner() {
if (service?.endsWith(".m3u8")) {
return <LiveStreamEvent ev={ev} />;
} else if (service?.startsWith("wss+livekit://")) {
return (
<Suspense>
<LiveKitRoom ev={ev} canJoin={true} />
</Suspense>
);
}
return <NoteAppHandler ev={ev} />;
}
return inner();
}
function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
const { title, status, starts, host } = extractStreamInfo(ev);
const [play, setPlay] = useState(false);
function statusLine() {
@ -22,7 +40,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
<div className="flex g4 items-center">
<Icon name="signal-01" />
<b className="uppercase">
<FormattedMessage defaultMessage="Live" id="Dn82AL" />
<FormattedMessage defaultMessage="Live" />
</b>
</div>
);
@ -30,7 +48,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
case "ended": {
return (
<b className="uppercase">
<FormattedMessage defaultMessage="Ended" id="TP/cMX" />
<FormattedMessage defaultMessage="Ended" />
</b>
);
}
@ -38,7 +56,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
return (
<b className="uppercase">
{new Intl.DateTimeFormat(undefined, { dateStyle: "full", timeStyle: "short" }).format(
new Date(starts * 1000),
new Date(Number(starts) * 1000),
)}
</b>
);
@ -52,7 +70,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
case "live": {
return (
<button className="nowrap" onClick={() => setPlay(true)}>
<FormattedMessage defaultMessage="Watch Stream" id="furjvW" />
<FormattedMessage defaultMessage="Watch Stream" />
</button>
);
}
@ -61,7 +79,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
return (
<Link to={link} target="_blank">
<button className="nowrap">
<FormattedMessage defaultMessage="Watch Replay" id="6/hB3S" />
<FormattedMessage defaultMessage="Watch Replay" />
</button>
</Link>
);
@ -73,8 +91,6 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
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={{
@ -86,7 +102,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
return (
<div className="sm:flex g12 br p24 bg-primary items-center">
<div>
<ProfileImage pubkey={host} showUsername={false} size={56} />
<ProfileImage pubkey={host!} showUsername={false} size={56} />
</div>
<div className="flex flex-col g8 grow">
<div className="font-semibold text-3xl">{title}</div>

View File

@ -1,54 +0,0 @@
.stream-list {
display: flex;
gap: 4px;
overflow-x: auto;
}
.stream-list::-webkit-scrollbar {
height: 6.25px;
}
.stream-event {
display: flex;
padding: 8px 12px;
gap: 8px;
text-decoration: none;
}
.stream-event > div:first-of-type {
border-radius: 8px;
height: 49px;
width: 65px;
background-color: var(--gray-light);
background-image: var(--img);
background-position: center;
background-size: cover;
}
.stream-event span.live {
display: flex;
padding: 4px 6px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 9px;
background: var(--live);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.stream-event .details .reactions {
color: var(--font-secondary-color);
}
.stream-event .details > div:nth-of-type(2) {
width: 100px;
min-width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}

View File

@ -1,64 +1,67 @@
import "./LiveStreams.css";
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 { NostrEvent, NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import classNames from "classnames";
import { CSSProperties } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import Icon from "@/Components/Icons/Icon";
import useFollowsControls from "@/Hooks/useFollowControls";
import useImgProxy from "@/Hooks/useImgProxy";
import useLiveStreams from "@/Hooks/useLiveStreams";
import { findTag } from "@/Utils";
export function LiveStreams() {
const { followList } = useFollowsControls();
const sub = useMemo(() => {
const since = unixNow() - 60 * 60 * 24;
const rb = new RequestBuilder("follows:streams");
rb.withFilter().kinds([EventKind.LiveEvent]).authors(followList).since(since);
rb.withFilter().kinds([EventKind.LiveEvent]).tag("p", followList).since(since);
return rb;
}, [followList]);
import Avatar from "../User/Avatar";
const streams = useRequestBuilder(sub);
export function LiveStreams() {
const streams = useLiveStreams();
if (streams.length === 0) return null;
return (
<div className="stream-list">
<div className="flex mx-2 gap-4 overflow-x-auto sm-hide-scrollbar">
{streams.map(v => (
<LiveStreamEvent ev={v} key={`${v.kind}:${v.pubkey}:${findTag(v, "d")}`} />
<LiveStreamEvent ev={v} key={`${v.kind}:${v.pubkey}:${findTag(v, "d")}`} className="h-[80px]" />
))}
</div>
);
}
function LiveStreamEvent({ ev }: { ev: NostrEvent }) {
export function LiveStreamEvent({ ev, className }: { ev: NostrEvent; className?: string }) {
const { proxy } = useImgProxy();
const title = findTag(ev, "title");
const image = findTag(ev, "image");
const status = findTag(ev, "status");
const viewers = findTag(ev, "current_participants");
const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
const hostProfile = useUserProfile(host);
const link = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
const link = NostrLink.fromEvent(ev).encode();
const imageProxy = proxy(image ?? "");
return (
<Link className="stream-event" to={`https://zap.stream/${link}`} target="_blank">
<Link className={classNames("flex gap-2", className)} to={`https://zap.stream/${link}`} target="_blank">
<div className="relative aspect-video">
<div
className="absolute h-full w-full bg-center bg-cover bg-gray-ultradark rounded-lg"
style={
{
"--img": `url(${imageProxy})`,
backgroundImage: `url(${imageProxy})`,
} as CSSProperties
}></div>
<div className="flex flex-col details">
<div className="flex g2">
<span className="live">{status}</span>
<div className="reaction-pill">
<Icon name="zap" size={24} />
<div className="reaction-pill-number">0</div>
<div className="absolute left-0 top-0 w-full overflow-hidden">
<div
className="whitespace-nowrap px-1 text-ellipsis overflow-hidden text-xs font-medium bg-background opacity-70 text-center"
title={title}>
{title}
</div>
</div>
<div>{title}</div>
<div className="absolute bottom-1 left-1 bg-heart rounded-md px-2 uppercase font-bold">{status}</div>
<div className="absolute right-1 bottom-1">
<Avatar pubkey={host} user={hostProfile} size={25} className="outline outline-2 outline-highlight" />
</div>
{viewers && (
<div className="absolute left-1 bottom-7 rounded-md px-2 py-1 text-xs bg-gray font-medium">
<FormattedMessage defaultMessage="{n} viewers" values={{ n: viewers }} />
</div>
)}
</div>
</Link>
);

View File

@ -0,0 +1,131 @@
import { LiveKitRoom as LiveKitRoomContext, RoomAudioRenderer, useParticipants } from "@livekit/components-react";
import { dedupe, unixNow } from "@snort/shared";
import { EventKind, NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder, useUserProfile } from "@snort/system-react";
import { LocalParticipant, RemoteParticipant } from "livekit-client";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { extractStreamInfo } from "@/Utils/stream";
import AsyncButton from "../Button/AsyncButton";
import { ProxyImg } from "../ProxyImg";
import Avatar from "../User/Avatar";
import { AvatarGroup } from "../User/AvatarGroup";
import DisplayName from "../User/DisplayName";
export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent; canJoin?: boolean }) {
const { stream, service, id } = extractStreamInfo(ev);
const { publisher } = useEventPublisher();
const [join, setJoin] = useState(false);
const [token, setToken] = useState<string>();
async function getToken() {
if (!service || !publisher) return;
const url = `${service}/api/v1/nests/${id}`;
const auth = await publisher.generic(eb => {
eb.kind(EventKind.HttpAuthentication);
eb.tag(["url", url]);
eb.tag(["u", url]);
eb.tag(["method", "GET"]);
return eb;
});
const rsp = await fetch(url, {
headers: {
authorization: `Nostr ${window.btoa(JSON.stringify(auth))}`,
},
});
const text = await rsp.text();
if (rsp.ok) {
return JSON.parse(text) as { token: string };
}
}
useEffect(() => {
if (join && !token) {
getToken()
.then(t => setToken(t?.token))
.catch(console.error);
}
}, [join]);
if (!join) {
return (
<div className="p flex flex-col gap-2">
<RoomHeader ev={ev} />
{(canJoin ?? false) && (
<AsyncButton onClick={() => setJoin(true)}>
<FormattedMessage defaultMessage="Join Room" />
</AsyncButton>
)}
</div>
);
}
return (
<LiveKitRoomContext token={token} serverUrl={stream?.replace("wss+livekit://", "wss://")} connect={true}>
<RoomAudioRenderer volume={1} />
<ParticipantList ev={ev} />
</LiveKitRoomContext>
);
}
function RoomHeader({ ev }: { ev: TaggedNostrEvent }) {
const { image, title } = extractStreamInfo(ev);
return (
<div className="relative rounded-xl h-[140px] w-full overflow-hidden">
{image ? <ProxyImg src={image} className="w-full" /> : <div className="absolute bg-gray-dark w-full h-full" />}
<div className="absolute left-4 top-4 w-full flex justify-between pr-4">
<div className="text-2xl">{title}</div>
<div>
<NostrParticipants ev={ev} />
</div>
</div>
</div>
);
}
function ParticipantList({ ev }: { ev: TaggedNostrEvent }) {
const participants = useParticipants();
return (
<div className="p">
<RoomHeader ev={ev} />
<h3>
<FormattedMessage defaultMessage="Participants" />
</h3>
<div className="grid grid-cols-4">
{participants.map(a => (
<LiveKitUser p={a} key={a.identity} />
))}
</div>
</div>
);
}
function NostrParticipants({ ev }: { ev: TaggedNostrEvent }) {
const link = NostrLink.fromEvent(ev);
const sub = useMemo(() => {
const sub = new RequestBuilder(`livekit-participants:${link.tagKey}`);
sub
.withFilter()
.replyToLink([link])
.kinds([10_312 as EventKind])
.since(unixNow() - 600);
return sub;
}, [link.tagKey]);
const presense = useRequestBuilder(sub);
return <AvatarGroup ids={dedupe(presense.map(a => a.pubkey))} size={32} />;
}
function LiveKitUser({ p }: { p: RemoteParticipant | LocalParticipant }) {
const pubkey = p.identity.startsWith("guest-") ? "anon" : p.identity;
const profile = useUserProfile(pubkey);
return (
<div className="flex flex-col gap-2 items-center text-center">
<Avatar pubkey={pubkey} className={p.isSpeaking ? "outline" : ""} user={profile} size={48} />
<DisplayName pubkey={pubkey} user={pubkey === "anon" ? { name: "Anon" } : profile} />
</div>
);
}

View File

@ -283,7 +283,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
: startBuy(handle, domain)
}>
{props.forSubscription ? (
<FormattedMessage defaultMessage="Claim Now" id="FdhSU2" />
<FormattedMessage defaultMessage="Claim Now" />
) : (
<FormattedMessage {...messages.BuyNow} />
)}

View File

@ -10,11 +10,11 @@ export function Offline({ onRetry, className }: { onRetry?: () => void | Promise
<div className={classNames("flex items-center g8", className)}>
<Icon name="wifi-off" className="error" />
<div className="error">
<FormattedMessage defaultMessage="Offline" id="7UOvbT" />
<FormattedMessage defaultMessage="Offline" />
</div>
{onRetry && (
<AsyncButton onClick={onRetry}>
<FormattedMessage defaultMessage="Retry" id="62nsdy" />
<FormattedMessage defaultMessage="Retry" />
</AsyncButton>
)}
</div>

View File

@ -70,7 +70,7 @@ export function PinPrompt({
}}>
<div className="flex flex-col g12">
<h2>
<FormattedMessage defaultMessage="Enter Pin" id="KtsyO0" />
<FormattedMessage defaultMessage="Enter Pin" />
</h2>
{subTitle ? <div>{subTitle}</div> : null}
<input
@ -84,10 +84,10 @@ export function PinPrompt({
{error && <b className="error">{error}</b>}
<div className="flex g8">
<button type="button" onClick={() => onCancel()}>
<FormattedMessage defaultMessage="Cancel" id="47FYwb" />
<FormattedMessage defaultMessage="Cancel" />
</button>
<AsyncButton ref={submitButtonRef} onClick={() => submitPin()} type="submit">
<FormattedMessage defaultMessage="Submit" id="wSZR47" />
<FormattedMessage defaultMessage="Submit" />
</AsyncButton>
</div>
</div>
@ -168,7 +168,7 @@ export function LoginUnlock() {
<PinPrompt
subTitle={
<p>
<FormattedMessage defaultMessage="Enter pin to unlock your private key" id="e7VmYP" />
<FormattedMessage defaultMessage="Enter pin to unlock your private key" />
</p>
}
onResult={unlockSession}

View File

@ -1,38 +1,55 @@
import { TaggedNostrEvent } from "@snort/system";
import { OkResponse, TaggedNostrEvent } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { useContext, useState } from "react";
import { FormattedMessage } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import Modal from "@/Components/Modal/Modal";
import useRelays from "@/Hooks/useRelays";
import AsyncButton from "./Button/AsyncButton";
import messages from "./messages";
export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) {
const [selected, setSelected] = useState<Array<string>>();
const [replies, setReplies] = useState<Array<OkResponse>>([]);
const [sending, setSending] = useState(false);
const system = useContext(SnortContext);
const relays = useRelays();
async function sendReBroadcast() {
setSending(true);
setReplies([]);
try {
if (selected) {
await Promise.all(selected.map(r => system.WriteOnceToRelay(r, ev)));
await Promise.all(selected.map(r => system.WriteOnceToRelay(r, ev).then(o => setReplies(v => [...v, o]))));
} else {
system.BroadcastEvent(ev);
const rsp = await system.BroadcastEvent(ev);
setReplies(rsp);
}
} catch (e) {
console.error(e);
} finally {
setSending(false);
}
}
function renderRelayCustomisation() {
return (
<div className="flex flex-col g8">
<>
<Modal id="broadcaster" onClose={onClose}>
<div className="flex flex-col gap-4">
<div className="text-xl font-medium">
<FormattedMessage defaultMessage="Broadcast Event" />
</div>
{Object.keys(relays)
.filter(el => relays[el].write)
.map((r, i, a) => (
<div key={r} className="card flex justify-between">
<div key={r} className="flex justify-between">
<div className="flex flex-col gap-1">
<div>{r}</div>
<small>{replies.findLast(a => a.relay === r)?.message}</small>
</div>
<div>
<input
type="checkbox"
disabled={sending}
checked={!selected || selected.includes(r)}
onChange={e =>
setSelected(
@ -45,22 +62,15 @@ export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: Tagged
</div>
</div>
))}
</div>
);
}
return (
<>
<Modal id="broadcaster" className="note-creator-modal" onClose={onClose}>
{renderRelayCustomisation()}
<div className="flex g8">
<div className="flex gap-2">
<button className="secondary" onClick={onClose}>
<FormattedMessage {...messages.Cancel} />
<FormattedMessage defaultMessage="Cancel" />
</button>
<AsyncButton onClick={sendReBroadcast}>
<FormattedMessage {...messages.ReBroadcast} />
<AsyncButton onClick={sendReBroadcast} disabled={sending}>
<FormattedMessage defaultMessage="Send" />
</AsyncButton>
</div>
</div>
</Modal>
</>
);

View File

@ -1,11 +0,0 @@
.relay {
border-radius: 5px;
display: grid;
grid-template-columns: min-content auto;
overflow: hidden;
font-size: var(--font-size-small);
}
.relay > div {
padding: 5px;
}

View File

@ -1,85 +1,48 @@
import "./Relay.css";
import { Link } from "react-router-dom";
import { RelaySettings } from "@snort/system";
import classNames from "classnames";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import useRelayState from "@/Feed/RelayState";
import useLogin from "@/Hooks/useLogin";
import { getRelayName } from "@/Utils";
import { RelayFavicon } from "./RelaysMetadata";
import Icon from "../Icons/Icon";
import RelayPermissions from "./permissions";
import RelayStatusLabel from "./status-label";
import RelayUptime from "./uptime";
export interface RelayProps {
addr: string;
}
export default function Relay(props: RelayProps) {
const navigate = useNavigate();
const state = useLogin(s => s.state);
const name = useMemo(() => getRelayName(props.addr), [props.addr]);
const connection = useRelayState(props.addr);
const relaySettings = state.relays?.find(a => a.url === props.addr)?.settings;
if (!relaySettings || !connection) return;
async function configure(o: RelaySettings) {
await state.updateRelay(props.addr, o);
}
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
if (!connection) return;
const name = connection.info?.name ?? getRelayName(props.addr);
return (
<>
<div className="relay bg-dark">
<div className={classNames("flex items-center", connection.isOpen ? "bg-success" : "bg-error")}>
<RelayFavicon url={props.addr} />
</div>
<div className="flex flex-col g8">
<div>
<b>{name}</b>
</div>
{!connection?.Ephemeral && (
<div className="flex g8">
<AsyncIcon
iconName="write"
iconSize={16}
className={classNames("button-icon-sm transparent", { active: relaySettings.write })}
onClick={() =>
configure({
write: !relaySettings.write,
read: relaySettings.read,
})
}
<tr>
<td className="text-ellipsis" title={props.addr}>
<Link to={`/settings/relays/${encodeURIComponent(props.addr)}`}>
{name.length > 20 ? <>{name.slice(0, 20)}...</> : name}
</Link>
</td>
<td>
<RelayStatusLabel conn={connection} />
</td>
<td>
<RelayPermissions conn={connection} />
</td>
<td className="text-center">
<RelayUptime url={props.addr} />
</td>
<td>
<Icon
name="trash"
className="text-gray-light cursor-pointer"
onClick={() => {
state.removeRelay(props.addr, true);
}}
/>
<AsyncIcon
iconName="read"
iconSize={16}
className={classNames("button-icon-sm transparent", { active: relaySettings.read })}
onClick={() =>
configure({
write: relaySettings.write,
read: !relaySettings.read,
})
}
/>
<AsyncIcon
iconName="trash"
iconSize={16}
className="button-icon-sm transparent trash-icon"
onClick={() => state.removeRelay(props.addr)}
/>
<AsyncIcon
iconName="gear"
iconSize={16}
className="button-icon-sm transparent"
onClick={() => navigate(connection?.Id ?? "")}
/>
</div>
)}
</div>
</div>
</>
</td>
</tr>
);
}

View File

@ -1,9 +0,0 @@
.favicon {
width: 21px;
height: 21px;
max-width: unset;
}
.relay-active {
color: var(--highlight);
}

View File

@ -1,12 +1,10 @@
import "./RelaysMetadata.css";
import { FullRelaySettings } from "@snort/system";
import { useState } from "react";
import Nostrich from "@/assets/img/nostrich.webp";
import Icon from "@/Components/Icons/Icon";
export const RelayFavicon = ({ url }: { url: string }) => {
export const RelayFavicon = ({ url, size }: { url: string; size?: number }) => {
const cleanUrl = url
.replace(/^wss:\/\//, "https://")
.replace(/^ws:\/\//, "http://")
@ -14,10 +12,12 @@ export const RelayFavicon = ({ url }: { url: string }) => {
const [faviconUrl, setFaviconUrl] = useState(`${cleanUrl}/favicon.ico`);
return (
<img
className="circle favicon"
className="rounded-full object-cover"
src={faviconUrl}
onError={() => setFaviconUrl(Nostrich)}
alt={`favicon for ${url}`}
width={size ?? 20}
height={size ?? 20}
/>
);
};
@ -35,8 +35,8 @@ const RelaysMetadata = ({ relays }: RelaysMetadataProps) => {
<RelayFavicon url={url} />
<code className="grow f-ellipsis">{url}</code>
<div className="flex g8">
<Icon name="read" className={settings.read ? "relay-active" : "disabled"} />
<Icon name="write" className={settings.write ? "relay-active" : "disabled"} />
<Icon name="read" className={settings.read ? "text-highlight" : "disabled"} />
<Icon name="write" className={settings.write ? "text-highlight" : "disabled"} />
</div>
</div>
);

View File

@ -0,0 +1,17 @@
import { RelayInfo } from "@snort/system";
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
export default function RelayPaymentLabel({ info }: { info: RelayInfo }) {
const isPaid = info?.limitation?.payment_required ?? false;
return (
<div
className={classNames("rounded-full px-2 py-1 font-medium", {
"bg-[var(--pro)] text-black": isPaid,
"bg-[var(--free)]": !isPaid,
})}>
{isPaid && <FormattedMessage defaultMessage="Paid" />}
{!isPaid && <FormattedMessage defaultMessage="Free" />}
</div>
);
}

View File

@ -0,0 +1,33 @@
import { ConnectionType } from "@snort/system/dist/connection-pool";
import { FormattedMessage } from "react-intl";
import useLogin from "@/Hooks/useLogin";
export default function RelayPermissions({ conn }: { conn: ConnectionType }) {
const { state } = useLogin(s => ({ v: s.state.version, state: s.state }));
return (
<div className="flex gap-2 cursor-pointer select-none">
<div
className={conn.settings.read ? "" : "text-gray"}
onClick={async () =>
await state.updateRelay(conn.address, {
read: !conn.settings.read,
write: conn.settings.write,
})
}>
<FormattedMessage defaultMessage="Read" />
</div>
<div
className={conn.settings.write ? "" : "text-gray"}
onClick={async () =>
await state.updateRelay(conn.address, {
read: conn.settings.read,
write: !conn.settings.write,
})
}>
<FormattedMessage defaultMessage="Write" />
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { Link } from "react-router-dom";
export default function RelaySoftware({ software }: { software: string }) {
if (software.includes("git")) {
const u = new URL(software);
return <Link to={software}>{u.pathname.split("/").at(-1)?.replace(".git", "")}</Link>;
}
return software;
}

View File

@ -0,0 +1,16 @@
import { ConnectionType } from "@snort/system/dist/connection-pool";
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
export default function RelayStatusLabel({ conn }: { conn: ConnectionType }) {
return (
<div className="flex gap-1 items-center">
<div
className={classNames("rounded-full w-4 h-4", {
"bg-success": conn.isOpen,
"bg-error": !conn.isOpen,
})}></div>
{conn.isOpen ? <FormattedMessage defaultMessage="Connected" /> : <FormattedMessage defaultMessage="Offline" />}
</div>
);
}

View File

@ -0,0 +1,21 @@
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
export default function UptimeLabel({ avgPing }: { avgPing: number }) {
const idealPing = 500;
const badPing = idealPing * 2;
return (
<div
className={classNames("font-semibold", {
"text-error": isNaN(avgPing) || avgPing > badPing,
"text-warning": avgPing > idealPing && avgPing < badPing,
"text-success": avgPing < idealPing,
})}
title={`${avgPing.toFixed(0)} ms`}>
{isNaN(avgPing) && <FormattedMessage defaultMessage="Dead" />}
{avgPing > badPing && <FormattedMessage defaultMessage="Poor" />}
{avgPing > idealPing && avgPing < badPing && <FormattedMessage defaultMessage="Good" />}
{avgPing < idealPing && <FormattedMessage defaultMessage="Great" />}
</div>
);
}

View File

@ -0,0 +1,43 @@
import { sanitizeRelayUrl, unixNow } from "@snort/shared";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import { findTag } from "@/Utils";
import { Day, MonitorRelays } from "@/Utils/Const";
import UptimeLabel from "./uptime-label";
export default function RelayUptime({ url }: { url: string }) {
const sub = useMemo(() => {
const u = sanitizeRelayUrl(url);
const rb = new RequestBuilder(`uptime`);
if (u) {
rb.withFilter()
.kinds([30_166 as EventKind])
.tag("d", [u])
.since(unixNow() - Day)
.relay(MonitorRelays);
}
return rb;
}, [url]);
const data = useRequestBuilder(sub);
const myData = data.filter(a => findTag(a, "d") === url);
const ping = myData.reduce(
(acc, v) => {
const read = findTag(v, "rtt-read");
if (read) {
acc.n += 1;
acc.total += Number(read);
}
return acc;
},
{
n: 0,
total: 0,
},
);
const avgPing = ping.total / ping.n;
return <UptimeLabel avgPing={avgPing} />;
}

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