263 Commits

Author SHA1 Message Date
ba62f0ef74 feat: link to nests from live streams header 2025-05-07 14:15:07 +01:00
fb844a5969 feat: nests chat / speak 2025-05-07 13:52:03 +01:00
79e2d33e06 feat: filter follow sets 2025-05-07 10:50:56 +01:00
4e5feede23 refactor: replace nip96 with blossom
feat: blossom fallback image loader
2025-05-06 17:24:41 +01:00
d4115e9073 fix: yarn lock 2025-05-06 15:15:12 +01:00
d22ce56ebc feat: follow sets page 2025-05-06 15:09:43 +01:00
91c912a886 feat: media root tab 2025-05-06 13:54:18 +01:00
07474a836e chore: remove lnc / cashu wallets 2025-05-06 12:50:04 +01:00
75324e4862 chore: fix build 2025-05-06 12:46:07 +01:00
948337228e fix: add ws:// relay
closes https://github.com/v0l/snort/issues/600
2025-05-06 12:38:57 +01:00
e4446962ac feat: improve messages:
1. WoT filter
2. React to read status
2025-05-06 12:34:42 +01:00
d442166846 chore: remove nip28 support 2025-05-06 11:35:08 +01:00
fe4e17227e fix: follow by tab 2025-04-30 18:51:17 +01:00
d30aca46e8 chore: lang 2025-04-30 12:50:48 +01:00
18f60681bd chore: update kind name list 2025-04-30 12:44:36 +01:00
3d778f7ec7 fix: handle invalid client tag 2025-04-30 12:22:18 +01:00
992d7f19be feat: application handler note render 2025-04-30 12:15:18 +01:00
1820e7426d fix: test client tag format 2025-04-30 11:22:22 +01:00
e6ca368134 feat: client tags 2025-04-30 11:14:02 +01:00
f028de7c04 chore: cleanup login state 2025-03-12 11:26:46 +00:00
0584089d92 fix: about page 2025-03-12 10:09:45 +00:00
bc1d0512f8 chore: add summoner001 2025-03-12 09:52:09 +00:00
aaa4b0de97 feat: support rendering kind 20,21,22
feat: reply to non-text-note as kind 1111
2025-02-27 15:29:28 +00:00
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 1f0f45e3f9.
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
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
782135f90d chore: Update translations 2024-12-10 15:53:59 +00:00
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
497e12c644 add sql AND tags filter 2024-11-14 13:14:11 +01:00
7002647ffc emulate code style 2024-11-13 13:39:21 +01:00
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
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
458 changed files with 23175 additions and 10398 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

3
.gitignore vendored
View File

@ -12,4 +12,5 @@ dist/
*.log
.DS_Store
.pnp*
docs/
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

@ -25,4 +25,5 @@ yarn-error.log*
.idea
dist/
dev-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": {
@ -45,10 +44,7 @@
"wss://relay.nostr.band/": { "read": true, "write": true },
"wss://relay.damus.io/": { "read": true, "write": true }
},
"chatChannels": [
{ "type": "telegram", "value": "https://t.me/irismessenger" },
{ "type": "nip28", "value": "23286a4602ada10cc10200553bff62a110e8dc0eacddf73277395a89ddf26a09" }
],
"chatChannels": [{ "type": "telegram", "value": "https://t.me/irismessenger" }],
"alby": {
"clientId": "5rYcHDrlDb",
"clientSecret": "QAI3QmgiaPH3BfTMzzFd"

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;
@ -102,7 +101,7 @@ declare const CONFIG: {
// public chat channels for site
chatChannels?: Array<{
type: "nip28" | "telegram";
type: "telegram";
value: string;
}>;
};

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",
@ -123,7 +131,7 @@
"vite": "^5.2.8",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-pwa": "^0.19.2",
"vite-plugin-version-mark": "^0.0.10",
"vite-plugin-version-mark": "^0.1.4",
"vitest": "^0.34.6"
}
}

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;
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;
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}
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
<iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
height={isSongLink ? 175 : 450}
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}
loading="lazy"
/>
);
};

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

@ -1,6 +1,6 @@
import { IMeta } from "@snort/system";
import classNames from "classnames";
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
import React, { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
import { useInView } from "react-intersection-observer";
import { ProxyImg } from "@/Components/ProxyImg";
@ -45,6 +45,8 @@ const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
return style;
}, [imageRef?.current, meta]);
const [alternatives, setAlternatives] = useState<Array<string>>(meta?.fallback ?? []);
const [currentUrl, setCurrentUrl] = useState<string>(url);
return (
<div
className={classNames("flex items-center -mx-4 md:mx-0 my-2", {
@ -52,8 +54,8 @@ const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
"cursor-pointer": onMediaClick,
})}>
<ProxyImg
key={url}
src={url}
key={currentUrl}
src={currentUrl}
size={size}
sha256={meta?.sha256}
onClick={onMediaClick}
@ -62,6 +64,14 @@ const ImageElement = ({ url, meta, onMediaClick, size }: ImageElementProps) => {
})}
style={style}
ref={imageRef}
onError={() => {
const next = alternatives.at(0);
if (next) {
console.warn("IMG FALLBACK", "Failed to load url, trying next: ", next);
setAlternatives(z => z.filter(y => y !== next));
setCurrentUrl(next);
}
}}
/>
</div>
);

View File

@ -26,7 +26,7 @@ export default function Mention({ link }: { link: NostrLink }) {
return (
<span className="highlight" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<ProfileLink pubkey={link.id} link={link} user={profile} onClick={e => e.stopPropagation()}>
<ProfileLink pubkey={link.id} user={profile} onClick={e => e.stopPropagation()}>
@<DisplayName user={profile} pubkey={link.id} />
</ProfileLink>
{isHovering && <ProfileCard pubkey={link.id} user={profile} show={true} />}

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`}
/>
<a href={link} target="_blank" rel="noreferrer">
{link}
</a>
</>
<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"
/>
);
};

View File

@ -12,6 +12,8 @@ import usePreferences from "@/Hooks/usePreferences";
import { dedupe, findTag, getDisplayName, hexToBech32 } from "@/Utils";
import { useWallet } from "@/Wallet";
import { ProxyImg } from "../ProxyImg";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
const wallet = useWallet();
const defaultZapAmount = usePreferences(s => s.defaultZapAmount);
@ -62,25 +64,33 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
}
}
const picture = findTag(ev, "image");
return (
<FollowListBase
pubkeys={ids}
showAbout={true}
className={className}
title={findTag(ev, "title") ?? findTag(ev, "d")}
actions={
<>
<AsyncButton className="mr5 secondary" onClick={() => zapAll()}>
<FormattedMessage
defaultMessage="Zap all {n} sats"
id="IVbtTS"
values={{
n: <FormattedNumber value={defaultZapAmount * ids.length} />,
}}
/>
</AsyncButton>
</>
}
/>
<>
{picture && <ProxyImg src={picture} className="br max-h-44 w-full object-cover mb-4" />}
<FollowListBase
pubkeys={ids}
className={className}
title={findTag(ev, "title") ?? findTag(ev, "d")}
actions={
<>
<AsyncButton className="mr5 secondary" onClick={() => zapAll()}>
<FormattedMessage
defaultMessage="Zap all {n} sats"
id="IVbtTS"
values={{
n: <FormattedNumber value={defaultZapAmount * ids.length} />,
}}
/>
</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}`}
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
<iframe
width="100%"
height="166"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}
loading="lazy"
/>
);
};

View File

@ -2,22 +2,15 @@ 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%"
height="352"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
</>
<iframe
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="352"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
);
};

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

@ -0,0 +1,39 @@
import { mapEventToProfile, TaggedNostrEvent } from "@snort/system";
import { FormattedMessage } from "react-intl";
import KindName from "../kind-name";
import Avatar from "../User/Avatar";
import DisplayName from "../User/DisplayName";
import ProfileImage from "../User/ProfileImage";
export function ApplicationHandler({ ev }: { ev: TaggedNostrEvent }) {
const profile = mapEventToProfile(ev);
const kinds = ev.tags
.filter(a => a[0] === "k")
.map(a => Number(a[1]))
.sort((a, b) => a - b);
return (
<div className="p flex gap-2 flex-col">
<div className="flex items-center gap-2 text-xl">
<Avatar user={profile} pubkey={""} size={120} />
<div className="flex flex-col gap-2">
<DisplayName user={profile} pubkey={""} />
<div className="text-sm flex items-center gap-2">
<div className="text-gray-light">
<FormattedMessage defaultMessage="Published by" />
</div>
<ProfileImage className="inline" pubkey={ev.pubkey} size={30} link="" />
</div>
</div>
</div>
<FormattedMessage defaultMessage="Supported Kinds:" />
<div className="flex flex-wrap">
{kinds.map(a => (
<div key={a} className="pill">
<KindName kind={a} />
</div>
))}
</div>
</div>
);
}

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,19 @@
/* eslint-disable max-lines */
import "./NoteCreator.css";
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
import { EventBuilder, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
import {
EventBuilder,
EventKind,
Nip94Tags,
nip94TagsToIMeta,
NostrLink,
NostrPrefix,
readNip94Tags,
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,14 +21,17 @@ 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";
@ -26,9 +40,7 @@ import { useNoteCreator } from "@/State/NoteCreator";
import { openFile, trackEvent } from "@/Utils";
import useFileUpload 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 +71,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();
@ -136,7 +149,6 @@ export function NoteCreator() {
extraTags ??= [];
extraTags.push(["content-warning", note.sensitive]);
}
const kind = note.pollOptions ? EventKind.Polls : EventKind.TextNote;
if (note.pollOptions) {
extraTags ??= [];
extraTags.push(...note.pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
@ -145,6 +157,35 @@ export function NoteCreator() {
extraTags ??= [];
extraTags.push(...note.hashTags.map(a => ["t", a.toLowerCase()]));
}
// attach 1 link and use other duplicates as fallback urls
for (const [, v] of Object.entries(note.attachments ?? {})) {
const at = v[0];
note.note += note.note.length > 0 ? `\n${at.url}` : at.url;
console.debug(at);
const n94 =
(at.nip94?.length ?? 0) > 0
? readNip94Tags(at.nip94!)
: ({
url: at.url,
hash: at.sha256,
size: at.size,
mimeType: at.type,
} as Nip94Tags);
// attach fallbacks
n94.fallback ??= [];
n94.fallback.push(
...v
.slice(1)
.filter(a => a.url)
.map(a => a.url!),
);
extraTags ??= [];
extraTags.push(nip94TagsToIMeta(n94));
}
// add quote repost
if (note.quote) {
if (!note.note.endsWith("\n")) {
@ -164,7 +205,9 @@ export function NoteCreator() {
const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t));
note.extraTags?.forEach(t => eb.tag(t));
eb.kind(kind);
if (note.pollOptions) {
eb.kind(EventKind.Polls);
}
return eb;
};
const ev = note.replyTo
@ -211,19 +254,16 @@ export function NoteCreator() {
}
trackEvent("PostNote", props);
const events = (note.otherEvents ?? []).concat(ev);
events.map(a =>
sendEventToRelays(system, a, note.selectedCustomRelays, r => {
if (CONFIG.noteCreatorToast) {
r.forEach(rr => {
Toastore.push({
element: c => <OkResponseRow rsp={rr} close={c} />,
expire: unixNow() + (rr.ok ? 5 : 55555),
});
sendEventToRelays(system, ev, note.selectedCustomRelays, r => {
if (CONFIG.noteCreatorToast) {
r.forEach(rr => {
Toastore.push({
element: c => <OkResponseRow rsp={rr} close={c} />,
expire: unixNow() + (rr.ok ? 5 : 55555),
});
}
}),
);
});
}
});
note.update(n => n.reset());
localStorage.removeItem("msgDraft");
}
@ -248,33 +288,13 @@ export function NoteCreator() {
async function uploadFile(file: File) {
try {
if (file) {
const rx = await uploader.upload(file, file.name);
if (file && uploader) {
const rx = await uploader.upload(file);
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];
} 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}`);
}
v.extraTags.push(imeta);
}
} else if (rx?.error) {
v.error = rx.error;
if (rx.url) {
v.attachments ??= {};
v.attachments[rx.sha256] ??= [];
v.attachments[rx.sha256].push(rx);
}
});
}
@ -330,12 +350,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 +389,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 +442,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 +474,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 +490,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 +504,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 +529,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 +539,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,18 +586,9 @@ 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" />
)}
</AsyncButton>
</div>
<AsyncButton onClick={onSubmit} className="primary">
{note.replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
</AsyncButton>
</div>
);
}
@ -617,7 +636,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 +647,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 +657,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 +684,71 @@ export function NoteCreator() {
/>
{renderPollOptions()}
</div>
</>
</div>
)}
{Object.entries(note.attachments ?? {}).length > 0 && !note.preview && (
<div className="flex gap-2 flex-wrap">
{Object.entries(note.attachments ?? {}).map(([k, v]) => (
<div key={k} className="relative">
<img className="object-cover w-[80px] h-[80px] !mt-0 rounded-lg" src={v[0].url} />
<Icon
name="x"
className="absolute -top-[0.25rem] -right-[0.25rem] bg-gray rounded-full cursor-pointer"
onClick={() =>
note.update(n => {
if (n.attachments?.[k]) {
delete n.attachments[k];
}
return n;
})
}
/>
</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 => {
for (const x of files) {
n.attachments ??= {};
n.attachments[x.sha256] ??= [];
n.attachments[x.sha256].push(x);
}
n.filePicker = "hidden";
});
}}
cols={note.filePicker === "compact" ? 2 : 6}
/>
)}
</div>
</Flyout>
</>
);
}
@ -674,11 +761,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

@ -1,16 +1,3 @@
.note > .header .reply {
font-size: 13px;
color: var(--font-secondary-color);
}
.note > .header .reply a {
color: var(--highlight);
}
.note > .header .reply a:hover {
text-decoration-color: var(--highlight);
}
.note .header .info {
font-size: var(--font-size);
margin-left: 4px;
@ -57,8 +44,7 @@
margin-top: 16px;
}
.note > .header img:hover,
.note > .header .name > .reply:hover {
.note > .header img:hover {
cursor: pointer;
}

View File

@ -1,20 +1,20 @@
import "./EventComponent.css";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { EventKind, NostrEvent, parseIMeta, TaggedNostrEvent } from "@snort/system";
import { memo, ReactNode } from "react";
import PubkeyList from "@/Components/Embed/PubkeyList";
import ZapstrEmbed from "@/Components/Embed/ZapstrEmbed";
import ErrorBoundary from "@/Components/ErrorBoundary";
import { ApplicationHandler } from "@/Components/Event/Application";
import { LongFormText } from "@/Components/Event/LongFormText";
import { NostrFileElement } from "@/Components/Event/NostrFileHeader";
import { Note } from "@/Components/Event/Note/Note";
import NoteReaction from "@/Components/Event/NoteReaction";
import { ZapGoal } from "@/Components/Event/ZapGoal";
import { LiveEvent } from "@/Components/LiveStream/LiveEvent";
import ProfilePreview from "@/Components/User/ProfilePreview";
import { LongFormText } from "./LongFormText";
import { Note } from "./Note/Note";
export interface NotePropsOptions {
isRoot?: boolean;
showHeader?: boolean;
@ -62,6 +62,7 @@ export default memo(function EventComponent(props: NoteProps) {
case EventKind.ZapstrTrack:
content = <ZapstrEmbed ev={ev} />;
break;
case EventKind.StarterPackSet:
case EventKind.FollowSet:
case EventKind.ContactList:
content = <PubkeyList ev={ev} className={className} />;
@ -75,6 +76,25 @@ export default memo(function EventComponent(props: NoteProps) {
case 9041: // Assuming 9041 is a valid EventKind
content = <ZapGoal ev={ev} />;
break;
case EventKind.ApplicationHandler: {
content = <ApplicationHandler ev={ev} />;
break;
}
case EventKind.Photo:
case EventKind.Video:
case EventKind.ShortVideo: {
// append media to note as if kind1 post
const media = parseIMeta(ev.tags);
// Sometimes we cann call this twice so check the URL's are not already
// in the content
const urls = Object.entries(media ?? {}).map(([k]) => k);
if (!urls.every(u => ev.content.includes(u))) {
const newContent = ev.content + " " + urls.join("\n");
props.data.content = newContent;
}
content = <Note {...props} />;
break;
}
case EventKind.LongFormTextNote:
content = (
<LongFormText

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

@ -0,0 +1,21 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
export function ClientTag({ ev }: { ev: TaggedNostrEvent }) {
const tag = ev.tags.find(a => a[0] === "client");
if (!tag) return;
const link = tag[2] && tag[2].includes(":") ? NostrLink.tryFromTag(["a", tag[2]]) : undefined;
return (
<span className="text-xs text-gray-light">
{" "}
<FormattedMessage
defaultMessage="via {client}"
description="via {client name} tag"
values={{
client: link ? <Link to={`/${link.encode()}`}>{tag[1]}</Link> : tag[1],
}}
/>
</span>
);
}

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 = {
@ -33,17 +32,24 @@ const defaultOptions = {
showContextMenu: true,
};
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
const canRenderAsTextNote = [
EventKind.TextNote,
EventKind.Polls,
EventKind.Photo,
EventKind.Video,
EventKind.ShortVideo,
EventKind.Comment,
];
const translationCache = new LRUCache<string, NoteTranslation>({ maxSize: 300 });
export function Note(props: NoteProps) {
const { data: ev, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className ?? "");
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { ref, 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 +62,9 @@ export function Note(props: NoteProps) {
let timeout: ReturnType<typeof setTimeout>;
if (setSeenAtInView) {
timeout = setTimeout(() => {
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
if (Relay instanceof WorkerRelayInterface) {
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
}
}, 1000);
}
return () => clearTimeout(timeout);
@ -99,7 +107,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 +118,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 +140,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 +177,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 +185,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,48 +55,42 @@ 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}>
{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>
<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
}
/>
</div>
),
)}
{tab.value === 2 && sortedReposts.map(ev => renderReactionItem(ev, "repost", "text-repost", 16))}
{tab.value === 3 && dislikes.map(ev => renderReactionItem(ev, "dislike"))}
<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 && (
<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" }) : undefined}
/>
</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

@ -9,11 +9,13 @@ import DisplayName from "@/Components/User/DisplayName";
import { ProfileLink } from "@/Components/User/ProfileLink";
import { hexToBech32 } from "@/Utils";
import { ClientTag } from "./ClientTag";
export default function ReplyTag({ ev }: { ev: TaggedNostrEvent }) {
const { formatMessage } = useIntl();
const thread = EventExt.extractThread(ev);
if (thread === undefined) {
return undefined;
return <ClientTag ev={ev} />;
}
const maxMentions = 2;
@ -33,7 +35,7 @@ export default function ReplyTag({ ev }: { ev: TaggedNostrEvent }) {
name: u?.name ?? shortNpub,
link: (
<ProfileLink pubkey={pk} user={u}>
<DisplayName pubkey={pk} user={u} />{" "}
<DisplayName pubkey={pk} user={u} className="text-highlight" />
</ProfileLink>
),
});
@ -53,7 +55,7 @@ export default function ReplyTag({ ev }: { ev: TaggedNostrEvent }) {
const others = mentions.length > maxMentions ? formatMessage(messages.Others, { n: othersLength }) : "";
const link = replyLink?.encode(CONFIG.eventLinkPrefix);
return (
<div className="reply">
<small className="text-xs">
re:&nbsp;
{(mentions?.length ?? 0) > 0 ? (
<>
@ -62,6 +64,7 @@ export default function ReplyTag({ ev }: { ev: TaggedNostrEvent }) {
) : (
replyLink && <Link to={`/${link}`}>{link?.substring(0, 12)}</Link>
)}
</div>
<ClientTag ev={ev} />
</small>
);
}

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);
return (
note && (
if (note) {
return (
<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">
<AvatarGroup ids={sortedZappers} onClick={() => {}} />
{zaps.length > 3 && <div className="hidden md:flex -ml-2">+{zaps.length - 3}</div>}
</div>
</div>
<div className="flex items-center cursor-pointer" onClick={myOnClick}>
<AvatarGroup ids={sortedZappers} onClick={() => {}} />
{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,18 +57,7 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Followed by friends" id="voxBKC" />
</>
),
},
{
tab: "suggested",
path: `${base}/suggested`,
show: Boolean(pubKey),
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Suggested Follows" id="C8HhVE" />
<FormattedMessage defaultMessage="Followed by friends" />
</>
),
},
@ -79,18 +68,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 +79,29 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
element: (
<>
<Icon name="hash" />
<FormattedMessage defaultMessage="Topics" id="kc79d3" />
<FormattedMessage defaultMessage="Topics" />
</>
),
},
{
tab: "media",
path: `${base}/media`,
show: true,
element: (
<>
<Icon name="camera-plus" />
<FormattedMessage defaultMessage="Media" />
</>
),
},
{
tab: "follow-sets",
path: `${base}/follow-sets`,
show: true,
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Follow Sets" />
</>
),
},

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,29 +1,34 @@
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 {
id?: string;
postsOnly: boolean;
liveStreams?: boolean;
noteFilter?: (ev: NostrEvent) => boolean;
noteRenderer?: (ev: NostrEvent) => ReactNode;
noteOnClick?: (ev: NostrEvent) => void;
displayAs?: DisplayAs;
kinds?: Array<EventKind>;
showDisplayAsSelector?: boolean;
firstChunkSize?: number;
windowSize?: number;
}
/**
* A list of notes by "subject"
* A list of notes by your follows
*/
const TimelineFollows = (props: TimelineFollowsProps) => {
const login = useLogin(s => ({
@ -33,81 +38,49 @@ 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);
}
},
}) as TimelineSubject,
[login.publicKey, followList, login.tags],
);
const feed = useTimelineFeed(subject, { method: "TIME_RANGE", now: openedAt } as TimelineFeedOptions);
const { chunks, showMore } = useTimelineChunks({
now: openedAt,
window: props.windowSize,
firstChunkSize: props.firstChunkSize ?? Hour * 2,
});
// TODO allow reposts:
const postsOnly = useCallback(
(a: NostrEvent) => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true),
[props.postsOnly],
);
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);
const builder = useCallback(
(rb: RequestBuilder) => {
rb.withFilter()
.authors(followList)
.kinds(props.kinds ?? [EventKind.TextNote, EventKind.Repost, EventKind.Polls]);
},
[postsOnly, props.noteFilter, isFollowing],
[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);
}
}
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],
);
return (
<>
<DisplayAsSelector
show={props.showDisplayAsSelector}
activeSelection={displayAs}
onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)}
/>
<TimelineRenderer
frags={[{ events: mainFeed, refTime: 0 }]}
latest={latestAuthors}
showLatest={t => onShowLatest(t)}
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()}
/>
{(props.showDisplayAsSelector ?? true) && (
<DisplayAsSelector activeSelection={displayAs} onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)} />
)}
{chunks.map(c => (
<TimelineChunk
key={c.until}
id={`follows${props.id ? `:${props.id}` : ""}`}
chunk={c}
builder={builder}
noteFilter={filterEvents}
noteOnClick={props.noteOnClick}
noteRenderer={props.noteRenderer}
displayAs={displayAs}
/>
))}
<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,24 @@
<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>
<symbol id="mic" viewBox="0 0 20 20" fill="none">
<path d="M10 13.4375C11.0771 13.4363 12.1097 13.0078 12.8713 12.2463C13.6328 11.4847 14.0613 10.4521 14.0625 9.375V5C14.0625 3.92256 13.6345 2.88925 12.8726 2.12738C12.1108 1.36551 11.0774 0.9375 10 0.9375C8.92256 0.9375 7.88925 1.36551 7.12738 2.12738C6.36551 2.88925 5.9375 3.92256 5.9375 5V9.375C5.93874 10.4521 6.36715 11.4847 7.12875 12.2463C7.89035 13.0078 8.92294 13.4363 10 13.4375ZM7.8125 5C7.8125 4.41984 8.04297 3.86344 8.4532 3.4532C8.86344 3.04297 9.41984 2.8125 10 2.8125C10.5802 2.8125 11.1366 3.04297 11.5468 3.4532C11.957 3.86344 12.1875 4.41984 12.1875 5V9.375C12.1875 9.95516 11.957 10.5116 11.5468 10.9218C11.1366 11.332 10.5802 11.5625 10 11.5625C9.41984 11.5625 8.86344 11.332 8.4532 10.9218C8.04297 10.5116 7.8125 9.95516 7.8125 9.375V5ZM10.9375 16.5016V18.125C10.9375 18.3736 10.8387 18.6121 10.6629 18.7879C10.4871 18.9637 10.2486 19.0625 10 19.0625C9.75136 19.0625 9.5129 18.9637 9.33709 18.7879C9.16127 18.6121 9.0625 18.3736 9.0625 18.125V16.5016C7.3344 16.2719 5.74838 15.4229 4.59892 14.1122C3.44947 12.8016 2.81471 11.1183 2.8125 9.375C2.8125 9.12636 2.91127 8.8879 3.08709 8.71209C3.2629 8.53627 3.50136 8.4375 3.75 8.4375C3.99864 8.4375 4.2371 8.53627 4.41291 8.71209C4.58873 8.8879 4.6875 9.12636 4.6875 9.375C4.6875 10.784 5.24721 12.1352 6.2435 13.1315C7.23978 14.1278 8.59104 14.6875 10 14.6875C11.409 14.6875 12.7602 14.1278 13.7565 13.1315C14.7528 12.1352 15.3125 10.784 15.3125 9.375C15.3125 9.12636 15.4113 8.8879 15.5871 8.71209C15.7629 8.53627 16.0014 8.4375 16.25 8.4375C16.4986 8.4375 16.7371 8.53627 16.9129 8.71209C17.0887 8.8879 17.1875 9.12636 17.1875 9.375C17.1853 11.1183 16.5505 12.8016 15.4011 14.1122C14.2516 15.4229 12.6656 16.2719 10.9375 16.5016Z" fill="currentColor"/>
</symbol>
<symbol id="mic-off" viewBox="0 0 24 30" fill="none">
<path d="M3.10989 2.99125C2.97816 2.84276 2.81827 2.72189 2.63949 2.63565C2.46071 2.54942 2.26658 2.49952 2.06837 2.48885C1.87016 2.47819 1.67181 2.50697 1.48481 2.57353C1.2978 2.6401 1.12587 2.74311 0.978971 2.87661C0.832073 3.01011 0.713132 3.17143 0.629043 3.35124C0.544953 3.53104 0.497387 3.72575 0.489101 3.92407C0.480814 4.1224 0.511973 4.32039 0.580772 4.50658C0.64957 4.69278 0.754638 4.86346 0.889888 5.00875L5.49989 10.08V14C5.49894 15.0718 5.76306 16.1273 6.26872 17.0723C6.77439 18.0174 7.50591 18.8227 8.39814 19.4166C9.29038 20.0105 10.3156 20.3746 11.3826 20.4764C12.4496 20.5782 13.5252 20.4145 14.5136 20L15.9211 21.5487C14.7105 22.1791 13.3648 22.5055 11.9999 22.5C9.74626 22.4977 7.5856 21.6014 5.99204 20.0078C4.39848 18.4143 3.5022 16.2536 3.49989 14C3.49989 13.6022 3.34185 13.2206 3.06055 12.9393C2.77924 12.658 2.39771 12.5 1.99989 12.5C1.60206 12.5 1.22053 12.658 0.939228 12.9393C0.657924 13.2206 0.499888 13.6022 0.499888 14C0.503423 16.7893 1.51905 19.4825 3.35817 21.5795C5.19729 23.6766 7.73494 25.035 10.4999 25.4025V28C10.4999 28.3978 10.6579 28.7794 10.9392 29.0607C11.2205 29.342 11.6021 29.5 11.9999 29.5C12.3977 29.5 12.7792 29.342 13.0605 29.0607C13.3419 28.7794 13.4999 28.3978 13.4999 28V25.4037C15.0922 25.2004 16.6228 24.66 17.9899 23.8187L20.8899 27.0087C21.1588 27.2977 21.5308 27.4689 21.9252 27.4854C22.3195 27.5019 22.7045 27.3622 22.9966 27.0968C23.2887 26.8313 23.4644 26.4614 23.4856 26.0673C23.5068 25.6731 23.3718 25.2865 23.1099 24.9912L3.10989 2.99125ZM11.9999 17.5C11.0716 17.5 10.1814 17.1313 9.52501 16.4749C8.86864 15.8185 8.49989 14.9283 8.49989 14V13.375L12.2374 17.4862C12.1586 17.5 12.0799 17.5 11.9999 17.5ZM7.33364 4.65875C7.18979 4.52338 7.0741 4.36094 6.9932 4.18075C6.9123 4.00055 6.86778 3.80616 6.86221 3.60871C6.85663 3.41127 6.89011 3.21467 6.96071 3.0302C7.03132 2.84572 7.13766 2.67701 7.27364 2.53375C8.16735 1.58718 9.3247 0.930779 10.5958 0.649565C11.8668 0.368351 13.1931 0.475282 14.4027 0.956509C15.6123 1.43774 16.6495 2.27108 17.38 3.34861C18.1105 4.42614 18.5007 5.69819 18.4999 7V13.0675C18.4999 13.4653 18.3419 13.8469 18.0605 14.1282C17.7792 14.4095 17.3977 14.5675 16.9999 14.5675C16.6021 14.5675 16.2205 14.4095 15.9392 14.1282C15.6579 13.8469 15.4999 13.4653 15.4999 13.0675V7C15.4998 6.29921 15.2894 5.61457 14.8959 5.03471C14.5024 4.45486 13.9438 4.00649 13.2926 3.74766C12.6413 3.48884 11.9274 3.43147 11.2432 3.58298C10.5589 3.7345 9.93597 4.08792 9.45489 4.5975C9.31967 4.74087 9.15752 4.85619 8.97771 4.93687C8.7979 5.01755 8.60395 5.062 8.40696 5.06769C8.20996 5.07337 8.01377 5.04019 7.82961 4.97002C7.64544 4.89985 7.47691 4.79408 7.33364 4.65875ZM19.8749 17.1975C20.2886 16.1823 20.5009 15.0963 20.4999 14C20.4999 13.6022 20.6579 13.2206 20.9392 12.9393C21.2205 12.658 21.6021 12.5 21.9999 12.5C22.3977 12.5 22.7792 12.658 23.0605 12.9393C23.3419 13.2206 23.4999 13.6022 23.4999 14C23.5023 15.4831 23.2161 16.9524 22.6574 18.3262C22.4983 18.6801 22.2082 18.9585 21.8482 19.1031C21.4881 19.2476 21.0861 19.247 20.7264 19.1014C20.3668 18.9558 20.0776 18.6766 19.9195 18.3222C19.7614 17.9679 19.7468 17.5661 19.8786 17.2012L19.8749 17.1975Z" fill="currentColor"/>
</symbol>
<symbol id="hand" viewBox="0 0 24 30" fill="none">
<path d="M19.5 9.49996C19.1627 9.4993 18.8267 9.5413 18.5 9.62496V6.49996C18.5006 5.85364 18.3445 5.2168 18.0452 4.64397C17.7458 4.07114 17.3121 3.57937 16.7812 3.21077C16.2503 2.84216 15.638 2.6077 14.9967 2.52745C14.3553 2.4472 13.7041 2.52355 13.0988 2.74996C12.7035 1.9353 12.0435 1.27886 11.2267 0.887983C10.4099 0.497104 9.48469 0.394917 8.60229 0.598133C7.7199 0.80135 6.93256 1.29794 6.36903 2.00671C5.8055 2.71547 5.49913 3.59447 5.5 4.49996V4.62496C4.90875 4.4723 4.2904 4.45704 3.69233 4.58034C3.09427 4.70363 2.53237 4.96222 2.04971 5.33629C1.56705 5.71035 1.17644 6.18995 0.907819 6.73833C0.639196 7.28672 0.499693 7.88932 0.500001 8.49996V18C0.500001 21.05 1.7116 23.975 3.86827 26.1317C6.02494 28.2884 8.95001 29.5 12 29.5C15.05 29.5 17.9751 28.2884 20.1317 26.1317C22.2884 23.975 23.5 21.05 23.5 18V13.5C23.5 12.4391 23.0786 11.4217 22.3284 10.6715C21.5783 9.92139 20.5609 9.49996 19.5 9.49996ZM20.5 18C20.5 20.2543 19.6045 22.4163 18.0104 24.0104C16.4163 25.6044 14.2543 26.5 12 26.5C9.74566 26.5 7.58365 25.6044 5.98959 24.0104C4.39553 22.4163 3.5 20.2543 3.5 18V8.49996C3.5 8.23475 3.60536 7.98039 3.79289 7.79286C3.98043 7.60532 4.23478 7.49996 4.5 7.49996C4.76522 7.49996 5.01957 7.60532 5.20711 7.79286C5.39464 7.98039 5.5 8.23475 5.5 8.49996V14C5.5 14.3978 5.65804 14.7793 5.93934 15.0606C6.22064 15.3419 6.60218 15.5 7 15.5C7.39783 15.5 7.77936 15.3419 8.06066 15.0606C8.34197 14.7793 8.5 14.3978 8.5 14V4.49996C8.5 4.23475 8.60536 3.98039 8.79289 3.79286C8.98043 3.60532 9.23478 3.49996 9.5 3.49996C9.76522 3.49996 10.0196 3.60532 10.2071 3.79286C10.3946 3.98039 10.5 4.23475 10.5 4.49996V13C10.5 13.3978 10.658 13.7793 10.9393 14.0606C11.2206 14.3419 11.6022 14.5 12 14.5C12.3978 14.5 12.7794 14.3419 13.0607 14.0606C13.342 13.7793 13.5 13.3978 13.5 13V6.49996C13.5 6.23475 13.6054 5.98039 13.7929 5.79286C13.9804 5.60532 14.2348 5.49996 14.5 5.49996C14.7652 5.49996 15.0196 5.60532 15.2071 5.79286C15.3946 5.98039 15.5 6.23475 15.5 6.49996V14.675C14.0773 15.0144 12.8103 15.823 11.9033 16.9705C10.9962 18.1179 10.5019 19.5373 10.5 21C10.5 21.3978 10.658 21.7793 10.9393 22.0606C11.2206 22.3419 11.6022 22.5 12 22.5C12.3978 22.5 12.7794 22.3419 13.0607 22.0606C13.342 21.7793 13.5 21.3978 13.5 21C13.5 20.0717 13.8687 19.1815 14.5251 18.5251C15.1815 17.8687 16.0717 17.5 17 17.5C17.3978 17.5 17.7794 17.3419 18.0607 17.0606C18.342 16.7793 18.5 16.3978 18.5 16V13.5C18.5 13.2347 18.6054 12.9804 18.7929 12.7929C18.9804 12.6053 19.2348 12.5 19.5 12.5C19.7652 12.5 20.0196 12.6053 20.2071 12.7929C20.3946 12.9804 20.5 13.2347 20.5 13.5V18Z" fill="currentColor"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 139 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,111 @@
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";
import { NestsParticipants } from "./nests-participants";
const streams = useRequestBuilder(sub);
export function LiveStreams() {
const streams = useLiveStreams();
if (streams.length === 0) return null;
return (
<div className="stream-list">
{streams.map(v => (
<LiveStreamEvent ev={v} key={`${v.kind}:${v.pubkey}:${findTag(v, "d")}`} />
))}
<div className="flex mx-2 gap-4 overflow-x-auto sm-hide-scrollbar">
{streams.map(v => {
const k = `${v.kind}:${v.pubkey}:${findTag(v, "d")}`;
const isVideoStream = v.tags.some(a => a[0] === "streaming" && a[1].includes(".m3u8"));
if (isVideoStream) {
return <LiveStreamEvent ev={v} key={k} className="h-[80px]" />;
}
const isNests = v.tags.some(a => a[0] === "streaming" && a[1].startsWith("wss+livekit://"));
if (isNests) {
return <AudioRoom ev={v} key={k} 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">
<div
style={
{
"--img": `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>
<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={
{
backgroundImage: `url(${imageProxy})`,
} as CSSProperties
}></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 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>
);
}
export function AudioRoom({ ev, className }: { ev: NostrEvent; className?: string }) {
const { proxy } = useImgProxy();
const title = findTag(ev, "title");
const image = findTag(ev, "image");
const link = NostrLink.fromEvent(ev).encode();
const imageProxy = proxy(image ?? "");
return (
<Link className={classNames("flex gap-2", className)} to={`/${link}`}>
<div className="relative aspect-video">
<div
className="absolute h-full w-full bg-center bg-cover bg-gray-ultradark rounded-lg flex items-end justify-center"
style={
{
backgroundImage: `url(${imageProxy})`,
} as CSSProperties
}>
<div className="flex items-center gap-1">
<NestsParticipants ev={ev} />
</div>
</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>
</Link>
);

View File

@ -0,0 +1,77 @@
import { useEffect, useRef } from "react";
export default function VuBar({
track,
full,
width,
height,
className,
}: {
track?: MediaStreamTrack;
full?: boolean;
height?: number;
width?: number;
className?: string;
}) {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (ref && track) {
const audioContext = new AudioContext();
const trackClone = track;
const mediaStreamSource = audioContext.createMediaStreamSource(new MediaStream([trackClone]));
const analyser = audioContext.createAnalyser();
const minVU = -60;
const maxVU = 0;
const minFreq = 50;
const maxFreq = 7_000;
analyser.minDecibels = -100;
analyser.maxDecibels = 0;
analyser.smoothingTimeConstant = 0.4;
analyser.fftSize = 1024;
mediaStreamSource.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const filteredAudio = (i: Uint8Array) => {
const binFreq = audioContext.sampleRate / 2 / dataArray.length;
return i.subarray(minFreq / binFreq, maxFreq / binFreq);
};
const peakVolume = (data: Uint8Array) => {
const max = data.reduce((acc, v) => (v > acc ? v : acc), 0);
return (maxVU - minVU) * (max / 256) + minVU;
};
const canvas = ref.current!;
const ctx = canvas.getContext("2d")!;
const t = setInterval(() => {
analyser.getByteFrequencyData(dataArray);
const data = filteredAudio(dataArray);
const vol = peakVolume(data);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (full) {
ctx.fillStyle = "#00FF00";
for (let x = 0; x < data.length; x++) {
const bx = data[x];
const h = canvas.height / data.length;
ctx.fillRect(0, x * h, (bx / 255) * canvas.width, h);
}
}
const barLen = ((vol - minVU) / (maxVU - minVU)) * canvas.height;
ctx.fillStyle = "#fff";
ctx.fillRect(0, canvas.height - barLen, canvas.width, barLen);
}, 50);
return () => {
clearInterval(t);
audioContext.close();
};
}
}, [ref, track, full]);
return <canvas ref={ref} width={width ?? 200} height={height ?? 10} className={className}></canvas>;
}

View File

@ -0,0 +1,313 @@
/* eslint-disable max-lines */
import {
LiveKitRoom as LiveKitRoomContext,
RoomAudioRenderer,
useEnsureRoom,
useParticipantPermissions,
useParticipants,
} from "@livekit/components-react";
import { unixNow } from "@snort/shared";
import { EventKind, EventPublisher, NostrLink, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder, useUserProfile } from "@snort/system-react";
import classNames from "classnames";
import { LocalParticipant, LocalTrackPublication, RemoteParticipant, RoomEvent, Track } from "livekit-client";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import Text from "@/Components/Text/Text";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { extractStreamInfo } from "@/Utils/stream";
import AsyncButton from "../Button/AsyncButton";
import IconButton from "../Button/IconButton";
import { ProxyImg } from "../ProxyImg";
import Avatar from "../User/Avatar";
import DisplayName from "../User/DisplayName";
import ProfileImage from "../User/ProfileImage";
import { NestsParticipants } from "./nests-participants";
import VuBar from "./VU";
enum RoomTab {
Participants,
Chat,
}
export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent; canJoin?: boolean }) {
const { stream, service, id } = extractStreamInfo(ev);
const { publisher, system } = useEventPublisher();
const [join, setJoin] = useState(false);
const [token, setToken] = useState<string>();
const [tab, setTab] = useState(RoomTab.Participants);
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 };
}
}
async function publishPresence(publisher: EventPublisher, system: SystemInterface) {
const e = await publisher.generic(eb => {
const aTag = NostrLink.fromEvent(ev).toEventTag();
return eb
.kind(10_312 as EventKind)
.tag(aTag!)
.tag(["expiration", (unixNow() + 60).toString()]);
});
await system.BroadcastEvent(e);
}
useEffect(() => {
if (join && !token) {
getToken()
.then(t => setToken(t?.token))
.catch(console.error);
}
}, [join]);
useEffect(() => {
if (token && publisher && system) {
publishPresence(publisher, system);
const t = setInterval(async () => {
if (token) {
publishPresence(publisher, system);
}
}, 60_000);
return () => clearInterval(t);
}
}, [token, publisher, system]);
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} muted={false} />
<RoomBody ev={ev} tab={tab} onSelectTab={setTab} />
</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 h-full object-cover object-center" />
) : (
<div className="absolute bg-gray-dark w-full h-full" />
)}
<div className="absolute left-4 top-4 w-full flex justify-between pr-8">
<div className="text-2xl">{title}</div>
<div className="flex gap-2 items-center">
<NestsParticipants ev={ev} />
</div>
</div>
</div>
);
}
function RoomBody({ ev, tab, onSelectTab }: { ev: TaggedNostrEvent; tab: RoomTab; onSelectTab: (t: RoomTab) => void }) {
const participants = useParticipants({
updateOnlyOn: [
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
RoomEvent.ParticipantPermissionsChanged,
RoomEvent.TrackMuted,
RoomEvent.TrackPublished,
RoomEvent.TrackUnmuted,
RoomEvent.TrackUnmuted,
],
});
return (
<div className="p">
<RoomHeader ev={ev} />
<MyControls />
<div className="flex text-center items-center text-xl font-medium mb-2">
<div
className={classNames("flex-1 py-2 cursor-pointer select-none border-b border-transparent", {
"!border-highlight": tab === RoomTab.Participants,
})}
onClick={() => onSelectTab(RoomTab.Participants)}>
<FormattedMessage defaultMessage="Participants" />
</div>
<div
className={classNames("flex-1 py-2 cursor-pointer select-none border-b border-transparent", {
"!border-highlight": tab === RoomTab.Chat,
})}
onClick={() => onSelectTab(RoomTab.Chat)}>
<FormattedMessage defaultMessage="Chat" />
</div>
</div>
{tab === RoomTab.Participants && (
<div className="grid grid-cols-4">
{participants.map(a => (
<LiveKitUser p={a} key={a.identity} />
))}
</div>
)}
{tab === RoomTab.Chat && (
<>
<RoomChat ev={ev} />
<WriteChatMessage ev={ev} />
</>
)}
</div>
);
}
function MyControls() {
const room = useEnsureRoom();
const p = room.localParticipant;
const permissions = useParticipantPermissions({
participant: p,
});
useEffect(() => {
if (permissions && p instanceof LocalParticipant) {
const handler = (lt: LocalTrackPublication) => {
lt.mute();
};
p.on("localTrackPublished", handler);
if (permissions.canPublish && p.audioTrackPublications.size === 0) {
p.setMicrophoneEnabled(true);
}
return () => {
p.off("localTrackPublished", handler);
};
}
}, [p, permissions]);
const isMuted = p.getTrackPublication(Track.Source.Microphone)?.isMuted ?? true;
return (
<div className="flex gap-2 items-center mt-2">
{p.permissions?.canPublish && (
<IconButton
icon={{ name: !isMuted ? "mic" : "mic-off", size: 20 }}
onClick={async () => {
if (isMuted) {
await p.setMicrophoneEnabled(true);
} else {
await p.setMicrophoneEnabled(false);
}
}}
/>
)}
{/*<IconButton icon={{ name: "hand", size: 20 }} />*/}
</div>
);
}
function RoomChat({ ev }: { ev: TaggedNostrEvent }) {
const link = NostrLink.fromEvent(ev);
const sub = useMemo(() => {
const sub = new RequestBuilder(`room-chat:${link.tagKey}`);
sub.withOptions({ leaveOpen: true, replaceable: true });
sub.withFilter().replyToLink([link]).kinds([EventKind.LiveEventChat]).limit(100);
return sub;
}, [link.tagKey]);
const chat = useRequestBuilder(sub);
return (
<div className="flex h-[calc(100dvh-370px)] overflow-x-hidden overflow-y-scroll">
<div className="flex flex-col gap-1 flex-col-reverse w-full">
{chat
.sort((a, b) => b.created_at - a.created_at)
.map(e => (
<ChatMessage key={e.id} ev={e} />
))}
</div>
</div>
);
}
function ChatMessage({ ev }: { ev: TaggedNostrEvent }) {
return (
<div className="grid grid-cols-[auto_1fr] items-center gap-2">
<ProfileImage
pubkey={ev.pubkey}
size={20}
showBadges={false}
showFollowDistance={false}
className="text-highlight"
/>
<Text id={ev.id} content={ev.content} creator={ev.pubkey} tags={ev.tags} disableMedia={true} />
</div>
);
}
function WriteChatMessage({ ev }: { ev: TaggedNostrEvent }) {
const link = NostrLink.fromEvent(ev);
const [chat, setChat] = useState("");
const { publisher, system } = useEventPublisher();
const { formatMessage } = useIntl();
async function sendMessage() {
if (!publisher || !system || chat.length < 2) return;
const eChat = await publisher.generic(eb => eb.kind(EventKind.LiveEventChat).tag(link.toEventTag()!).content(chat));
await system.BroadcastEvent(eChat);
setChat("");
}
return (
<div className="flex gap-2 mt-2">
<input
type="text"
value={chat}
placeholder={formatMessage({ defaultMessage: "Write message" })}
onChange={e => setChat(e.target.value)}
className="grow"
onKeyDown={e => {
if (e.key === "Enter") {
sendMessage();
}
}}
/>
<IconButton icon={{ name: "arrow-right" }} onClick={sendMessage} />
</div>
);
}
function LiveKitUser({ p }: { p: RemoteParticipant | LocalParticipant }) {
const pubkey = p.identity.startsWith("guest-") ? "anon" : p.identity;
const profile = useUserProfile(pubkey);
const mic = p.getTrackPublication(Track.Source.Microphone);
return (
<div className="flex flex-col gap-2 items-center text-center">
<div className="relative w-[45px] h-[45px] flex items-center justify-center rounded-full overflow-hidden">
{mic?.audioTrack?.mediaStreamTrack && (
<VuBar track={mic.audioTrack?.mediaStreamTrack} className="absolute h-full w-full" />
)}
<Avatar pubkey={pubkey} user={profile} size={40} className="absolute" />
</div>
<div>
<DisplayName pubkey={pubkey} user={pubkey === "anon" ? { name: "Anon" } : profile} />
{p.permissions?.canPublish && <div className="text-highlight">Speaker</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { dedupe, unixNow } from "@snort/shared";
import { EventKind, NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import { AvatarGroup } from "../User/AvatarGroup";
export function NestsParticipants({ ev }: { ev: TaggedNostrEvent }) {
const link = NostrLink.fromEvent(ev);
const sub = useMemo(() => {
const sub = new RequestBuilder(`livekit-participants:${link.tagKey}`);
sub.withOptions({ leaveOpen: true });
sub
.withFilter()
.replyToLink([link])
.kinds([10_312 as EventKind])
.since(unixNow() - 600);
return sub;
}, [link.tagKey]);
const presense = useRequestBuilder(sub);
const filteredPresence = presense.filter(ev => ev.created_at > unixNow() - 600);
return <AvatarGroup ids={dedupe(filteredPresence.map(a => a.pubkey)).slice(0, 5)} size={32} />;
}

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,65 +1,75 @@
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() {
if (selected) {
await Promise.all(selected.map(r => system.WriteOnceToRelay(r, ev)));
} else {
system.BroadcastEvent(ev);
setSending(true);
setReplies([]);
try {
if (selected) {
await Promise.all(selected.map(r => system.WriteOnceToRelay(r, ev).then(o => setReplies(v => [...v, o]))));
} else {
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">
{Object.keys(relays)
.filter(el => relays[el].write)
.map((r, i, a) => (
<div key={r} className="card flex justify-between">
<div>{r}</div>
<div>
<input
type="checkbox"
checked={!selected || selected.includes(r)}
onChange={e =>
setSelected(
e.target.checked && selected && selected.length === a.length - 1
? undefined
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
)
}
/>
</div>
</div>
))}
</div>
);
}
return (
<>
<Modal id="broadcaster" className="note-creator-modal" onClose={onClose}>
{renderRelayCustomisation()}
<div className="flex g8">
<button className="secondary" onClick={onClose}>
<FormattedMessage {...messages.Cancel} />
</button>
<AsyncButton onClick={sendReBroadcast}>
<FormattedMessage {...messages.ReBroadcast} />
</AsyncButton>
<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="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(
e.target.checked && selected && selected.length === a.length - 1
? undefined
: a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
)
}
/>
</div>
</div>
))}
<div className="flex gap-2">
<button className="secondary" onClick={onClose}>
<FormattedMessage defaultMessage="Cancel" />
</button>
<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;
}

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