Compare commits

...

118 Commits

Author SHA1 Message Date
mroxso
ebaef5826d
Merge b013e76fec into ec836bab70 2024-06-20 06:15:49 +00:00
Bojan Mojsilovic
ec836bab70 0.105.8 2024-04-30 12:40:41 +02:00
Bojan Mojsilovic
b5b043152c Fix top zaps z-axis stacking for better animation 2024-04-30 12:18:27 +02:00
Bojan Mojsilovic
2a9d443bc6 0.105.7 2024-04-29 17:39:14 +02:00
Bojan Mojsilovic
ffa54fb895 Modify labels 2024-04-29 17:39:02 +02:00
Bojan Mojsilovic
f296978998 0.105.6 2024-04-29 16:04:09 +02:00
Bojan Mojsilovic
b238aa30d3 Remove checking ffor pubkey every second 2024-04-29 16:03:57 +02:00
Bojan Mojsilovic
fd4f27066e fix typo 2024-04-29 14:36:12 +02:00
Bojan Mojsilovic
32fabac0d3 Handle Custom zap Modal clicks 2024-04-29 14:22:27 +02:00
Bojan Mojsilovic
7423100823 Top zap transitions 2024-04-29 14:06:49 +02:00
Bojan Mojsilovic
9455df93ab Refactor top zaps into a single list 2024-04-29 12:45:38 +02:00
Bojan Mojsilovic
a55c5122f7 Allow guests to see top zaps 2024-04-26 17:20:02 +02:00
Bojan Mojsilovic
c5092a0c3a Top zaps loading skeleton 2024-04-26 15:52:07 +02:00
Bojan Mojsilovic
dc78cdd6b8 0.105.5 2024-04-26 14:30:03 +02:00
Bojan Mojsilovic
513accfedd Fix reaction zap message length in modal 2024-04-26 14:29:58 +02:00
Bojan Mojsilovic
3eacab8fc3 0.105.4 2024-04-26 12:13:05 +02:00
Bojan Mojsilovic
864bd1458b Render multiple links in notes without previews 2024-04-26 12:12:55 +02:00
Bojan Mojsilovic
5091e10584 Fix rendering of non-preview links 2024-04-26 11:35:15 +02:00
Bojan Mojsilovic
a43c10ef01 0.105.3 2024-04-25 22:47:16 +02:00
Bojan Mojsilovic
f41094e574 Fix zap animations 2024-04-25 22:47:08 +02:00
Bojan Mojsilovic
1d41cf9742 0.105.2 2024-04-25 17:35:01 +02:00
Bojan Mojsilovic
278431597b Fix paging 2024-04-25 17:35:01 +02:00
Bojan Mojsilovic
7df0dbb775 0.105.1 2024-04-25 15:21:28 +02:00
Bojan Mojsilovic
00f976ae78 Note quotes reactions 2024-04-25 15:21:23 +02:00
Bojan Mojsilovic
a12bfc573d Modify profile loading 2024-04-25 12:47:24 +02:00
Bojan Mojsilovic
914a8ec866 Change reaction icons 2024-04-24 16:29:05 +02:00
Bojan Mojsilovic
8557f7a7a8 Fix people list hover 2024-04-24 15:54:59 +02:00
Bojan Mojsilovic
6249bb2733 0.105.0 2024-04-23 13:49:02 +02:00
Bojan Mojsilovic
b0091ccfe1 Instant refresh top zaps 2024-04-23 13:48:48 +02:00
Bojan Mojsilovic
26d24179e2 Fix user name cutoff on safari 2024-04-23 13:04:57 +02:00
Bojan Mojsilovic
a8cb75de07 Fix muted caption 2024-04-23 12:44:29 +02:00
Bojan Mojsilovic
96349c65a8 Simpler custom zap selection 2024-04-23 12:16:08 +02:00
Bojan Mojsilovic
c18249d067 Refresh top zaps after zapping 2024-04-22 19:30:45 +02:00
Bojan Mojsilovic
8570fd4611 Collapse few zaps into a single row. Show three dots only when thee are more zaps than displayed 2024-04-22 17:47:32 +02:00
Bojan Mojsilovic
a0be83b564 Fix reactions captions 2024-04-22 15:54:45 +02:00
Bojan Mojsilovic
ec0bd99d84 Delay zapping after the animation has completed 2024-04-22 15:54:45 +02:00
Bojan Mojsilovic
37a522ecbe Fix zap user feedback 2024-04-22 14:52:37 +02:00
Bojan Mojsilovic
15a0b3d3f1 Fix note zapping state managment 2024-04-19 18:09:47 +02:00
Bojan Mojsilovic
de0cdd6c09 Fix people list 2024-04-19 15:56:37 +02:00
Bojan Mojsilovic
28c1d6dca7 Fix bookmarks icon size 2024-04-19 14:47:31 +02:00
Bojan Mojsilovic
3d73b8119f missing lottie 2024-04-19 14:45:53 +02:00
Bojan Mojsilovic
2cfd170852 Mentioned label and emoji cutoff 2024-04-19 14:30:01 +02:00
Bojan Mojsilovic
28e1dd4c89 Fix clearing mentions in thread 2024-04-19 14:05:04 +02:00
Bojan Mojsilovic
f9cfa51458 Fix zap lottie animation 2024-04-18 17:20:04 +02:00
Bojan Mojsilovic
1bb40ea185 Initial zap animation fix 2024-04-18 13:41:19 +02:00
Bojan Mojsilovic
9f31e86546 Fix notification mentions padding 2024-04-18 12:36:42 +02:00
Bojan Mojsilovic
ac21e06df7 Refactor zapps for note 2024-04-18 12:22:51 +02:00
Bojan Mojsilovic
d06c74f466 Remove zappers from people list in a thread 2024-04-18 12:22:51 +02:00
Bojan Mojsilovic
c7e066121b Add top zaps to thread's primary note 2024-04-18 12:22:51 +02:00
Bojan Mojsilovic
87d1241e85 Open reactions modal on reaction summary 2024-04-16 15:31:03 +02:00
Bojan Mojsilovic
33e7efdc02 Redesigned Primary note 2024-04-16 14:57:28 +02:00
Bojan Mojsilovic
20b4bf76da Redesigned people list 2024-04-16 14:57:28 +02:00
Bojan Mojsilovic
e359b127a2 Reactions modal uses zaps sorted by sats API 2024-04-16 13:04:39 +02:00
Bojan Mojsilovic
78b430264e Fix reaction modal zaps linking to undefined 2024-04-16 12:50:44 +02:00
Bojan Mojsilovic
d1fd943569 0.104.7 2024-04-15 16:36:12 +02:00
Bojan Mojsilovic
bc89b0ad33 Fix spacing for Lnbc 2024-04-15 16:36:03 +02:00
Bojan Mojsilovic
de1dd413ee 0.104.6 2024-04-15 16:22:04 +02:00
Bojan Mojsilovic
e3a58637a7 Fix DMs 2024-04-15 16:21:41 +02:00
Bojan Mojsilovic
9bbf5882ce 0.104.5 2024-04-15 14:35:38 +02:00
Bojan Mojsilovic
535febbff1 Improve DMs 2024-04-15 14:34:57 +02:00
Bojan Mojsilovic
9b9c7ba370 Parse naddr1 2024-04-15 12:13:55 +02:00
Bojan Mojsilovic
8dd1f36f83 0.104.4 2024-04-10 15:13:34 +02:00
Bojan Mojsilovic
e44339562a Fix missing contacts in DMs 2024-04-10 14:35:55 +02:00
Bojan Mojsilovic
30b5807248 0.104.3 2024-04-09 23:54:51 +02:00
Bojan Mojsilovic
b34dbfaac3 Fix bug for profile qr code when ln is missing 2024-04-09 23:54:44 +02:00
Bojan Mojsilovic
8ac6fdf624 0.104.2 2024-04-09 16:04:02 +02:00
Bojan Mojsilovic
a3e5a2173d Remove icon from Cashu QR code 2024-04-09 16:03:53 +02:00
Bojan Mojsilovic
b98b966213 Prioritize display_name over name 2024-04-09 14:46:14 +02:00
Bojan Mojsilovic
9683c34380 0.104.1 2024-04-08 17:44:24 +02:00
Bojan Mojsilovic
feb0c1311f Add ln invoice to editor preview 2024-04-08 17:44:24 +02:00
Bojan Mojsilovic
c6807a9cd8 Support Cashu Ecash 2024-04-08 17:44:24 +02:00
Bojan Mojsilovic
8f13127d6b 0.104.0 2024-04-08 12:30:08 +02:00
Bojan Mojsilovic
505ec1da87 Various fixes 2024-04-08 12:29:58 +02:00
Bojan Mojsilovic
25707d8e29 Fix mention search 2024-04-05 18:33:12 +02:00
Bojan Mojsilovic
6879edd184 Add reset relays button 2024-04-05 18:33:12 +02:00
Bojan Mojsilovic
1411825f30 Fix profile qr code modal labels 2024-04-05 15:37:13 +02:00
Bojan Mojsilovic
f4b3cf831e Add lnbc to DMs 2024-04-05 15:17:35 +02:00
Bojan Mojsilovic
8d2124a56b Use lightning and nostrich icons in qr codes 2024-04-05 15:16:59 +02:00
Bojan Mojsilovic
1a0c3cff04 Set sunrise as the starting theme setting 2024-04-05 00:51:11 +02:00
Bojan Mojsilovic
2e87f52c87 Add lightning and nostr prefix to profile qr info 2024-04-05 00:36:17 +02:00
Bojan Mojsilovic
e331c7afe8 Handle note image error 2024-04-05 00:30:45 +02:00
Bojan Mojsilovic
c0a3f085ba Raise badge 2024-04-04 18:06:09 +02:00
Bojan Mojsilovic
4d0f4e1124 Fix banner 2024-04-04 18:05:56 +02:00
Bojan Mojsilovic
237991a681 Fix possible error with relay hints 2024-04-04 12:24:22 +02:00
Bojan Mojsilovic
c19a24e1a5 Fix overwriting unsupported bookmarks 2024-04-04 12:24:03 +02:00
Bojan Mojsilovic
dcf32be914 Add support for unified addresses 2024-04-04 10:35:10 +02:00
Bojan Mojsilovic
4e22957391 Fix lnbc rendering when error is encountered 2024-04-04 09:42:05 +02:00
Bojan Mojsilovic
948a1d7cba Fix link preview image flickering indefinitely. 2024-04-04 09:42:05 +02:00
Bojan Mojsilovic
e6104ff4ad Add relay hints to mentioned notes 2024-04-04 09:42:05 +02:00
Bojan Mojsilovic
c77656d6b8 Add relay hints to replies and publish to parent relay hint 2024-04-03 17:31:02 +02:00
Bojan Mojsilovic
ded15540a2 Refactor notes 2024-04-02 16:53:43 +02:00
Bojan Mojsilovic
21457cc2c5 Update home and messages nav icons 2024-04-02 16:53:43 +02:00
Bojan Mojsilovic
5b9df00aa9 Add bookmarks 2024-04-02 16:53:43 +02:00
Bojan Mojsilovic
4e15d19b40 Preliminary lightning invoice rendering 2024-03-28 14:52:53 +01:00
Bojan Mojsilovic
371b55aa44 Fix Profile tab pagination 2024-03-28 13:17:38 +01:00
Bojan Mojsilovic
d20836a002 0.103.5 2024-03-27 17:16:27 +01:00
Bojan Mojsilovic
e10728f1c7 Fix links from profile about 2024-03-27 17:16:19 +01:00
Bojan Mojsilovic
8f5cd3b728 0.103.4 2024-03-26 17:32:08 +01:00
Bojan Mojsilovic
98835daf54 Parse profile about 2024-03-26 17:32:08 +01:00
Bojan Mojsilovic
1638029c93 Fix long-form note display 2024-03-26 15:07:54 +01:00
Bojan Mojsilovic
5d08eeb097 Allow following yourself in you are not already 2024-03-26 15:07:15 +01:00
Bojan Mojsilovic
18f95a5e83 0.103.3 2024-03-26 13:16:45 +01:00
Bojan Mojsilovic
4299b15abd Handle profile and note mentions in profile bio 2024-03-26 13:16:34 +01:00
Bojan Mojsilovic
377569ce79 0.103.2 2024-03-26 12:55:17 +01:00
Bojan Mojsilovic
0f6afbc8ac Fix npub breaking layout 2024-03-26 12:55:13 +01:00
Bojan Mojsilovic
d1d33ff471 Fix blur on safari 2024-03-26 12:36:17 +01:00
Bojan Mojsilovic
4002f55035 0.103.1 2024-03-26 11:29:43 +01:00
Bojan Mojsilovic
913c478e9e Handle missing notes 2024-03-26 11:29:37 +01:00
Bojan Mojsilovic
7c13ded03c 0.103.0 2024-03-26 11:11:09 +01:00
Bojan Mojsilovic
631f21adee 0.102.22 2024-03-26 11:11:01 +01:00
Bojan Mojsilovic
f9ad9742e9 Render long-form notes 2024-03-26 11:10:51 +01:00
Bojan Mojsilovic
cca4ab5949 Remove the ability to unfollow yourself 2024-03-26 10:37:24 +01:00
Bojan Mojsilovic
af70930b35 Fix mention notification actor identification 2024-03-25 12:59:39 +01:00
Bojan Mojsilovic
7e73894c2a 0.102.21 2024-03-20 14:08:01 +01:00
Bojan Mojsilovic
b2f0a94dd6 Handle link preview image errors 2024-03-20 14:07:53 +01:00
PascalR
b013e76fec
Merge branch 'main' into main 2023-08-28 14:48:23 +02:00
mroxso
56031847c5 add Dockerfile 2023-07-09 21:09:37 +02:00
mbrat1
64643aa054
Update README.md 2023-07-07 10:43:26 -04:00
112 changed files with 6401 additions and 817 deletions

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM node:20.2.0-alpine3.18
WORKDIR /app
COPY ./ .
RUN npm install
CMD ["npm", "run", "dev:host"]

View File

@ -8,8 +8,6 @@
*** Thanks again! Now go create something AMAZING! :D
-->
<!-- PROJECT SHIELDS -->
<!--
*** I'm using markdown "reference style" links for readability.

188
package-lock.json generated
View File

@ -1,14 +1,15 @@
{
"name": "primal-web-app",
"version": "0.102.20",
"version": "0.105.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "primal-web-app",
"version": "0.102.20",
"version": "0.105.8",
"license": "MIT",
"dependencies": {
"@cashu/cashu-ts": "0.9.0",
"@cookbook/solid-intl": "0.1.2",
"@jukben/emoji-search": "3.0.0",
"@kobalte/core": "0.11.0",
@ -18,12 +19,14 @@
"@thisbeyond/solid-select": "^0.13.0",
"@types/dompurify": "3.0.2",
"dompurify": "3.0.5",
"light-bolt11-decoder": "^3.1.1",
"medium-zoom": "1.0.8",
"nostr-tools": "1.15.0",
"photoswipe": "5.4.3",
"qr-code-styling": "^1.6.0-rc.1",
"sass": "1.67.0",
"solid-js": "1.7.11"
"solid-js": "1.7.11",
"solid-transition-group": "^0.2.3"
},
"devDependencies": {
"@formatjs/cli": "^6.0.4",
@ -490,6 +493,61 @@
"node": ">=6.9.0"
}
},
"node_modules/@cashu/cashu-ts": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-0.9.0.tgz",
"integrity": "sha512-DacSnpv3dJIGzo6A1nHIp2guFuDcmoPB5CX9m0SXA60bQxoIa4srHKLkjxUZ8GFCD9RaI+60UZ2+hZS635Ro2w==",
"dependencies": {
"@gandlaf21/bolt11-decode": "^3.0.6",
"@noble/curves": "^1.0.0",
"@scure/bip32": "^1.3.2",
"@scure/bip39": "^1.2.1",
"buffer": "^6.0.3"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.0.tgz",
"integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==",
"dependencies": {
"@noble/hashes": "1.4.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.6.tgz",
"integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz",
"integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==",
"dependencies": {
"@noble/curves": "~1.4.0",
"@noble/hashes": "~1.4.0",
"@scure/base": "~1.1.6"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cookbook/solid-intl": {
"version": "0.1.2",
"license": "MIT",
@ -634,6 +692,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@gandlaf21/bolt11-decode": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@gandlaf21/bolt11-decode/-/bolt11-decode-3.1.1.tgz",
"integrity": "sha512-uorLVc3FAWO6USCaBHGixwUd7B86z4IVRa/TdLicsWi3Vm7QngYDIuUkOBemut0Qh/Qtsx47EjWMXS4WHel98A==",
"dependencies": {
"bech32": "^1.1.2",
"bn.js": "^4.11.8",
"buffer": "^6.0.3"
}
},
"node_modules/@internationalized/date": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.0.tgz",
@ -894,6 +962,14 @@
"solid-js": "^1.6.12"
}
},
"node_modules/@solid-primitives/transition-group": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@solid-primitives/transition-group/-/transition-group-1.0.5.tgz",
"integrity": "sha512-G3FuqvL13kQ55WzWPX2ewiXdZ/1iboiX53195sq7bbkDbXqP6TYKiadwEdsaDogW5rPnPYAym3+xnsNplQJRKQ==",
"peerDependencies": {
"solid-js": "^1.6.12"
}
},
"node_modules/@solid-primitives/trigger": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@solid-primitives/trigger/-/trigger-1.0.8.tgz",
@ -1042,6 +1118,30 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/bech32": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz",
"integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ=="
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"license": "MIT",
@ -1049,6 +1149,11 @@
"node": ">=8"
}
},
"node_modules/bn.js": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
},
"node_modules/braces": {
"version": "3.0.2",
"license": "MIT",
@ -1090,6 +1195,29 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001534",
"dev": true,
@ -1648,6 +1776,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/immutable": {
"version": "4.3.4",
"license": "MIT"
@ -1734,6 +1881,25 @@
"node": ">=6"
}
},
"node_modules/light-bolt11-decoder": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.1.1.tgz",
"integrity": "sha512-sLg/KCwYkgsHWkefWd6KqpCHrLFWWaXTOX3cf6yD2hAzL0SLpX+lFcaFK2spkjbgzG6hhijKfORDc9WoUHwX0A==",
"dependencies": {
"@scure/base": "1.1.1"
}
},
"node_modules/light-bolt11-decoder/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
]
},
"node_modules/lru-cache": {
"version": "5.1.1",
"dev": true,
@ -1993,6 +2159,22 @@
"solid-js": "^1.3"
}
},
"node_modules/solid-transition-group": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/solid-transition-group/-/solid-transition-group-0.2.3.tgz",
"integrity": "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==",
"dependencies": {
"@solid-primitives/refs": "^1.0.5",
"@solid-primitives/transition-group": "^1.0.2"
},
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.6.0"
},
"peerDependencies": {
"solid-js": "^1.6.12"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"license": "BSD-3-Clause",

View File

@ -1,6 +1,6 @@
{
"name": "primal-web-app",
"version": "0.102.20",
"version": "0.105.8",
"description": "",
"scripts": {
"start": "vite",
@ -21,6 +21,7 @@
"vite-plugin-solid": "^2.5.0"
},
"dependencies": {
"@cashu/cashu-ts": "0.9.0",
"@cookbook/solid-intl": "0.1.2",
"@jukben/emoji-search": "3.0.0",
"@kobalte/core": "0.11.0",
@ -30,11 +31,13 @@
"@thisbeyond/solid-select": "^0.13.0",
"@types/dompurify": "3.0.2",
"dompurify": "3.0.5",
"light-bolt11-decoder": "^3.1.1",
"medium-zoom": "1.0.8",
"nostr-tools": "1.15.0",
"photoswipe": "5.4.3",
"qr-code-styling": "^1.6.0-rc.1",
"sass": "1.67.0",
"solid-js": "1.7.11"
"solid-js": "1.7.11",
"solid-transition-group": "0.2.3"
}
}

View File

@ -20,6 +20,7 @@ const Layout = lazy(() => import('./components/Layout/Layout'));
const Explore = lazy(() => import('./pages/Explore'));
const Thread = lazy(() => import('./pages/Thread'));
const Messages = lazy(() => import('./pages/Messages'));
const Bookmarks = lazy(() => import('./pages/Bookmarks'));
const Notifications = lazy(() => import('./pages/Notifications'));
const Downloads = lazy(() => import('./pages/Downloads'));
const Settings = lazy(() => import('./pages/Settings/Settings'));
@ -126,6 +127,7 @@ const Router: Component = () => {
<Route path="/network" component={Network} />
<Route path="/filters" component={Moderation} />
</Route>
<Route path="/bookmarks" component={Bookmarks}/>
<Route path="/settings/profile" component={EditProfile} />
<Route path="/profile/:npub?" component={Profile} />
<Route path="/p/:npub?" component={Profile} />

View File

@ -0,0 +1,3 @@
<svg width="14" height="20" viewBox="0 0 14 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.5294 2.14703H2.47059C2.01577 2.14703 1.64706 2.51573 1.64706 2.97055V17.0857L5.93144 14.0615C6.57205 13.6093 7.42795 13.6093 8.06856 14.0615L12.3529 17.0857V2.97055C12.3529 2.51573 11.9842 2.14703 11.5294 2.14703ZM2.47059 0.5C1.10612 0.5 0 1.6061 0 2.97055V18.675C0 19.3428 0.752928 19.7329 1.29845 19.3478L6.88127 15.4071C6.95245 15.3568 7.04755 15.3568 7.11873 15.4071L12.7016 19.3478C13.2471 19.7329 14 19.3428 14 18.675V2.97055C14 1.6061 12.8939 0.5 11.5294 0.5H2.47059Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 649 B

View File

@ -0,0 +1,3 @@
<svg width="14" height="20" viewBox="0 0 14 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.47059 0.5C1.10612 0.5 0 1.6061 0 2.97055V18.675C0 19.3428 0.752928 19.7329 1.29845 19.3478L6.88127 15.4071C6.95245 15.3568 7.04755 15.3568 7.11873 15.4071L12.7016 19.3478C13.2471 19.7329 14 19.3428 14 18.675V2.97055C14 1.6061 12.8939 0.5 11.5294 0.5H2.47059Z" fill="#0090F8"/>
</svg>

After

Width:  |  Height:  |  Size: 432 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6181_960)">
<path d="M2 1.5H9C9.27614 1.5 9.5 1.72386 9.5 2V2.25C9.5 2.66421 9.83579 3 10.25 3C10.6642 3 11 2.66421 11 2.25V2C11 0.895431 10.1046 0 9 0H2C0.895431 0 0 0.89543 0 2V9C0 10.1046 0.895431 11 2 11H2.25C2.66421 11 3 10.6642 3 10.25C3 9.83579 2.66421 9.5 2.25 9.5H2C1.72386 9.5 1.5 9.27614 1.5 9V2C1.5 1.72386 1.72386 1.5 2 1.5Z" fill="#AAAAAA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 7C5 5.89543 5.89543 5 7 5H14C15.1046 5 16 5.89543 16 7V14C16 15.1046 15.1046 16 14 16H7C5.89543 16 5 15.1046 5 14V7ZM7 6.5H14C14.2761 6.5 14.5 6.72386 14.5 7V14C14.5 14.2761 14.2761 14.5 14 14.5H7C6.72386 14.5 6.5 14.2761 6.5 14V7C6.5 6.72386 6.72386 6.5 7 6.5Z" fill="#AAAAAA"/>
</g>
<defs>
<clipPath id="clip0_6181_960">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 923 B

View File

@ -1,3 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0132 5.10756L9.78323 5.56125C9.5935 5.93552 9.05201 5.93553 8.86227 5.56127L8.63264 5.10834L8.62905 5.10143C8.62565 5.09494 8.61986 5.08399 8.61174 5.06899C8.59548 5.03897 8.56994 4.99284 8.5355 4.93374C8.46648 4.81526 8.36261 4.64621 8.22713 4.45121C7.9526 4.05606 7.56519 3.57896 7.09416 3.19637C6.62169 2.81262 6.11197 2.56128 5.58052 2.52745C5.07043 2.49497 4.42571 2.65536 3.64715 3.31352C2.8859 3.95706 2.60757 4.60405 2.55443 5.19819C2.49897 5.81822 2.68208 6.46687 2.99878 7.09167C3.31371 7.71297 3.7363 8.26217 4.08917 8.66209C4.26393 8.86016 4.4176 9.01714 4.52603 9.1232C4.58015 9.17615 4.62273 9.21614 4.65071 9.24195C4.66469 9.25485 4.67499 9.26418 4.68124 9.2698L4.68739 9.2753L4.68827 9.27608L4.70433 9.29016L8.97162 13.4992C9.16631 13.6912 9.47916 13.6912 9.67385 13.4992L14.9758 8.26966L14.9963 8.25233L14.9973 8.25146L14.9992 8.24979L15.0187 8.23239C15.0375 8.21538 15.0673 8.18784 15.1056 8.1505C15.1824 8.07559 15.2919 7.96263 15.4144 7.81758C15.6629 7.52349 15.9474 7.11831 16.1326 6.64841C16.3158 6.18372 16.3948 5.67619 16.2693 5.14565C16.1446 4.61858 15.7998 3.99103 14.9983 3.31354C14.2197 2.65536 13.5749 2.49497 13.0648 2.52745C12.5334 2.56128 12.0236 2.81262 11.5512 3.19636C11.0802 3.57895 10.6928 4.05604 10.4183 4.45119C10.2828 4.64619 10.179 4.81524 10.11 4.93371C10.0755 4.99281 10.05 5.03894 10.0337 5.06897C10.0256 5.08397 10.0198 5.09491 10.0164 5.1014L10.0132 5.10756ZM3.64195 10.3968L3.64096 10.3959C3.6299 10.3859 3.61462 10.3721 3.59555 10.3545C3.55741 10.3193 3.50396 10.269 3.43843 10.2049C3.30755 10.0769 3.12738 9.89265 2.92406 9.66221C2.52078 9.20514 2.01025 8.54819 1.61729 7.77294C1.2261 7.00119 0.926547 6.06289 1.01587 5.0643C1.10751 4.0398 1.60141 3.0365 2.64203 2.15679C3.66536 1.29171 4.69384 0.94438 5.68 1.00716C6.6448 1.06858 7.45438 1.51546 8.07598 2.02034C8.5925 2.43987 9.01169 2.9253 9.3227 3.34212C9.6337 2.9253 10.0529 2.43987 10.5694 2.02035C11.1909 1.51546 12.0005 1.06858 12.9653 1.00716C13.9515 0.94438 14.98 1.2917 16.0034 2.15677C17.0039 3.00252 17.5602 3.89832 17.7734 4.79945C17.9858 5.69711 17.8394 6.52262 17.5721 7.20057C17.3069 7.87331 16.9157 8.42036 16.601 8.79284C16.4419 8.98108 16.298 9.12999 16.1918 9.23363C16.1385 9.28557 16.0944 9.32647 16.0622 9.35559C16.0495 9.3671 16.0386 9.37679 16.0297 9.38461L9.67385 15.6537C9.47916 15.8457 9.16631 15.8457 8.97162 15.6537L3.64195 10.3968Z" fill="#666666"/>
<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.6823 5.36822L10.4098 5.90592C10.1849 6.3495 9.54313 6.34951 9.31824 5.90595L9.04609 5.36914L9.04183 5.36096C9.0378 5.35326 9.03095 5.34029 9.02132 5.32251C9.00205 5.28693 8.97178 5.23226 8.93097 5.16221C8.84915 5.02179 8.72605 4.82144 8.56549 4.59032C8.24012 4.122 7.78097 3.55655 7.2227 3.10311C6.66274 2.6483 6.05864 2.35041 5.42876 2.31031C4.82421 2.27182 4.0601 2.46191 3.13737 3.24195C2.23514 4.00466 1.90527 4.77147 1.84229 5.47563C1.77655 6.21048 1.99357 6.97926 2.36892 7.71976C2.74217 8.45611 3.24303 9.10701 3.66124 9.581C3.86836 9.81574 4.05049 10.0018 4.179 10.1275C4.24314 10.1902 4.29361 10.2376 4.32676 10.2682C4.34333 10.2835 4.35555 10.2946 4.36295 10.3012L4.37024 10.3078L4.37128 10.3087L4.39032 10.3254L9.51286 15.378C9.70755 15.57 10.0204 15.57 10.2151 15.378L16.5639 9.1159L16.5882 9.09535L16.5894 9.09432L16.5916 9.09235L16.6148 9.07172C16.6371 9.05157 16.6724 9.01892 16.7178 8.97467C16.8088 8.88589 16.9385 8.752 17.0837 8.5801C17.3782 8.23154 17.7154 7.75133 17.935 7.19441C18.1521 6.64366 18.2457 6.04215 18.0969 5.41336C17.9491 4.78869 17.5405 4.04493 16.5906 3.24198C15.6678 2.46191 14.9036 2.27182 14.299 2.31031C13.6692 2.35041 13.0651 2.64829 12.5051 3.1031C11.9469 3.55653 11.4878 4.12198 11.1624 4.5903C11.0019 4.82141 10.8788 5.02176 10.797 5.16218C10.7562 5.23222 10.7259 5.28689 10.7066 5.32248C10.697 5.34026 10.6902 5.35323 10.6861 5.36092L10.6823 5.36822ZM3.13121 11.6369L3.13003 11.6359C3.11691 11.6241 3.09881 11.6077 3.0762 11.5868C3.031 11.5451 2.96766 11.4855 2.88999 11.4095C2.73487 11.2578 2.52134 11.0394 2.28037 10.7663C1.8024 10.2246 1.19733 9.446 0.731598 8.52719C0.267965 7.61252 -0.087055 6.50047 0.0188101 5.31694C0.12742 4.10273 0.712781 2.91363 1.94612 1.87101C3.15894 0.845728 4.37788 0.43408 5.54667 0.508488C6.69013 0.581283 7.64964 1.11092 8.38635 1.70929C8.99852 2.20651 9.49533 2.78184 9.86395 3.27584C10.2325 2.78184 10.7293 2.20651 11.3415 1.7093C12.0782 1.11092 13.0377 0.581281 14.1811 0.508486C15.3499 0.43408 16.5689 0.845722 17.7818 1.87099C18.9676 2.87336 19.6269 3.93505 19.8796 5.00306C20.1313 6.06694 19.9579 7.04533 19.6411 7.84883C19.3267 8.64614 18.863 9.2945 18.49 9.73596C18.3016 9.95906 18.131 10.1355 18.0051 10.2584C17.942 10.3199 17.8897 10.3684 17.8515 10.4029C17.8364 10.4166 17.8235 10.428 17.813 10.4373L10.2151 17.9315C10.0204 18.1235 9.70755 18.1235 9.51286 17.9315L3.13121 11.6369Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,3 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.64195 10.3968L3.64096 10.3959C3.6299 10.3859 3.61462 10.3721 3.59555 10.3545C3.55741 10.3193 3.50396 10.269 3.43843 10.2049C3.30754 10.0769 3.12738 9.89265 2.92406 9.66221C2.52078 9.20514 2.01025 8.54819 1.61729 7.77294C1.2261 7.00119 0.926547 6.06289 1.01587 5.0643C1.10751 4.0398 1.60141 3.0365 2.64203 2.15679C3.66536 1.29171 4.69384 0.94438 5.68 1.00716C6.6448 1.06858 7.45438 1.51546 8.07598 2.02034C8.5925 2.43987 9.01169 2.9253 9.3227 3.34212C9.6337 2.9253 10.0529 2.43987 10.5694 2.02035C11.1909 1.51546 12.0005 1.06858 12.9653 1.00716C13.9515 0.94438 14.98 1.2917 16.0034 2.15677C17.0039 3.00252 17.5602 3.89832 17.7734 4.79945C17.9858 5.69711 17.8394 6.52262 17.5721 7.20057C17.3069 7.87331 16.9157 8.42036 16.601 8.79284C16.4419 8.98108 16.298 9.12999 16.1918 9.23363C16.1385 9.28557 16.0944 9.32647 16.0622 9.35559C16.0495 9.3671 16.0386 9.37679 16.0297 9.38461L9.67385 15.6537C9.47915 15.8457 9.16631 15.8457 8.97162 15.6537L3.64195 10.3968Z" fill="#BC1870"/>
<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.1312 11.6369L3.13003 11.6359C3.11691 11.6241 3.09881 11.6077 3.0762 11.5868C3.031 11.5451 2.96766 11.4855 2.88999 11.4095C2.73487 11.2578 2.52134 11.0394 2.28037 10.7663C1.8024 10.2246 1.19733 9.446 0.731598 8.52719C0.267965 7.61252 -0.087055 6.50047 0.0188101 5.31694C0.12742 4.10273 0.712781 2.91363 1.94612 1.87101C3.15894 0.845728 4.37788 0.43408 5.54667 0.508488C6.69013 0.581283 7.64964 1.11092 8.38635 1.70929C8.99851 2.20651 9.49533 2.78184 9.86394 3.27584C10.2325 2.78184 10.7293 2.20651 11.3415 1.7093C12.0782 1.11092 13.0377 0.581281 14.1811 0.508486C15.3499 0.43408 16.5689 0.845722 17.7818 1.87099C18.9676 2.87336 19.6269 3.93505 19.8796 5.00306C20.1313 6.06694 19.9579 7.04533 19.6411 7.84883C19.3267 8.64614 18.863 9.2945 18.49 9.73596C18.3016 9.95906 18.131 10.1355 18.0051 10.2584C17.942 10.3199 17.8897 10.3684 17.8515 10.4029C17.8364 10.4166 17.8235 10.428 17.813 10.4373L10.2151 17.9315C10.0204 18.1235 9.70755 18.1235 9.51286 17.9315L3.1312 11.6369Z" fill="#CA077C"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,10 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2584_7892)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.0841 11.2595L3.78577 14.6592L7.20662 12.4102L7.81646 12.4814C8.20105 12.5264 8.59631 12.5498 9 12.5498C11.1503 12.5498 13.0338 11.8857 14.3431 10.891C15.6512 9.89724 16.3125 8.6471 16.3125 7.39992C16.3125 6.15274 15.6512 4.9026 14.3431 3.90884C13.0338 2.91415 11.1503 2.25 9 2.25C6.84965 2.25 4.96617 2.91415 3.65687 3.90884C2.34881 4.9026 1.6875 6.15274 1.6875 7.39992C1.6875 8.56858 2.26553 9.73351 3.41097 10.6947L4.0841 11.2595ZM2.77093 17.3459C2.37805 17.6042 1.86047 17.2951 1.90158 16.8267L2.32624 11.9874C0.880583 10.7743 0 9.16522 0 7.39992C0 3.62372 4.02944 0.5625 9 0.5625C13.9706 0.5625 18 3.62372 18 7.39992C18 11.1761 13.9706 14.2373 9 14.2373C8.53096 14.2373 8.07029 14.2101 7.62061 14.1575L2.77093 17.3459Z" fill="#666666"/>
</g>
<defs>
<clipPath id="clip0_2584_7892">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.53788 14.0425L5.20641 17.8698L9.00735 15.3379L9.68496 15.4181C10.1123 15.4687 10.5515 15.4951 11 15.4951C13.3893 15.4951 15.482 14.7475 16.9368 13.6276C18.3902 12.5089 19.125 11.1015 19.125 9.69745C19.125 8.2934 18.3902 6.88601 16.9368 5.76726C15.482 4.64745 13.3893 3.89976 11 3.89976C8.61072 3.89976 6.51797 4.64745 5.06319 5.76726C3.60979 6.88601 2.875 8.2934 2.875 9.69745C2.875 11.0131 3.51725 12.3246 4.78997 13.4067L5.53788 14.0425ZM4.07881 20.8945C3.64227 21.1853 3.06719 20.8373 3.11286 20.31L3.58471 14.8619C1.97843 13.4962 1 11.6848 1 9.69745C1 5.44627 5.47715 2 11 2C16.5228 2 21 5.44627 21 9.69745C21 13.9486 16.5228 17.3949 11 17.3949C10.4788 17.3949 9.96699 17.3642 9.46734 17.3051L4.07881 20.8945Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 887 B

View File

@ -1,10 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2584_7890)">
<path d="M9 14.2373C13.9706 14.2373 18 11.1761 18 7.39992C18 3.62372 13.9706 0.5625 9 0.5625C4.02944 0.5625 0 3.62372 0 7.39992C0 9.16522 0.880583 10.7743 2.32624 11.9874L1.90158 16.8267C1.86047 17.2951 2.37805 17.6042 2.77093 17.3459L7.62061 14.1575C8.07029 14.2101 8.53096 14.2373 9 14.2373Z" fill="#CCCCCC"/>
</g>
<defs>
<clipPath id="clip0_2584_7890">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.07881 18.8945C2.64227 19.1853 2.06719 18.8373 2.11286 18.31L2.58471 12.8619C0.978426 11.4962 0 9.6848 0 7.69745C0 3.44627 4.47715 0 10 0C15.5228 0 20 3.44627 20 7.69745C20 11.9486 15.5228 15.3949 10 15.3949C9.47884 15.3949 8.96699 15.3642 8.46734 15.3051L3.07881 18.8945Z" fill="#AAAAAA"/>
</svg>

Before

Width:  |  Height:  |  Size: 561 B

After

Width:  |  Height:  |  Size: 445 B

View File

@ -1,4 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.2595 5.81202C6.53778 6.03552 6.95 5.83661 6.95 5.47884V3.80652H13.75C15.1583 3.80652 16.3 4.95271 16.3 6.3666V9.5709C16.3 9.86135 16.2518 10.1405 16.163 10.4008C16.098 10.5914 16.1418 10.8092 16.2986 10.9352L16.999 11.4977C17.1986 11.658 17.4947 11.6067 17.6029 11.3742C17.8577 10.8264 18 10.2154 18 9.5709V6.3666C18 4.01011 16.0972 2.0998 13.75 2.0998H6.95V0.427481C6.95 0.069707 6.53778 -0.1292 6.2595 0.0942999L3.11484 2.61998C2.90216 2.79079 2.90216 3.11553 3.11484 3.28634L6.2595 5.81202Z" fill="#666666"/>
<path d="M2.00104 5.50235C1.80143 5.34203 1.50528 5.39326 1.39711 5.62579C1.14231 6.17356 1 6.78464 1 7.4291V10.6334C1 12.9899 2.90279 14.9002 5.25 14.9002H12.05V16.5725C12.05 16.9303 12.4622 17.1292 12.7405 16.9057L15.8852 14.38C16.0978 14.2092 16.0978 13.8845 15.8852 13.7137L12.7405 11.188C12.4622 10.9645 12.05 11.1634 12.05 11.5212V13.1935H5.25C3.84167 13.1935 2.7 12.0473 2.7 10.6334V7.4291C2.7 7.13864 2.74818 6.85949 2.83695 6.59924C2.902 6.40857 2.85816 6.19075 2.7014 6.06485L2.00104 5.50235Z" fill="#666666"/>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.37827 6.99579C6.68928 7.24558 7.15 7.02327 7.15 6.62341V4.75435H14.75C16.324 4.75435 17.6 6.03538 17.6 7.61561V11.1969C17.6 11.5215 17.5462 11.8335 17.4469 12.1244C17.3742 12.3375 17.4232 12.5809 17.5984 12.7216L18.3812 13.3503C18.6043 13.5295 18.9353 13.4722 19.0562 13.2124C19.3409 12.6001 19.5 11.9172 19.5 11.1969V7.61561C19.5 4.98189 17.3734 2.84684 14.75 2.84684H7.15V0.977773C7.15 0.577908 6.68928 0.3556 6.37827 0.605394L2.86364 3.42821C2.62595 3.61912 2.62595 3.98206 2.86364 4.17297L6.37827 6.99579Z" fill="#666666"/>
<path d="M1.61881 6.64968C1.39572 6.4705 1.06472 6.52776 0.943833 6.78764C0.659057 7.39986 0.5 8.08284 0.5 8.80311V12.3844C0.5 15.0181 2.62665 17.1532 5.25 17.1532H12.85V19.0222C12.85 19.4221 13.3107 19.6444 13.6217 19.3946L17.1364 16.5718C17.3741 16.3809 17.3741 16.0179 17.1364 15.827L13.6217 13.0042C13.3107 12.7544 12.85 12.9767 12.85 13.3766V15.2457H5.25C3.67599 15.2457 2.4 13.9646 2.4 12.3844V8.80311C2.4 8.47849 2.45385 8.16649 2.55307 7.87562C2.62576 7.66252 2.57677 7.41908 2.40157 7.27836L1.61881 6.64968Z" fill="#666666"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,4 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.2595 5.81202C6.53778 6.03552 6.95 5.83661 6.95 5.47884V3.80652H13.75C15.1583 3.80652 16.3 4.95271 16.3 6.3666V9.5709C16.3 9.86135 16.2518 10.1405 16.163 10.4008C16.098 10.5914 16.1418 10.8092 16.2986 10.9352L16.999 11.4977C17.1986 11.658 17.4947 11.6067 17.6029 11.3742C17.8577 10.8264 18 10.2154 18 9.5709V6.3666C18 4.01011 16.0972 2.0998 13.75 2.0998H6.95V0.427481C6.95 0.069707 6.53778 -0.1292 6.2595 0.0942999L3.11484 2.61998C2.90216 2.79079 2.90216 3.11553 3.11484 3.28634L6.2595 5.81202Z" fill="#66E205"/>
<path d="M2.00104 5.50235C1.80143 5.34203 1.50528 5.39326 1.39711 5.62579C1.14231 6.17356 1 6.78464 1 7.4291V10.6334C1 12.9899 2.90279 14.9002 5.25 14.9002H12.05V16.5725C12.05 16.9303 12.4622 17.1292 12.7405 16.9057L15.8852 14.38C16.0978 14.2092 16.0978 13.8845 15.8852 13.7137L12.7405 11.188C12.4622 10.9645 12.05 11.1634 12.05 11.5212V13.1935H5.25C3.84167 13.1935 2.7 12.0473 2.7 10.6334V7.4291C2.7 7.13864 2.74818 6.85949 2.83695 6.59924C2.902 6.40857 2.85816 6.19075 2.7014 6.06485L2.00104 5.50235Z" fill="#66E205"/>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.37827 6.99579C6.68928 7.24558 7.15 7.02327 7.15 6.62341V4.75435H14.75C16.324 4.75435 17.6 6.03538 17.6 7.61561V11.1969C17.6 11.5215 17.5462 11.8335 17.4469 12.1244C17.3742 12.3375 17.4232 12.5809 17.5984 12.7216L18.3812 13.3503C18.6043 13.5295 18.9353 13.4722 19.0562 13.2124C19.3409 12.6001 19.5 11.9172 19.5 11.1969V7.61561C19.5 4.98189 17.3734 2.84684 14.75 2.84684H7.15V0.977773C7.15 0.577908 6.68928 0.3556 6.37827 0.605394L2.86364 3.42821C2.62595 3.61912 2.62595 3.98206 2.86364 4.17297L6.37827 6.99579Z" fill="#52CE0A"/>
<path d="M1.61881 6.64968C1.39572 6.4705 1.06472 6.52776 0.943833 6.78764C0.659057 7.39986 0.5 8.08284 0.5 8.80311V12.3844C0.5 15.0181 2.62665 17.1532 5.25 17.1532H12.85V19.0222C12.85 19.4221 13.3107 19.6444 13.6217 19.3946L17.1364 16.5718C17.3741 16.3809 17.3741 16.0179 17.1364 15.827L13.6217 13.0042C13.3107 12.7544 12.85 12.9767 12.85 13.3766V15.2457H5.25C3.67599 15.2457 2.4 13.9646 2.4 12.3844V8.80311C2.4 8.47849 2.45385 8.16649 2.55307 7.87562C2.62576 7.66252 2.57677 7.41908 2.40157 7.27836L1.61881 6.64968Z" fill="#52CE0A"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.1467 8.42428C19.4064 8.07395 19.1466 7.58726 18.6999 7.58726H13.1769L14.4626 1.26676C14.6866 0.166157 13.214 -0.470385 12.5102 0.422761L2.86283 12.6664C2.58819 13.015 2.84615 13.5159 3.30019 13.5159H8.78359L7.44317 20.751C7.23729 21.8622 8.73755 22.471 9.41819 21.5525L19.1467 8.42428ZM11.8933 4.26887L10.8413 9.43996H16.032L9.97755 17.6101L11.0793 11.6633H6.06692L11.8933 4.26887Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="22" viewBox="0 0 18 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1467 8.42428C17.4064 8.07395 17.1466 7.58726 16.6999 7.58726H11.1769L12.4626 1.26676C12.6866 0.166157 11.214 -0.470385 10.5102 0.422761L0.862834 12.6664C0.588189 13.015 0.846145 13.5159 1.30019 13.5159H6.78359L5.44317 20.751C5.23729 21.8622 6.73755 22.471 7.41819 21.5525L17.1467 8.42428Z" fill="#FFA02F"/>
</svg>

After

Width:  |  Height:  |  Size: 463 B

View File

@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5881 9.47872L12.6018 1.20132C12.2455 0.932895 11.7545 0.932895 11.3982 1.20132L0.411896 9.47872C-0.0347535 9.81524 -0.133503 10.4631 0.191333 10.9259C0.51617 11.3886 1.14158 11.4909 1.58823 11.1543L3.0001 10.0906V20.7479C3.0001 21.8525 3.89554 22.7479 5.00011 22.7479H19C20.1046 22.7479 21 21.8525 21 20.7479V10.0907L22.4118 11.1543C22.8584 11.4909 23.4838 11.3886 23.8087 10.9259C24.1335 10.4631 24.0348 9.81524 23.5881 9.47872ZM19 8.58384L12.6017 3.76323C12.2455 3.4948 11.7545 3.49481 11.3983 3.76323L5.00009 8.58376V19.676C5.00009 20.2283 5.44781 20.676 6.00009 20.676H18C18.5523 20.676 19 20.2283 19 19.676V8.58384Z" fill="#AAAAAA"/>
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5881 8.47872L12.6018 0.201315C12.2455 -0.0671053 11.7545 -0.067105 11.3982 0.201316L0.411896 8.47872C-0.0347535 8.81524 -0.133503 9.46314 0.191333 9.92585C0.51617 10.3886 1.14158 10.4909 1.58823 10.1543L3.0001 9.09061L4.0001 19.7479C4.0001 20.8525 4.89554 21.7479 6.00011 21.7479H18C19.1046 21.7479 20 20.8525 20 19.7479L21 9.09068L22.4118 10.1543C22.8584 10.4909 23.4838 10.3886 23.8087 9.92585C24.1335 9.46314 24.0348 8.81524 23.5881 8.47872ZM19 7.58384L12.6017 2.76323C12.2455 2.4948 11.7545 2.49481 11.3983 2.76323L5.00009 7.58376L6.00009 18.676C6.00009 19.2283 6.44781 19.676 7.00009 19.676H17C17.5523 19.676 18 19.2283 18 18.676L19 7.58384Z" fill="#AAAAAA"/>
</svg>

Before

Width:  |  Height:  |  Size: 795 B

After

Width:  |  Height:  |  Size: 821 B

View File

@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5881 9.47872L12.6018 1.20132C12.2455 0.932895 11.7545 0.932895 11.3982 1.20132L0.411896 9.47872C-0.0347535 9.81524 -0.133503 10.4631 0.191333 10.9259C0.51617 11.3886 1.14158 11.4909 1.58823 11.1543L3.0001 10.0906V20.7479C3.0001 21.8525 3.89554 22.7479 5.00011 22.7479H19C20.1046 22.7479 21 21.8525 21 20.7479V10.0907L22.4118 11.1543C22.8584 11.4909 23.4838 11.3886 23.8087 10.9259C24.1335 10.4631 24.0348 9.81524 23.5881 9.47872Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5881 9.47872L12.6018 1.20132C12.2455 0.932895 11.7545 0.932895 11.3982 1.20132L0.411896 9.47872C-0.0347535 9.81524 -0.133503 10.4631 0.191333 10.9259C0.51617 11.3886 1.14158 11.4909 1.58823 11.1543L3.0001 10.0906L4.0001 20.7479C4.0001 21.8525 4.89554 22.7479 6.00011 22.7479H18C19.1046 22.7479 20 21.8525 20 20.7479L21 10.0907L22.4118 11.1543C22.8584 11.4909 23.4838 11.3886 23.8087 10.9259C24.1335 10.4631 24.0348 9.81524 23.5881 9.47872Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 602 B

After

Width:  |  Height:  |  Size: 612 B

View File

@ -0,0 +1,10 @@
<svg width="14" height="20" viewBox="0 0 14 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9211 7.71733C14.1194 7.45376 13.921 7.0876 13.5799 7.0876H8.66235L9.6442 0.953049C9.81526 0.125008 8.6907 -0.353895 8.15325 0.318065L0.0861641 11.5986C-0.123564 11.8608 0.07342 12.2377 0.420148 12.2377H5.30747L4.28387 19.0603C4.12666 19.8963 5.27231 20.3544 5.79207 19.6633L13.9211 7.71733Z" fill="url(#paint0_linear_6181_952)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9211 7.71733C14.1194 7.45376 13.921 7.0876 13.5799 7.0876H8.66235L9.6442 0.953049C9.81526 0.125008 8.6907 -0.353895 8.15325 0.318065L0.0861641 11.5986C-0.123564 11.8608 0.07342 12.2377 0.420148 12.2377H5.30747L4.28387 19.0603C4.12666 19.8963 5.27231 20.3544 5.79207 19.6633L13.9211 7.71733Z" fill="#FA9011"/>
<defs>
<linearGradient id="paint0_linear_6181_952" x1="4.55" y1="1.37931" x2="10.0011" y2="18.3238" gradientUnits="userSpaceOnUse">
<stop offset="0.078125" stop-color="#FFD12F"/>
<stop offset="0.860784" stop-color="#FF9F2F"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,10 @@
<svg width="14" height="20" viewBox="0 0 14 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9211 7.71733C14.1194 7.45376 13.921 7.0876 13.5799 7.0876H8.66235L9.6442 0.953049C9.81526 0.125008 8.6907 -0.353895 8.15325 0.318065L0.0861641 11.5986C-0.123564 11.8608 0.07342 12.2377 0.420148 12.2377H5.30747L4.28387 19.0603C4.12666 19.8963 5.27231 20.3544 5.79207 19.6633L13.9211 7.71733Z" fill="url(#paint0_linear_6273_8209)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9211 7.71733C14.1194 7.45376 13.921 7.0876 13.5799 7.0876H8.66235L9.6442 0.953049C9.81526 0.125008 8.6907 -0.353895 8.15325 0.318065L0.0861641 11.5986C-0.123564 11.8608 0.07342 12.2377 0.420148 12.2377H5.30747L4.28387 19.0603C4.12666 19.8963 5.27231 20.3544 5.79207 19.6633L13.9211 7.71733Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_6273_8209" x1="4.55" y1="1.37931" x2="10.0011" y2="18.3238" gradientUnits="userSpaceOnUse">
<stop offset="0.078125" stop-color="#FFD12F"/>
<stop offset="0.860784" stop-color="#FF9F2F"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,3 +1,3 @@
<svg width="25" height="20" viewBox="0 0 25 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.00292969 2C0.00292969 0.895431 0.89836 0 2.00293 0H22.0029C23.1075 0 24.0029 0.895431 24.0029 2V18C24.0029 19.1046 23.1075 20 22.0029 20H2.00293C0.89836 20 0.00292969 19.1046 0.00292969 18V2ZM2.54295 2.44324C2.36149 2.2943 2.46681 2 2.70156 2H21.3043C21.5391 2 21.6444 2.2943 21.4629 2.44324L13.2718 9.16644C12.5342 9.77186 11.4716 9.77186 10.734 9.16644L2.54295 2.44324ZM2.81955 5.27839C2.49308 5.01128 2.00293 5.24355 2.00293 5.66536V17C2.00293 17.5523 2.45064 18 3.00293 18H21.0029C21.5552 18 22.0029 17.5523 22.0029 17V5.66536C22.0029 5.24355 21.5128 5.01128 21.1863 5.27839L13.2694 11.7559C12.5327 12.3586 11.4732 12.3586 10.7365 11.7559L2.81955 5.27839Z" fill="#AAAAAA"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 6C0 3.79086 1.79086 2 4 2H20C22.2091 2 24 3.79086 24 6V18C24 20.2091 22.2091 22 20 22H4C1.79086 22 0 20.2091 0 18V6ZM4 4H20C20.468 4 20.8984 4.16073 21.2391 4.43C21.4584 4.60331 21.4094 4.92762 21.1838 5.09271L12.5906 11.3824C12.239 11.6398 11.7611 11.6398 11.4094 11.3824L2.81618 5.0927C2.59063 4.92761 2.5416 4.6033 2.76089 4.42999C3.10159 4.16073 3.53203 4 4 4ZM2.39648 7.24156C2.23116 7.12202 2 7.24014 2 7.44415V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V7.44416C22 7.24016 21.7688 7.12204 21.6035 7.24157L13.1719 13.338C12.4725 13.8437 11.5276 13.8437 10.8282 13.338L2.39648 7.24156Z" fill="#AAAAAA"/>
</svg>

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 780 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.398299 6.69353C0.233233 6.5719 0 6.68976 0 6.8948V18C0 20.2091 1.79086 22 4 22H20C22.2091 22 24 20.2091 24 18V6.8948C24 6.68976 23.7668 6.5719 23.6017 6.69353L13.1864 14.368C12.4809 14.8878 11.5191 14.8878 10.8136 14.368L0.398299 6.69353Z" fill="white"/>
<path d="M23.2462 4.47121C23.4416 4.32719 23.5082 4.06152 23.3779 3.85665C22.6682 2.74054 21.4206 2 20 2H4C2.57942 2 1.33179 2.74054 0.622116 3.85665C0.491845 4.06152 0.558382 4.32719 0.753839 4.47121L11.4068 12.3208C11.7596 12.5807 12.2404 12.5807 12.5932 12.3208L23.2462 4.47121Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

View File

@ -0,0 +1,5 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="34" cy="34" r="34" fill="white"/>
<circle cx="34" cy="34" r="30" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.3873 30.576C44.6706 30.1806 44.3872 29.6314 43.8998 29.6314H36.8748L38.2774 20.4296C38.5218 19.1875 36.9153 18.4692 36.1475 19.4771L24.6231 36.3979C24.3235 36.7912 24.6049 37.3566 25.1002 37.3566H32.0821L30.6198 47.5905C30.3952 48.8445 32.0319 49.5315 32.7744 48.495L44.3873 30.576Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 547 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

View File

@ -34,6 +34,23 @@
border-radius: 50%;
}
.microAvatar {
@include avatar;
width: 22px;
height: 22px;
.missingBack {
width: 22px;
height: 22px;
}
.iconBackground {
@include iconBackground;
bottom: -6px;
right: -6px;
}
}
.xxsAvatar {
@include avatar;
width: 28px;
@ -217,6 +234,13 @@
mask-size: contain;
}
.microMissing {
@include missing;
width: 22px;
height: 22px;
font-size: 10px;
}
.xxsMissing {
@include missing;
width: 28px;

View File

@ -11,7 +11,7 @@ import styles from './Avatar.module.scss';
const Avatar: Component<{
src?: string | undefined,
size?: "xxs" | "xss" | "xs" | "vvs" | "vs" | "sm" | "md" | "lg" | "xl" | "xxl",
size?: "micro" | "xxs" | "xss" | "xs" | "vvs" | "vs" | "sm" | "md" | "lg" | "xl" | "xxl",
user?: PrimalUser,
highlightBorder?: boolean,
id?: string,
@ -26,6 +26,7 @@ const Avatar: Component<{
const selectedSize = props.size || 'sm';
const avatarClass = {
micro: styles.microAvatar,
xxs: styles.xxsAvatar,
xss: styles.xssAvatar,
xs: styles.xsAvatar,
@ -39,6 +40,7 @@ const Avatar: Component<{
};
const missingClass = {
micro: styles.microAvatar,
xxs: styles.xxsMissing,
xss: styles.xssMissing,
xs: styles.xsMissing,
@ -77,6 +79,7 @@ const Avatar: Component<{
let size: MediaSize = 'm';
switch (selectedSize) {
case 'micro':
case 'xxs':
case 'xss':
case 'xs':

View File

@ -0,0 +1,39 @@
.bookmark {
.emptyBookmark {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px 0px;
background-color: var(--text-tertiary);
-webkit-mask: url(../../assets/icons/bookmark_empty.svg) no-repeat 0 / auto 100%;
mask: url(../../assets/icons/bookmark_empty.svg) no-repeat 0 / auto 100%;
&.large {
width: 22px;
height: 22px;
}
}
.fullBookmark {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px 0px;
background-color: var(--active-bookmarked);
-webkit-mask: url(../../assets/icons/bookmark_filled.svg) no-repeat 0 / auto 100%;
mask: url(../../assets/icons/bookmark_filled.svg) no-repeat 0 / auto 100%;
&.large {
width: 22px;
height: 22px;
}
}
&:hover {
.emptyBookmark {
background-color: var(--active-bookmarked);
-webkit-mask: url(../../assets/icons/bookmark_filled.svg) no-repeat 0 / auto 100%;
mask: url(../../assets/icons/bookmark_filled.svg) no-repeat 0 / auto 100%;
}
}
}

View File

@ -0,0 +1,160 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, createSignal, Match, Show, Switch } from 'solid-js';
import { APP_ID } from '../../App';
import { Kind } from '../../constants';
import { useAccountContext } from '../../contexts/AccountContext';
import { useAppContext } from '../../contexts/AppContext';
import { getUserFeed } from '../../lib/feed';
import { logWarning } from '../../lib/logger';
import { getBookmarks, sendBookmarks } from '../../lib/profile';
import { subscribeTo } from '../../sockets';
import { PrimalNote } from '../../types/primal';
import ButtonGhost from '../Buttons/ButtonGhost';
import { account, bookmarks as tBookmarks } from '../../translations';
import styles from './BookmarkNote.module.scss';
import { saveBookmarks } from '../../lib/localStore';
import { importEvents, triggerImportEvents } from '../../lib/notes';
const BookmarkNote: Component<{ note: PrimalNote, large?: boolean }> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
const [isBookmarked, setIsBookmarked] = createSignal(false);
const [bookmarkInProgress, setBookmarkInProgress] = createSignal(false);
createEffect(() => {
setIsBookmarked(() => account?.bookmarks.includes(props.note.post.id) || false);
})
const updateBookmarks = async (bookmarkTags: string[][]) => {
if (!account) return;
const bookmarks = bookmarkTags.reduce((acc, t) =>
t[0] === 'e' ? [...acc, t[1]] : [...acc]
, []);
const date = Math.floor((new Date()).getTime() / 1000);
account.actions.updateBookmarks(bookmarks)
saveBookmarks(account.publicKey, bookmarks);
const { success, note} = await sendBookmarks([...bookmarkTags], date, '', account?.relays, account?.relaySettings);
if (success && note) {
triggerImportEvents([note], `bookmark_import_${APP_ID}`)
}
};
const addBookmark = async (bookmarkTags: string[][]) => {
if (account && !bookmarkTags.find(b => b[0] === 'e' && b[1] === props.note.post.id)) {
const bookmarksToAdd = [...bookmarkTags, ['e', props.note.post.id]];
if (bookmarksToAdd.length < 2) {
logWarning('BOOKMARK ISSUE: ', `before_bookmark_${APP_ID}`);
app?.actions.openConfirmModal({
title: intl.formatMessage(tBookmarks.confirm.title),
description: intl.formatMessage(tBookmarks.confirm.description),
confirmLabel: intl.formatMessage(tBookmarks.confirm.confirm),
abortLabel: intl.formatMessage(tBookmarks.confirm.abort),
onConfirm: async () => {
await updateBookmarks(bookmarksToAdd);
app.actions.closeConfirmModal();
},
onAbort: app.actions.closeConfirmModal,
})
return;
}
await updateBookmarks(bookmarksToAdd);
}
}
const removeBookmark = async (bookmarks: string[][]) => {
if (account && bookmarks.find(b => b[0] === 'e' && b[1] === props.note.post.id)) {
const bookmarksToAdd = bookmarks.filter(b => b[0] !== 'e' || b[1] !== props.note.post.id);
if (bookmarksToAdd.length < 1) {
logWarning('BOOKMARK ISSUE: ', `before_bookmark_${APP_ID}`);
app?.actions.openConfirmModal({
title: intl.formatMessage(tBookmarks.confirm.titleZero),
description: intl.formatMessage(tBookmarks.confirm.descriptionZero),
confirmLabel: intl.formatMessage(tBookmarks.confirm.confirmZero),
abortLabel: intl.formatMessage(tBookmarks.confirm.abortZero),
onConfirm: async () => {
await updateBookmarks(bookmarksToAdd);
app.actions.closeConfirmModal();
},
onAbort: app.actions.closeConfirmModal,
})
return;
}
await updateBookmarks(bookmarksToAdd);
}
}
const doBookmark = (remove: boolean, then?: () => void) => {
if (!account?.publicKey) {
return;
}
let bookmarks: string[][] = []
const unsub = subscribeTo(`before_bookmark_${APP_ID}`, async (type, subId, content) => {
if (type === 'EOSE') {
if (remove) {
await removeBookmark(bookmarks);
}
else {
await addBookmark(bookmarks);
}
then && then();
setBookmarkInProgress(() => false);
unsub();
return;
}
if (type === 'EVENT') {
if (!content || content.kind !== Kind.Bookmarks) return;
bookmarks = content.tags;
}
});
setBookmarkInProgress(() => true);
getBookmarks(account.publicKey, `before_bookmark_${APP_ID}`);
}
return (
<div class={styles.bookmark}>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault();
doBookmark(isBookmarked());
}}
disabled={bookmarkInProgress()}
>
<Show
when={isBookmarked()}
fallback={
<div class={`${styles.emptyBookmark} ${props.large ? styles.large : ''}`}></div>
}
>
<div class={`${styles.fullBookmark} ${props.large ? styles.large : ''}`}></div>
</Show>
</ButtonGhost>
</div>
)
}
export default BookmarkNote;

View File

@ -0,0 +1,336 @@
.cashu {
width: 100%;
min-height: 158px;
background-color: var(--background-header-input);
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
position: relative;
.paymentOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-site);
opacity: 0.6;
display: flex;
justify-content: center;
align-items: center;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 20px;
.title {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
color: var(--text-primary);
font-size: 15px;
font-weight: 700;
line-height: 16px;
}
.headerActions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 2px;
button {
.qrIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
}
.copyIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
}
&:hover {
.qrIcon, .copyIcon {
background-color: var(--text-primary);
}
}
}
.copyDone {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
.checkIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--success-bright);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
}
}
}
}
.body {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 8px;
.description {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
}
.amount {
color: var(--text-primary);
font-size: 24px;
font-weight: 600;
line-height: 24px;
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
.mint {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
display: flex;
button {
width: 100%;
height: 100%;
}
}
.spent {
height: 36px;
min-width: 120px;
margin-right: 12px;
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
}
&.noBack {
background-color: unset;
}
.cashuIcon {
width: 20px;
height: 20px;
background-image: url('../../assets/icons/cashu.svg');
background-size: contain;
background-repeat: no-repeat;
}
}
.cashuAlter {
width: 100%;
min-height: 158px;
background-color: none;
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
position: relative;
.paymentOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-site);
opacity: 0.6;
display: flex;
justify-content: center;
align-items: center;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 20px;
.title {
display: flex;
justify-content: flex-start;
align-items: center;
color: white;
font-size: 15px;
font-weight: 700;
line-height: 16px;
}
.headerActions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 2px;
button {
.qrIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: white;
-webkit-mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
}
.copyIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: white;
-webkit-mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
}
&:hover {
.qrIcon, .copyIcon {
background-color: white;
}
}
}
.copyDone {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
.checkIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--success-bright);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
}
}
}
}
.body {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 8px;
.description {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
}
.amount {
color: white;
font-size: 24px;
font-weight: 600;
line-height: 24px;
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
.expiryDate {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.expiredDate {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
color: var(--accent);
background-color: white;
}
}
}
&.noBack {
background-color: unset;
}
.cashuIcon {
width: 20px;
height: 20px;
background-image: url('../../assets/icons/cashu.svg');
background-size: contain;
background-repeat: no-repeat;
}
}

View File

@ -0,0 +1,183 @@
import { Component, createEffect, createSignal, Match, onMount, Show, Switch } from 'solid-js';
import { hookForDev } from '../../lib/devTools';
import styles from './Cashu.module.scss';
import { createStore } from 'solid-js/store';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonGhost from '../Buttons/ButtonGhost';
import { useAppContext } from '../../contexts/AppContext';
import Loader from '../Loader/Loader';
import { logError } from '../../lib/logger';
import { useIntl } from '@cookbook/solid-intl';
import { cashuInvoice } from '../../translations';
import { getDecodedToken, Token, TokenEntry } from "@cashu/cashu-ts";
import { useAccountContext } from '../../contexts/AccountContext';
const Cashu: Component< { id?: string, token: string, alternative?: boolean, noBack?: boolean } > = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
const [invoice, setInvoice] = createStore<Token>({ token: [] });
const [cashuSpendable, setCashuSpendable] = createSignal<boolean>(false);
const [invoiceCopied, setInvoiceCopied] = createSignal(false);
const [paymentInProgress, setPaymentInProgress] = createSignal(false);
const checkMints = async (entries: TokenEntry[]) => {
let statuses: boolean[] = [];
for (const entry of entries) {
const mint = app?.actions.getCashuMint(entry.mint);
if (!mint) continue;
const spent = await mint.check({ proofs: entry.proofs.map((p) => ({ secret: p.secret })) });
const data = spent.spendable.map(s => s);
statuses = [ ...statuses, ...data];
}
setCashuSpendable(() => !statuses.includes(false));
}
createEffect(() => {
if (invoice.token.length === 0) return;
checkMints(invoice.token);
});
createEffect(() => {
try {
const dec: Token = getDecodedToken(props.token);
setInvoice(() => ({ ...dec }));
} catch (e) {
logError('Failed to decode cashu token: ', e);
}
});
createEffect(() => {
if (invoiceCopied()) {
setTimeout(() => {
setInvoiceCopied(() => false);
}, 1_000);
}
})
const amount = () =>
`${invoice.token[0]?.proofs.reduce((acc, v) => acc + v.amount, 0) || 0} sats`;
const description = () => invoice.memo || '';
const confirmPayment = () => app?.actions.openConfirmModal({
title: intl.formatMessage(cashuInvoice.confirm.title),
description: intl.formatMessage(cashuInvoice.confirm.description, { amount: amount() }),
confirmLabel: intl.formatMessage(cashuInvoice.confirm.confirmLabel),
abortLabel: intl.formatMessage(cashuInvoice.confirm.abortLabel),
onAbort: app.actions.closeConfirmModal,
onConfirm: () => {
app.actions.closeConfirmModal();
redeemCashu();
},
});
const redeemCashu = () => {
const lnurl = account?.activeUser?.lud16 ?? '';
const url = `https://redeem.cashu.me?token=${encodeURIComponent(props.token)}&lightning=${encodeURIComponent(
lnurl,
)}&autopay=yes`;
window.open(url, 'blank_');
};
const klass = () => {
let k = props.alternative ? styles.cashuAlter : styles.cashu;
if (props.noBack) {
k += ` ${styles.noBack}`
}
return k;
}
return (
<div id={props.id} class={klass()}>
<Show when={paymentInProgress()}>
<div class={styles.paymentOverlay}>
<Loader />
</div>
</Show>
<div class={styles.header}>
<div class={styles.title}>
<div class={styles.cashuIcon}></div>
<div>{intl.formatMessage(cashuInvoice.title)}</div>
</div>
<div class={styles.headerActions}>
<Show when={cashuSpendable()}>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault();
app?.actions.openCashuModal(props.token, () => {
app.actions.closeCashuModal();
confirmPayment();
});
}}
shrink={true}
>
<div class={styles.qrIcon}></div>
</ButtonGhost>
</Show>
<Show
when={!invoiceCopied()}
fallback={<div class={styles.copyDone}><div class={styles.checkIcon}></div></div>}
>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault()
navigator.clipboard.writeText(props.token);
setInvoiceCopied(() => true);
}}
shrink={true}
>
<div class={styles.copyIcon}></div>
</ButtonGhost>
</Show>
</div>
</div>
<div class={styles.body}>
<div class={styles.description}>{description()}</div>
<div class={styles.amount}>{amount()}</div>
</div>
<div class={styles.footer}>
<div class={styles.mint}>
<Show when={invoice.token[0]}>
{intl.formatMessage(cashuInvoice.mint, { url: new URL(invoice.token[0]?.mint).hostname })}
</Show>
</div>
<Show
when={cashuSpendable()}
fallback={(
<div class={styles.spent}>
{intl.formatMessage(cashuInvoice.spent)}
</div>
)}
>
<div class={styles.payAction}>
<ButtonPrimary onClick={(e: MouseEvent) => {
e.preventDefault();
confirmPayment();
}}>
{intl.formatMessage(cashuInvoice.redeem)}
</ButtonPrimary>
</div>
</Show>
</div>
</div>
);
}
export default hookForDev(Cashu);

View File

@ -0,0 +1,120 @@
.CashuQrCodeModal {
position: fixed;
min-width: 472px;
color: var(--text-primary);
background-color: var(--background-input);
border-radius: 8px;
display: flex;
flex-direction: column;
padding: 20px 24px 28px 24px;
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 24px;
.title {
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
line-height: 20px;
}
.close {
border: none;
outline: none;
padding: 0;
margin: 0;
box-shadow: none;
width: 20px;
height: 20px;
display: inline-block;
margin: 0px 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
mask: url(../../assets/icons/close.svg) no-repeat center;
&:hover {
background-color: var(--text-primary);
}
}
}
}
.body {
display: flex;
flex-direction: column;
gap: 12px;
.description {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
display: flex;
justify-content: center;
align-items: center;
text-align: left;
width: 100%;
}
.amount {
color: var(--text-primary);
font-size: 24px;
font-weight: 600;
line-height: 24px;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.separator {
width: 100%;
height: 1px;
border: 1px solid var(--subtile-devider);
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 20px;
.mint {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
}
}
}
.zapIcon {
width: 22px;
height: 22px;
display: inline-block;
margin-right: 9px;
background: var(--sidebar-section-icon-gradient);
-webkit-mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px;
mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px;
}

View File

@ -0,0 +1,83 @@
import { useIntl } from '@cookbook/solid-intl';
// @ts-ignore
import { decode } from 'light-bolt11-decoder';
import { Component, createEffect, Show } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { emptyInvoice } from '../../constants';
import { date, dateFuture } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { humanizeNumber } from '../../lib/stats';
import { cashuInvoice } from '../../translations';
import { LnbcInvoice } from '../../types/primal';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import QrCode from '../QrCode/QrCode';
import { getDecodedToken, Token } from "@cashu/cashu-ts";
import styles from './CashuQrCodeModal.module.scss';
const CashuQrCodeModal: Component<{
id?: string,
open?: boolean,
cashu: string | undefined,
onPay?: () => void,
onClose?: () => void,
}> = (props) => {
const intl = useIntl();
const [invoice, setInvoice] = createStore<Token>({ token: []});
createEffect(() => {
if (props.cashu) {
const dec: Token = getDecodedToken(props.cashu);
setInvoice(reconcile(dec));
} else {
setInvoice(reconcile({ token: [] }));
}
});
const amount = () =>
`${invoice.token[0]?.proofs.reduce((acc, v) => acc + v.amount, 0) || 0} sats`;
const description = () => invoice.memo || '';
return (
<Modal open={props.open} onClose={props.onClose}>
<div id={props.id} class={styles.CashuQrCodeModal}>
<div class={styles.header}>
<div class={styles.title}>
{intl.formatMessage(cashuInvoice.title)}
</div>
<button class={styles.close} onClick={props.onClose}>
</button>
</div>
<div class={styles.body}>
<div class={styles.qrCode}>
<QrCode data={props.cashu || ''} type="cashu"/>
</div>
<div class={styles.description}>{description()}</div>
<div class={styles.amount}>{amount()}</div>
<div class={styles.separator}></div>
</div>
<div class={styles.footer}>
<div class={styles.mint}>
<Show when={invoice.token[0]}>
{intl.formatMessage(cashuInvoice.mint, { url: new URL(invoice.token[0]?.mint).hostname })}
</Show>
</div>
<div class={styles.payAction}>
<ButtonPrimary onClick={props.onPay}>
{intl.formatMessage(cashuInvoice.redeem)}
</ButtonPrimary>
</div>
</div>
</div>
</Modal>
);
}
export default hookForDev(CashuQrCodeModal);

View File

@ -11,6 +11,7 @@ import { PrimalNote, PrimalUser, ZapOption } from '../../types/primal';
import { debounce } from '../../utils';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import { lottieDuration } from '../Note/NoteFooter/NoteFooter';
import TextInput from '../TextInput/TextInput';
import { useToastContext } from '../Toaster/Toaster';
@ -33,7 +34,6 @@ const CustomZap: Component<{
const settings = useSettingsContext();
const [selectedValue, setSelectedValue] = createSignal(settings?.availableZapOptions[0] || defaultZapOptions[0]);
const [comment, setComment] = createSignal(defaultZapOptions[0].message || '');
createEffect(() => {
setSelectedValue(settings?.availableZapOptions[0] || defaultZapOptions[0])
@ -48,12 +48,16 @@ const CustomZap: Component<{
const amount = parseInt(value.replaceAll(',', ''));
if (isNaN(amount)) {
setSelectedValue(() => ({ amount: 0 }))
setSelectedValue((v) => ({ ...v, amount: 0 }))
};
setSelectedValue(()=> ({ amount }));
setSelectedValue((v)=> ({ ...v, amount }));
};
const updateComment = (message: string) => {
setSelectedValue((v) => ({ ...v, message }))
}
const truncateNumber = (amount: number) => {
const t = 1000;
@ -92,43 +96,75 @@ const CustomZap: Component<{
if (account?.hasPublicKey()) {
props.onConfirm(selectedValue());
let success = false;
const note = props.note;
if (props.note) {
success = await zapNote(
props.note,
account.publicKey,
selectedValue().amount || 0,
comment(),
account.relays,
);
}
else if (props.profile) {
success = await zapProfile(
props.profile,
account.publicKey,
selectedValue().amount || 0,
comment(),
account.relays,
);
}
if (note) {
setTimeout(async () => {
const success = await zapNote(
note,
account.publicKey,
selectedValue().amount || 0,
selectedValue().message,
account.relays,
);
if (success) {
props.onSuccess(selectedValue());
handleZap(success);
}, lottieDuration());
return;
}
toast?.sendWarning(
intl.formatMessage(toastZapFail),
);
if (props.profile) {
const success = await zapProfile(
props.profile,
account.publicKey,
selectedValue().amount || 0,
selectedValue().message,
account.relays,
);
props.onFail(selectedValue())
handleZap(success);
return;
}
}
};
const handleZap = (success = false) => {
if (success) {
props.onSuccess(selectedValue());
return;
}
toast?.sendWarning(
intl.formatMessage(toastZapFail),
);
props.onFail(selectedValue());
};
let md = false;
return (
<Modal open={props.open} onClose={() => props.onCancel({ amount: 0, message: '' })}>
<div id={props.id} class={styles.customZap}>
<Modal
open={props.open}
onClose={() => {
if (md) {
md = false;
}
else {
props.onCancel({ amount: 0, message: '' });
}
}}
>
<div
id={props.id}
class={styles.customZap}
onMouseUp={() => md = false}
onMouseDown={() => md = true}
onClick={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div class={styles.header}>
<div class={styles.title}>
<div class={styles.caption}>
@ -156,8 +192,7 @@ const CustomZap: Component<{
<button
class={`${styles.zapOption} ${isSelected(value) ? styles.selected : ''}`}
onClick={() => {
setComment(value.message || '')
setSelectedValue(value);
setSelectedValue(() => ({...value}));
}}
>
<div>
@ -190,9 +225,9 @@ const CustomZap: Component<{
<TextInput
type="text"
value={comment()}
value={selectedValue().message || ''}
placeholder={intl.formatMessage(tPlaceholders.addComment)}
onChange={setComment}
onChange={updateComment}
noExtraSpace={true}
/>

View File

@ -17,6 +17,10 @@
text-decoration: none !important;
}
&.altBack {
background-color: var(--background-sheet);
}
.mentionedNoteHeader {
display: flex;
justify-content: flex-start;

View File

@ -15,7 +15,13 @@ import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './EmbeddedNote.module.scss';
const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string, PrimalUser>, includeEmbeds?: boolean, isLast?: boolean}> = (props) => {
const EmbeddedNote: Component<{
note: PrimalNote,
mentionedUsers?: Record<string, PrimalUser>,
includeEmbeds?: boolean,
isLast?: boolean,
alternativeBackground?: boolean,
}> = (props) => {
const threadContext = useThreadContext();
const intl = useIntl();
@ -32,11 +38,20 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string
return trimVerification(props.note.user?.nip05);
});
const klass = () => {
let k = styles.mentionedNote;
k += ' embeddedNote';
if (props.isLast) k+= ' noBottomMargin';
if (props.alternativeBackground) k+= ` ${styles.altBack}`;
return k;
}
const wrapper = (children: JSXElement) => {
if (props.includeEmbeds) {
return (
<div
class={`${styles.mentionedNote} embeddedNote ${props.isLast ? 'noBottomMargin' : ''}`}
class={klass()}
data-event={props.note.post.id}
data-event-bech32={noteId()}
>
@ -48,7 +63,7 @@ const EmbeddedNote: Component<{ note: PrimalNote, mentionedUsers?: Record<string
return (
<A
href={`/e/${noteId()}`}
class={`${styles.mentionedNote} embeddedNote ${props.isLast ? 'noBottomMargin' : ''}`}
class={klass()}
onClick={() => navToThread()}
data-event={props.note.post.id}
data-event-bech32={noteId()}

View File

@ -7,7 +7,6 @@ import NavMenu from '../NavMenu/NavMenu';
import ProfileWidget from '../ProfileWidget/ProfileWidget';
import NewNote from '../NewNote/NewNote';
import { useAccountContext } from '../../contexts/AccountContext';
import zapSM from '../../assets/lottie/zap_sm.json';
import zapMD from '../../assets/lottie/zap_md.json';
import { useHomeContext } from '../../contexts/HomeContext';
import { SendNoteResult } from '../../types/primal';
@ -15,11 +14,13 @@ import { useProfileContext } from '../../contexts/ProfileContext';
import Branding from '../Branding/Branding';
import BannerIOS, { isIOS } from '../BannerIOS/BannerIOS';
import ZapAnimation from '../ZapAnimation/ZapAnimation';
import Landing from '../../pages/Landing';
import ReactionsModal from '../ReactionsModal/ReactionsModal';
import { useAppContext } from '../../contexts/AppContext';
import CustomZap from '../CustomZap/CustomZap';
import NoteContextMenu from '../Note/NoteContextMenu';
import LnQrCodeModal from '../LnQrCodeModal/LnQrCodeModal';
import ConfirmModal from '../ConfirmModal/ConfirmModal';
import CashuQrCodeModal from '../CashuQrCodeModal/CashuQrCodeModal';
export const [isHome, setIsHome] = createSignal(false);
@ -90,7 +91,7 @@ const Layout: Component = () => {
}
createEffect(() => {
if (location.pathname === '/' || account?.isKeyLookupDone) return;
if (location.pathname === '/') return;
account?.actions.checkNostrKey();
});
@ -166,6 +167,30 @@ const Layout: Component = () => {
onFail={app?.customZap?.onFail}
onCancel={app?.customZap?.onCancel}
/>
<LnQrCodeModal
open={app?.showLnInvoiceModal}
lnbc={app?.lnbc?.invoice || ''}
onPay={app?.lnbc?.onPay}
onClose={app?.lnbc?.onCancel}
/>
<CashuQrCodeModal
open={app?.showCashuInvoiceModal}
cashu={app?.cashu?.invoice || ''}
onPay={app?.cashu?.onPay}
onClose={app?.cashu?.onCancel}
/>
<ConfirmModal
open={app?.showConfirmModal}
title={app?.confirmInfo?.title}
description={app?.confirmInfo?.description}
confirmLabel={app?.confirmInfo?.confirmLabel}
abortLabel={app?.confirmInfo?.abortLabel}
onConfirm={app?.confirmInfo?.onConfirm}
onAbort={app?.confirmInfo?.onAbort}
/>
</div>
</Show>
</div>

View File

@ -1,9 +1,11 @@
import { Component, createMemo, Show } from 'solid-js';
import { Component, createMemo, createSignal, Show } from 'solid-js';
import { useMediaContext } from '../../contexts/MediaContext';
import { hookForDev } from '../../lib/devTools';
import styles from './LinkPreview.module.scss';
const errorCountLimit = 3;
const LinkPreview: Component<{ preview: any, id?: string, bordered?: boolean, isLast?: boolean }> = (props) => {
const media = useMediaContext();
@ -49,6 +51,17 @@ const LinkPreview: Component<{ preview: any, id?: string, bordered?: boolean, is
return k;
};
const [errorCount, setErrorCount] = createSignal(0);
const onError = (event: any) => {
if (errorCount() > errorCountLimit) return;
setErrorCount(v => v + 1);
const image = event.target;
image.onerror = '';
image.src = props.preview.images[0];
return true;
};
return (
<a
id={props.id}
@ -56,11 +69,12 @@ const LinkPreview: Component<{ preview: any, id?: string, bordered?: boolean, is
class={klass()}
target="_blank"
>
<Show when={image()}>
<Show when={errorCount() < errorCountLimit && (image() || props.preview.images[0])}>
<img
class={styles.previewImage}
src={image()?.media_url}
src={image()?.media_url || props.preview.images[0]}
style={`width: 100%; height: ${height()}`}
onerror={onError}
/>
</Show>

View File

@ -0,0 +1,128 @@
.LnQrCodeModal {
position: fixed;
min-width: 472px;
color: var(--text-primary);
background-color: var(--background-input);
border-radius: 8px;
display: flex;
flex-direction: column;
padding: 20px 24px 28px 24px;
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 24px;
.title {
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
line-height: 20px;
}
.close {
border: none;
outline: none;
padding: 0;
margin: 0;
box-shadow: none;
width: 20px;
height: 20px;
display: inline-block;
margin: 0px 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
mask: url(../../assets/icons/close.svg) no-repeat center;
&:hover {
background-color: var(--text-primary);
}
}
}
}
.body {
display: flex;
flex-direction: column;
gap: 12px;
.description {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
display: flex;
justify-content: center;
align-items: center;
text-align: left;
width: 100%;
}
.amount {
color: var(--text-primary);
font-size: 24px;
font-weight: 600;
line-height: 24px;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.separator {
width: 100%;
height: 1px;
border: 1px solid var(--subtile-devider);
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 20px;
.expiryDate {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.expiredDate {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
}
}
}
.zapIcon {
width: 22px;
height: 22px;
display: inline-block;
margin-right: 9px;
background: var(--sidebar-section-icon-gradient);
-webkit-mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px;
mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px;
}

View File

@ -0,0 +1,88 @@
import { useIntl } from '@cookbook/solid-intl';
// @ts-ignore
import { decode } from 'light-bolt11-decoder';
import { Component, createEffect } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { emptyInvoice } from '../../constants';
import { date, dateFuture } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { humanizeNumber } from '../../lib/stats';
import { lnInvoice } from '../../translations';
import { LnbcInvoice } from '../../types/primal';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import QrCode from '../QrCode/QrCode';
import styles from './LnQrCodeModal.module.scss';
const LnQrCodeModal: Component<{
id?: string,
open?: boolean,
lnbc: string | undefined,
onPay?: () => void,
onClose?: () => void,
}> = (props) => {
const intl = useIntl();
const [invoice, setInvoice] = createStore<LnbcInvoice>(emptyInvoice);
createEffect(() => {
if (props.lnbc) {
const dec: LnbcInvoice = decode(props.lnbc);
setInvoice(reconcile(dec));
} else {
setInvoice(reconcile(emptyInvoice));
}
});
const expiryDate = () => {
const expiry = invoice.sections.find(s => s.name === 'expiry')?.value as number;
const created = invoice.sections.find(s => s.name === 'timestamp')?.value as number;
return expiry + created;
}
const amount = () =>
`${humanizeNumber(parseInt(invoice.sections.find(s => s.name === 'amount')?.value ||'0') / 1_000)} sats`;
const description = () =>
invoice.sections.find(s => s.name === 'description')?.value;
return (
<Modal open={props.open} onClose={props.onClose}>
<div id={props.id} class={styles.LnQrCodeModal}>
<div class={styles.header}>
<div class={styles.title}>
{intl.formatMessage(lnInvoice.title)}
</div>
<button class={styles.close} onClick={props.onClose}>
</button>
</div>
<div class={styles.body}>
<div class={styles.qrCode}>
<QrCode data={props.lnbc || ''} type="lightning"/>
</div>
<div class={styles.description}>{description()}</div>
<div class={styles.amount}>{amount()}</div>
<div class={styles.separator}></div>
</div>
<div class={styles.footer}>
<div class={styles.expiryDate}>
{intl.formatMessage(lnInvoice.expires, { date: dateFuture(expiryDate(), 'long').label })}
</div>
<div class={styles.payAction}>
<ButtonPrimary onClick={props.onPay}>
{intl.formatMessage(lnInvoice.pay)}
</ButtonPrimary>
</div>
</div>
</div>
</Modal>
);
}
export default hookForDev(LnQrCodeModal);

View File

@ -0,0 +1,334 @@
.lnbc {
width: 100%;
min-height: 158px;
background-color: var(--background-header-input);
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
position: relative;
margin-top: 8px;
.paymentOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-site);
opacity: 0.6;
display: flex;
justify-content: center;
align-items: center;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 20px;
.title {
display: flex;
justify-content: flex-start;
align-items: center;
color: var(--text-primary);
font-size: 15px;
font-weight: 700;
line-height: 16px;
}
.headerActions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 2px;
button {
.qrIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
}
.copyIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
}
&:hover {
.qrIcon, .copyIcon {
background-color: var(--text-primary);
}
}
}
.copyDone {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
.checkIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--success-bright);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
}
}
}
}
.body {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 8px;
.description {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
}
.amount {
color: var(--text-primary);
font-size: 24px;
font-weight: 600;
line-height: 24px;
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
.expiryDate {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.expiredDate {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
}
}
}
&.noBack {
background-color: unset;
}
.lnIcon {
width: 20px;
height: 20px;
background-image: url('../../assets/icons/lightning.svg');
background-size: contain;
background-repeat: no-repeat;
}
}
.lnbcAlter {
width: 100%;
min-height: 158px;
background-color: none;
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
position: relative;
.paymentOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-site);
opacity: 0.6;
display: flex;
justify-content: center;
align-items: center;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 20px;
.title {
display: flex;
justify-content: flex-start;
align-items: center;
color: white;
font-size: 15px;
font-weight: 700;
line-height: 16px;
}
.headerActions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 2px;
button {
.qrIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: white;
-webkit-mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
}
.copyIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: white;
-webkit-mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
}
&:hover {
.qrIcon, .copyIcon {
background-color: white;
}
}
}
.copyDone {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
.checkIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--success-bright);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
}
}
}
}
.body {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 8px;
.description {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
}
.amount {
color: white;
font-size: 24px;
font-weight: 600;
line-height: 24px;
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
.expiryDate {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.expiredDate {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
color: var(--accent);
background-color: white;
}
}
}
&.noBack {
background-color: unset;
}
.lnIcon {
width: 20px;
height: 20px;
background-image: url('../../assets/icons/lightning_white.svg');
background-size: contain;
background-repeat: no-repeat;
}
}

View File

@ -0,0 +1,376 @@
import { Component, createEffect, createSignal, onMount, Show } from 'solid-js';
import { hookForDev } from '../../lib/devTools';
// @ts-ignore
import { decode } from 'light-bolt11-decoder';
import styles from './Lnbc.module.scss';
import { createStore, reconcile } from 'solid-js/store';
import { humanizeNumber } from '../../lib/stats';
import { date, dateFuture } from '../../lib/dates';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonGhost from '../Buttons/ButtonGhost';
import { LnbcInvoice } from '../../types/primal';
import { emptyInvoice, Kind } from '../../constants';
import { useAppContext } from '../../contexts/AppContext';
import { sendMessage, subTo } from '../../lib/sockets';
import { APP_ID } from '../../App';
import { signEvent } from '../../lib/nostrAPI';
import Loader from '../Loader/Loader';
import { logError, logInfo, logWarning } from '../../lib/logger';
import { useToastContext } from '../Toaster/Toaster';
import { useIntl } from '@cookbook/solid-intl';
import { lnInvoice } from '../../translations';
const Lnbc: Component< {
id?: string,
lnbc: string,
alternative?: boolean,
noBack?: boolean,
inactive?: boolean,
} > = (props) => {
const app = useAppContext();
const toast = useToastContext();
const intl = useIntl();
const [invoice, setInvoice] = createStore<LnbcInvoice>({ ...emptyInvoice });
const [invoiceCopied, setInvoiceCopied] = createSignal(false);
const [paymentInProgress, setPaymentInProgress] = createSignal(false);
createEffect(() => {
try {
const dec: LnbcInvoice = decode(props.lnbc);
setInvoice(reconcile({...emptyInvoice}))
setInvoice(() => ({ ...dec }));
} catch (e) {
logError('Failed to decode lightining unvoice: ', e);
}
});
createEffect(() => {
if (invoiceCopied()) {
setTimeout(() => {
setInvoiceCopied(() => false);
}, 1_000);
}
})
const isLightning = () => invoice.sections.find(s => s.name === 'lightning_network');
const expiryDate = () => {
const expiry = invoice.sections.find(s => s.name === 'expiry')?.value as number;
const created = invoice.sections.find(s => s.name === 'timestamp')?.value as number;
return expiry + created;
}
const hasExpired = () => {
const today = Math.floor((new Date()).getTime() / 1_000);
return today > expiryDate();
}
const amount = () =>
`${humanizeNumber(parseInt(invoice.sections.find(s => s.name === 'amount')?.value || '0') / 1_000)} sats`;
const description = () =>
decodeURI(invoice.sections.find(s => s.name === 'description')?.value) || '';
const confirmPayment = () => app?.actions.openConfirmModal({
title: intl.formatMessage(lnInvoice.confirm.title),
description: intl.formatMessage(lnInvoice.confirm.description, { amount: amount() }),
confirmLabel: intl.formatMessage(lnInvoice.confirm.confirmLabel),
abortLabel: intl.formatMessage(lnInvoice.confirm.abortLabel),
onAbort: app.actions.closeConfirmModal,
onConfirm: () => {
app.actions.closeConfirmModal();
payInvoice();
},
});
const payInvoice = () => {
if (props.inactive) return;
setPaymentInProgress(() => true);
const walletSocket = new WebSocket('wss://wallet.primal.net/v1');
walletSocket.addEventListener('close', () => {
logInfo('PREMIUM SOCKET CLOSED');
});
walletSocket.addEventListener('open', () => {
logInfo('WALLET SOCKET OPENED');
sendPayment(walletSocket, (success: boolean) => {
if (!success) {
toast?.sendWarning(`Failed to pay ${amount()}`);
}
walletSocket.close();
setPaymentInProgress(() => false);
});
});
};
const sendPayment = async (socket: WebSocket, then?: (success: boolean) => void) => {
if (props.inactive) return;
const subId = `sp_${APP_ID}`;
let success = true;
const unsub = subTo(socket, subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
then && then(success);
}
if (type === 'NOTICE') {
success = false;
}
});
const content = JSON.stringify(
["withdraw", {
subwallet: 1,
lnInvoice: invoice.paymentRequest,
target_lud16: '',
note_for_recipient: invoice.sections.find(s => s.name === 'description')?.value || '',
note_for_self: '',
}],
);
const event = {
content,
kind: Kind.WALLET_OPERATION,
created_at: Math.ceil((new Date()).getTime() / 1000),
tags: [],
};
try {
const signedEvent = await signEvent(event);
sendMessage(socket, JSON.stringify([
"REQ",
subId,
{cache: ["wallet", { operation_event: signedEvent }]},
]));
} catch (reason) {
logError('failed to sign due to: ', reason);
}
};
const klass = () => {
let k = props.alternative ? styles.lnbcAlter : styles.lnbc;
if (props.noBack) {
k += ` ${styles.noBack}`
}
return k;
}
return (
<div id={props.id} class={klass()}>
<Show when={paymentInProgress()}>
<div class={styles.paymentOverlay}>
<Loader />
</div>
</Show>
<div class={styles.header}>
<Show when={isLightning()}>
<div class={styles.title}>
<div class={styles.lnIcon}></div>
<div>{intl.formatMessage(lnInvoice.title)}</div>
</div>
</Show>
<div class={styles.headerActions}>
<Show
when={!hasExpired()}
>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault();
if (props.inactive) return;
app?.actions.openLnbcModal(props.lnbc, () => {
app.actions.closeLnbcModal();
confirmPayment();
});
}}
shrink={true}
>
<div class={styles.qrIcon}></div>
</ButtonGhost>
</Show>
<Show
when={!invoiceCopied()}
fallback={<div class={styles.copyDone}><div class={styles.checkIcon}></div></div>}
>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault()
if (props.inactive) return;
navigator.clipboard.writeText(props.lnbc);
setInvoiceCopied(() => true);
}}
shrink={true}
>
<div class={styles.copyIcon}></div>
</ButtonGhost>
</Show>
</div>
</div>
<div class={styles.body}>
<div class={styles.description}>{description()}</div>
<div class={styles.amount}>{amount()}</div>
</div>
<div class={styles.footer}>
<Show
when={!hasExpired()}
fallback={
<div class={styles.expiredDate}>
{intl.formatMessage(lnInvoice.expired, { date: date(expiryDate(), 'long').label })}
</div>
}
>
<div class={styles.expiryDate}>
{intl.formatMessage(lnInvoice.expires, { date: dateFuture(expiryDate(), 'long').label })}
</div>
<div class={styles.payAction}>
<ButtonPrimary onClick={(e: MouseEvent) => {
e.preventDefault();
!props.inactive && confirmPayment();
}}>
{intl.formatMessage(lnInvoice.pay)}
</ButtonPrimary>
</div>
</Show>
</div>
</div>
);
}
export default hookForDev(Lnbc);
// sections = [
// {
// "name": "lightning_network",
// "letters": "ln"
// },
// {
// "name": "coin_network",
// "letters": "bc",
// "value": {
// "bech32": "bc",
// "pubKeyHash": 0,
// "scriptHash": 5,
// "validWitnessVersions": [
// 0
// ]
// }
// },
// {
// "name": "amount",
// "letters": "100u",
// "value": "10000000"
// },
// {
// "name": "separator",
// "letters": "1"
// },
// {
// "name": "timestamp",
// "letters": "pjatlyx",
// "value": 1708522630
// },
// {
// "name": "payment_secret",
// "tag": "s",
// "letters": "sp5938h8ewswdm7smn9yfge6wvfeletzxrujz2kt6yjxl77at09zlys",
// "value": "2c4f73e5d07377e86e6522519d3989cff2b1187c909565e89237fdeeade517c9"
// },
// {
// "name": "payment_hash",
// "tag": "p",
// "letters": "pp5edcuua748en26zlyjga47gpcga3htnw06xmqw4zrkwwz37s45cxq",
// "value": "cb71ce77d53e66ad0be4923b5f2038476375cdcfd1b6075443b39c28fa15a60c"
// },
// {
// "name": "description",
// "tag": "d",
// "letters": "dqgv4h82arn",
// "value": "enuts"
// },
// {
// "name": "expiry",
// "tag": "x",
// "letters": "xqzjc",
// "value": 600
// },
// {
// "name": "min_final_cltv_expiry",
// "tag": "c",
// "letters": "cqpj",
// "value": 18
// },
// {
// "name": "route_hint",
// "tag": "r",
// "letters": "rzjqgfffll4jmjf0tffqtx47xt886gzp9fajp3966xz96gm2xj9cqedxrrld5qq0tgqqqqqqqqqqqqqrssqyg",
// "value": [
// {
// "pubkey": "021294fff596e497ad2902cd5f19673e9020953d90625d68c22e91b51a45c032d3",
// "short_channel_id": "0c7f6d0007ad0000",
// "fee_base_msat": 0,
// "fee_proportional_millionths": 450,
// "cltv_expiry_delta": 34
// }
// ]
// },
// {
// "name": "feature_bits",
// "tag": "9",
// "letters": "9qxpqysgq",
// "value": {
// "option_data_loss_protect": "unsupported",
// "initial_routing_sync": "unsupported",
// "option_upfront_shutdown_script": "unsupported",
// "gossip_queries": "unsupported",
// "var_onion_optin": "required",
// "gossip_queries_ex": "unsupported",
// "option_static_remotekey": "unsupported",
// "payment_secret": "required",
// "basic_mpp": "supported",
// "option_support_large_channel": "unsupported",
// "extra_bits": {
// "start_bit": 20,
// "bits": [
// false,
// false,
// false,
// false,
// false,
// true,
// false,
// false,
// false,
// false
// ],
// "has_required": false
// }
// }
// },
// {
// "name": "signature",
// "letters": "ml5za767e9scmd52l8mh8zl0g93n74jq0asr98ezvq0gpw8cmsrknehucng4utdjm3cx5mpzkc3psty5yp3ftddkhhrp2hsvy3q08ucq",
// "value": "dfe82efb5ec9618db68af9f7738bef41633f56407f60329f22601e80b8f8dc0769e6fcc4d15e2db2dc706a6c22b622182c94206295b5b6bdc6155e0c2440f3f300"
// },
// {
// "name": "checksum",
// "letters": "ef6k3v"
// }
// ],

View File

@ -10,6 +10,7 @@
padding-top: 32px;
background-color: var(--background-modal);
z-index: var(--z-index-header);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
font-size: 16px;

View File

@ -73,6 +73,11 @@
-webkit-mask: url(../../assets/icons/help.svg) no-repeat center;
mask: url(../../assets/icons/help.svg) no-repeat center;
}
.bookmarkIcon {
@include iconNav;
-webkit-mask: url(../../assets/icons/bookmark_empty.svg) no-repeat 0 / auto 100%;
mask: url(../../assets/icons/bookmark_empty.svg) no-repeat center 0 / auto 100%;
}
.active {
display: flex;

View File

@ -38,6 +38,11 @@ const NavMenu: Component< { id?: string } > = (props) => {
icon: 'messagesIcon',
bubble: () => messages?.messageCount || 0,
},
{
to: '/bookmarks',
label: intl.formatMessage(t.bookmarks),
icon: 'bookmarkIcon',
},
{
to: '/notifications',
label: intl.formatMessage(t.notifications),

View File

@ -1,9 +1,9 @@
import { useIntl } from "@cookbook/solid-intl";
import { Router, useLocation } from "@solidjs/router";
import { nip19 } from "nostr-tools";
import { Component, createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { Component, createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";
import { createStore, reconcile, unwrap } from "solid-js/store";
import { noteRegex, profileRegex, Kind, editMentionRegex, emojiSearchLimit, profileRegexG } from "../../../constants";
import { noteRegex, profileRegex, Kind, editMentionRegex, emojiSearchLimit, profileRegexG, linebreakRegex } from "../../../constants";
import { useAccountContext } from "../../../contexts/AccountContext";
import { useSearchContext } from "../../../contexts/SearchContext";
import { TranslatorProvider } from "../../../contexts/TranslatorContext";
@ -13,7 +13,7 @@ import { getUserProfiles } from "../../../lib/profile";
import { subscribeTo } from "../../../sockets";
import { convertToNotes, referencesToTags } from "../../../stores/note";
import { convertToUser, nip05Verification, truncateNpub, userName } from "../../../stores/profile";
import { EmojiOption, FeedPage, NostrMentionContent, NostrNoteContent, NostrStatsContent, NostrUserContent, PrimalNote, PrimalUser, SendNoteResult } from "../../../types/primal";
import { EmojiOption, FeedPage, NostrMentionContent, NostrNoteContent, NostrRelayHint, NostrStatsContent, NostrUserContent, PrimalNote, PrimalUser, SendNoteResult } from "../../../types/primal";
import { debounce, getScreenCordinates, isVisibleInContainer, uuidv4 } from "../../../utils";
import Avatar from "../../Avatar/Avatar";
import EmbeddedNote from "../../EmbeddedNote/EmbeddedNote";
@ -43,6 +43,7 @@ import ConfirmAlternativeModal from "../../ConfirmModal/ConfirmAlternativeModal"
import { readNoteDraft, readNoteDraftUserRefs, saveNoteDraft, saveNoteDraftUserRefs } from "../../../lib/localStore";
import Uploader from "../../Uploader/Uploader";
import { logError } from "../../../lib/logger";
import Lnbc from "../../Lnbc/Lnbc";
type AutoSizedTextArea = HTMLTextAreaElement & { _baseScrollHeight: number };
@ -99,10 +100,27 @@ const EditBox: Component<{
const [fileToUpload, setFileToUpload] = createSignal<File | undefined>();
const [relayHints, setRelayHints] = createStore<Record<string, string>>({});
const location = useLocation();
let currentPath = location.pathname;
const noteHasInvoice = (text: string) => {
const r =/(\s+|\r\n|\r|\n|^)lnbc[a-zA-Z0-9]+/;
const test = r.test(text);
return test
};
const noteHasCashu = (text: string) => {
const r =/(\s+|\r\n|\r|\n|^)cashuA[a-zA-Z0-9]+/;
const test = r.test(text);
return test
};
const getScrollHeight = (elm: AutoSizedTextArea) => {
var savedValue = elm.value
elm.value = ''
@ -155,6 +173,74 @@ const EditBox: Component<{
}
});
const renderMessage = () => {
const text = parsedMessage();
if (!noteHasInvoice(text)) {
return (
<div
class={styles.editor}
ref={textPreview}
innerHTML={text}
></div>
);
};
let sections: string[] = [];
let content = text.replace(linebreakRegex, ' __LB__ ').replaceAll('<br>', ' __LB__ ').replace(/\s+/g, ' __SP__ ');
let tokens: string[] = content.split(/[\s]+/);
let sectionIndex = 0;
tokens.forEach((t) => {
if (t.startsWith('lnbc')) {
if (sections[sectionIndex]) sectionIndex++;
sections[sectionIndex] = t;
sectionIndex++;
}
else {
let c = t;
const prev = sections[sectionIndex] || '';
if (t === '__SP__') {
c = prev.length === 0 ? '' : ' ';
}
if (t === '__LB__') {
c = prev.length === 0 ? '' : ' <br/>';
}
sections[sectionIndex] = prev + c;
}
});
return (
<div
class={styles.editor}
ref={textPreview}
>
<For each={sections}>
{section => (
<Switch fallback={
<div
innerHTML={section}
></div>
}>
<Match when={section.startsWith('lnbc')}>
<Lnbc lnbc={section} inactive={true} />
</Match>
</Switch>
)}
</For>
</div>
);
};
const onKeyDown = (e: KeyboardEvent) => {
if (!textArea) {
return false;
@ -474,7 +560,7 @@ const EditBox: Component<{
const addQuote = (quote: string | undefined) => {
setMessage((msg) => {
if (!textArea || !quote) return msg;
let position = textArea.selectionStart;
let position = textArea.selectionStart + 2;
const isEmptyMessage = msg.length === 0;
@ -494,6 +580,7 @@ const EditBox: Component<{
textArea.selectionEnd = position;
return newMsg;
});
};
createEffect(() => {
@ -628,21 +715,25 @@ const EditBox: Component<{
});
if (account) {
let tags = referencesToTags(messageToSend);
let tags = referencesToTags(messageToSend, relayHints);
const rep = props.replyToNote;
if (rep) {
const rootTag = rep.post.tags.find(t => t[0] === 'e' && t[3] === 'root');
let rootTag = rep.post.tags.find(t => t[0] === 'e' && t[3] === 'root');
// If the note has a root tag, that meens it is not a root note itself
// So we need to copy the `root` tag and add a `reply` tag
if (rootTag) {
tags.push([...rootTag]);
tags.push(['e', rep.post.id, '', 'reply']);
const tagWithHint = rootTag.map((v, i) => i === 2 ?
(rep.post.relayHints && rep.post.relayHints[rep.post.id]) || '' :
v,
);
tags.push([...tagWithHint]);
tags.push(['e', rep.post.id, (rep.post.relayHints && rep.post.relayHints[rep.post.id]) || '', 'reply']);
}
// Otherwise, add the note as the root tag for this reply
else {
tags.push(['e', rep.post.id, '', 'root']);
tags.push(['e', rep.post.id, (rep.post.relayHints && rep.post.relayHints[rep.post.id]) || '', 'root']);
}
// Copy all `p` tags from the note we are repling to
@ -957,6 +1048,11 @@ const EditBox: Component<{
);
return;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content) as Record<string, string>;
setRelayHints(() => ({ ...hints }))
}
}
});
@ -1002,7 +1098,6 @@ const EditBox: Component<{
});
setParsedMessage(parsed);
};
@ -1052,7 +1147,7 @@ const EditBox: Component<{
createEffect(() => {
if (query().length === 0) {
search?.actions.getRecomendedUsers();
search?.actions.getRecomendedUsers(profile?.profileHistory.profiles || []);
return;
}
@ -1272,11 +1367,7 @@ const EditBox: Component<{
class={styles.editorScroll}
id={`${prefix()}new_note_text_preview`}
>
<div
class={styles.editor}
ref={textPreview}
innerHTML={parsedMessage()}
></div>
{renderMessage()}
<div class={styles.uploader}>
<Uploader
publicKey={account?.publicKey}

View File

@ -10,6 +10,7 @@ import styles from "./MentionedUserLink.module.scss";
const MentionedUserLink: Component<{
user: PrimalUser,
npub?: string,
openInNewTab?: boolean,
id?: string,
}> = (props) => {
@ -39,7 +40,7 @@ const MentionedUserLink: Component<{
return <A
id={props.id}
class={styles.userMention}
href={`/p/${props.user.npub}`}
href={`/p/${props.user.npub || props.npub}`}
>
{p.children}
</A>;

View File

@ -1,4 +1,5 @@
.note {
position: relative;
display: flex;
flex-direction: column;
padding: 12px;
@ -22,6 +23,11 @@
border: none;
}
&.reactionNote {
background: none;
border-bottom: 1px solid var(--subtile-devider);
}
.header {
position: relative;
display: flex;
@ -92,6 +98,12 @@
.label {
color: var(--text-primary);
}
a {
max-width: 440px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.author {
display: inline-block;
color: var(--accent-links);
@ -227,13 +239,299 @@
font-weight: bold;
}
.verificationFailed {
display: inline-block;
width: 4px;
height: 4px;
}
.notePrimary {
position: relative;
background-color: var(--background-card);
display: flex;
flex-direction: column;
padding-inline: 12px;
padding-top: 0;
padding-bottom: 12px;
border-radius: 0;
border: none;
.content {
grid-area: content;
display: flex;
flex-direction: column;
margin-left: 2px;
margin-top: 2px;
cursor: text;
.message {
position: relative;
grid-area: message;
color: var(--text-primary);
word-break: break-word;
font-size: 18px;
font-weight: 400;
line-height: 24px;
width: 100%;
margin-bottom: 12px;
a:hover {
text-decoration: underline;
}
.messageFade {
position: absolute;
z-index: 1;
top: 610px;
left: 0;
pointer-events: none;
background-image: var(--fade-note-vertical);
width: 100%;
height: 40px;
}
}
.time {
padding-block: 20px;
border-bottom: 1px solid var(--devider);
margin-bottom: 16px;
color: var(--text-tertiary);
font-size: 16px;
font-weight: 400;
line-height: 16px;
.reactSummary {
&::before {
content: ' · ';
}
background: none;
margin: 0;
padding: 0;
margin-left: 4px;
display: inline-block;
width: fit-content;
outline: none;
border: none;
color: var(--text-tertiary);
font-size: 16px;
font-weight: 400;
line-height: 16px;
.number {
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
line-height: 16px;
}
&:hover {
color: var(--text-primary);
}
}
}
.zapHighlights {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: flex-start;
&.onlyFew {
flex-direction: row;
align-items: center;
gap: 6px;
}
.break {
flex-basis: 100%;
height: 0;
}
.topZap {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding-left: 2px;
padding-right: 10px;
padding-block: 2px;
margin: 0;
border-radius: 12px;
background: var(--devider);
width: fit-content;
max-width: 100%;
text-decoration: none;
border: none;
outline: none;
.amount {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
line-height: 14px;
}
.description {
color: var(--text-secondary-2);
font-size: 14px;
font-weight: 400;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
background: var(--subtile-devider);
}
transition: all 0.6s;
}
.moreZaps {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--devider);
width: 26px;
height: 26px;
padding: 0;
margin: 0;
border: none;
outline: none;
.contextIcon {
width: 16px;
height: 14px;
background-color: var(--text-secondary-2);
-webkit-mask: url(../../assets/icons/context.svg) no-repeat 0 / 100%;
mask: url(../../assets/icons/context.svg) no-repeat 0 / 100%;
}
&:hover {
.contextIcon {
background-color: var(--text-primary);
}
}
}
}
}
}
.topZapEnterTransition {
opacity: 0;
transform: translateX(300px);
}
.topZapExitTransition {
opacity: 0;
transform: translateY(3px);
}
.noteNotificationLink {
text-decoration: none;
color: unset;
margin: 0px;
padding: 0px;
// background: var(--brand-gradient-vertical);
background-color: var(--background-card);
border-radius: 6px;
display: block;
transition: 0.2s padding;
margin-top: 6px;
.noteNotifications {
background-color: var(--background-site);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
grid-template-areas: "content";
padding: 0px;
border-radius: 4px;
transition: 0.2s border-radius ease-out;
.content {
grid-area: content;
display: flex;
flex-direction: column;
padding-right: 20px;
.message {
grid-area: message;
color: var(--text-primary);
word-break: break-word;
font-size: 16px;
line-height: 24px;
width: 100%;
a:hover {
text-decoration: underline;
}
}
.footer {
margin-top: 12px;
margin-right: 0 !important;
}
}
}
// &:hover {
// padding-left: 4px;
// transition: 0.2s padding;
// border-radius: 4px;
// >div {
// border-radius: 0px 4px 4px 0px;
// transition: 0.2s border-radius ease-out;
// }
// }
}
.context {
background: none;
display: flex;
justify-content: flex-end;
align-items: center;
.contextButton {
width: 42px;
height: 32px;
padding: 0;
margin: 0;
background: none;
border: none;
outline: none;
display: flex;
justify-content: center;
align-items: center;
&:focus {
outline: none;
box-shadow: none;
}
.contextIcon {
width: 16px;
height: 14px;
background-color: var(--text-secondary-2);
-webkit-mask: url(../../assets/icons/context.svg) no-repeat 0 / 100%;
mask: url(../../assets/icons/context.svg) no-repeat 0 / 100%;
}
&:hover {
.contextIcon {
background-color: var(--text-primary);
}
}
}
}
@media only screen and (max-width: 720px) {
.note {
width: 100dvw;
@ -251,4 +549,108 @@
width: 100%;
}
.notePrimary {
width: 100vw;
margin-left: 0px;
margin-right: 0px;
padding-right: 12px;
.content {
margin-left: 0px;
}
}
.noteNotificationLink {
.noteNotifications {
grid-template-columns: 1fr;
margin-left: 0px;
margin-right: 0px;
padding-right: 0px;
}
}
}
.upRightFloater {
position: absolute;
top: 4px;
right: 4px;
}
@keyframes shimmer {
to {
background-position-x: 0%
}
}
.topZapsLoading {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
&.onlyFew {
flex-direction: row;
align-items: center;
gap: 6px;
}
.firstZap {
display: flex;
align-items: center;
gap: 8px;
width: 120px;
height: 26px;
margin: 0;
border-radius: 12px;
// background: var(--devider);
text-decoration: none;
border: none;
outline: none;
background: linear-gradient(-45deg, var(--devider) 35%, #333333 50%, var(--devider) 65%);
background-size: 300%;
background-position-x: 100%;
animation: shimmer 0.6s infinite linear;
}
.topZaps {
display: flex;
align-self: center;
gap: 6px;
width: 100%;
.zapList {
display: flex;
align-self: center;
gap: 6px;
max-width: calc(100% - 30px);
overflow-x: scroll;
overflow-y: hidden;
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none !important;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none !important; /* IE and Edge */
scrollbar-width: none !important; /* Firefox */
.topZap {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
border-radius: 12px;
// background: var(--devider);
width: 70px;
height: 26px;
text-decoration: none;
border: none;
outline: none;
background: linear-gradient(-45deg, var(--devider) 35%, #333333 50%, var(--devider) 65%);
background-size: 300%;
background-position-x: 100%;
animation: shimmer 0.6s infinite linear;
}
}
}
}

View File

@ -1,83 +1,449 @@
import { A } from '@solidjs/router';
import { Component, createSignal, Show } from 'solid-js';
import { PrimalNote } from '../../types/primal';
import { batch, Component, createEffect, Match, Show, Switch } from 'solid-js';
import { PrimalNote, ZapOption } from '../../types/primal';
import ParsedNote from '../ParsedNote/ParsedNote';
import NoteFooter from './NoteFooter/NoteFooter';
import NoteHeader from './NoteHeader/NoteHeader';
import styles from './Note.module.scss';
import { useThreadContext } from '../../contexts/ThreadContext';
import { TopZap, useThreadContext } from '../../contexts/ThreadContext';
import { useIntl } from '@cookbook/solid-intl';
import { authorName, userName } from '../../stores/profile';
import { note as t } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import NoteReplyHeader from './NoteHeader/NoteReplyHeader';
import Avatar from '../Avatar/Avatar';
import NoteAuthorInfo from './NoteAuthorInfo';
import NoteRepostHeader from './NoteRepostHeader';
import PrimalMenu from '../PrimalMenu/PrimalMenu';
import NoteContextMenu from './NoteContextMenu';
import NoteReplyToHeader from './NoteReplyToHeader';
import NoteHeader from './NoteHeader/NoteHeader';
import { createStore } from 'solid-js/store';
import { CustomZapInfo, useAppContext } from '../../contexts/AppContext';
import NoteContextTrigger from './NoteContextTrigger';
import { date, veryLongDate } from '../../lib/dates';
import { useAccountContext } from '../../contexts/AccountContext';
import { uuidv4 } from '../../utils';
import NoteTopZaps from './NoteTopZaps';
const Note: Component<{ note: PrimalNote, id?: string, parent?: boolean, shorten?: boolean }> = (props) => {
export type NoteReactionsState = {
likes: number,
liked: boolean,
reposts: number,
reposted: boolean,
replies: number,
replied: boolean,
zapCount: number,
satsZapped: number,
zappedAmount: number,
zapped: boolean,
zappedNow: boolean,
isZapping: boolean,
showZapAnim: boolean,
hideZapIcon: boolean,
moreZapsAvailable: boolean,
isRepostMenuVisible: boolean,
topZaps: TopZap[],
quoteCount: number,
};
const Note: Component<{
note: PrimalNote,
id?: string,
parent?: boolean,
shorten?: boolean,
noteType?: 'feed' | 'primary' | 'notification' | 'reaction'
onClick?: () => void,
quoteCount?: number,
}> = (props) => {
const threadContext = useThreadContext();
const app = useAppContext();
const account = useAccountContext();
const intl = useIntl();
createEffect(() => {
if (props.quoteCount) {
updateReactionsState('quoteCount', () => props.quoteCount || 0);
}
})
const noteType = () => props.noteType || 'feed';
const repost = () => props.note.repost;
const navToThread = (note: PrimalNote) => {
props.onClick && props.onClick();
threadContext?.actions.setPrimaryNote(note);
};
const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({
likes: props.note.post.likes,
liked: props.note.post.noteActions.liked,
reposts: props.note.post.reposts,
reposted: props.note.post.noteActions.reposted,
replies: props.note.post.replies,
replied: props.note.post.noteActions.replied,
zapCount: props.note.post.zaps,
satsZapped: props.note.post.satszapped,
zapped: props.note.post.noteActions.zapped,
zappedAmount: 0,
zappedNow: false,
isZapping: false,
showZapAnim: false,
hideZapIcon: false,
moreZapsAvailable: false,
isRepostMenuVisible: false,
topZaps: [],
quoteCount: props.quoteCount || 0,
});
let noteContextMenu: HTMLDivElement | undefined;
let latestTopZap: string = '';
const onConfirmZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
batch(() => {
updateReactionsState('zappedAmount', () => zapOption.amount || 0);
updateReactionsState('satsZapped', (z) => z + (zapOption.amount || 0));
// updateFooterState('zappedNow', () => true);
updateReactionsState('zapped', () => true);
updateReactionsState('showZapAnim', () => true)
});
addTopZap(zapOption)
};
const addTopZap = (zapOption: ZapOption) => {
console.log('AADD')
const pubkey = account?.publicKey;
if (!pubkey) return;
const oldZaps = [ ...reactionsState.topZaps ];
latestTopZap = uuidv4() as string;
const newZap = {
amount: zapOption.amount || 0,
message: zapOption.message || '',
pubkey,
eventId: props.note.post.id,
id: latestTopZap,
};
if (!threadContext?.users.find((u) => u.pubkey === pubkey)) {
threadContext?.actions.fetchUsers([pubkey])
}
const zaps = [ ...oldZaps, { ...newZap }].sort((a, b) => b.amount - a.amount);
updateReactionsState('topZaps', () => [...zaps]);
};
const removeTopZap = (zapOption: ZapOption) => {
const zaps = reactionsState.topZaps.filter(z => z.id !== latestTopZap);
updateReactionsState('topZaps', () => [...zaps]);
};
const onSuccessZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
app?.actions.resetCustomZap();
const pubkey = account?.publicKey;
if (!pubkey) return;
// const oldZaps = [ ...reactionsState.topZaps ];
// const newZap = {
// amount: zapOption.amount || 0,
// message: zapOption.message || '',
// pubkey,
// eventId: props.note.post.id,
// id: uuidv4() as string,
// };
// if (!threadContext?.users.find((u) => u.pubkey === pubkey)) {
// threadContext?.actions.fetchUsers([pubkey])
// }
// const zaps = [ ...oldZaps, { ...newZap }].sort((a, b) => b.amount - a.amount);
batch(() => {
updateReactionsState('zapCount', (z) => z + 1);
// updateFooterState('satsZapped', (z) => z + (zapOption.amount || 0));
updateReactionsState('isZapping', () => false);
// updateFooterState('zappedNow', () => false);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => true);
// updateReactionsState('topZaps', () => [...zaps]);
});
};
const onFailZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
app?.actions.resetCustomZap();
batch(() => {
updateReactionsState('zappedAmount', () => -(zapOption.amount || 0));
updateReactionsState('satsZapped', (z) => z - (zapOption.amount || 0));
updateReactionsState('isZapping', () => false);
// updateFooterState('zappedNow', () => true);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => props.note.post.noteActions.zapped);
});
removeTopZap(zapOption);
};
const onCancelZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
app?.actions.resetCustomZap();
batch(() => {
updateReactionsState('zappedAmount', () => -(zapOption.amount || 0));
updateReactionsState('satsZapped', (z) => z - (zapOption.amount || 0));
updateReactionsState('isZapping', () => false);
// updateFooterState('zappedNow', () => true);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => props.note.post.noteActions.zapped);
});
removeTopZap(zapOption);
};
const customZapInfo: () => CustomZapInfo = () => ({
note: props.note,
onConfirm: onConfirmZap,
onSuccess: onSuccessZap,
onFail: onFailZap,
onCancel: onCancelZap,
});
const openReactionModal = (openOn = 'likes') => {
app?.actions.openReactionModal(props.note.post.id, {
likes: reactionsState.likes,
zaps: reactionsState.zapCount,
reposts: reactionsState.reposts,
quotes: reactionsState.quoteCount,
openOn,
});
};
const onContextMenuTrigger = () => {
app?.actions.openContextMenu(
props.note,
noteContextMenu?.getBoundingClientRect(),
() => {
app?.actions.openCustomZapModal(customZapInfo());
},
openReactionModal,
);
}
const reactionSum = () => {
const { likes, zapCount, reposts, quoteCount } = reactionsState;
return (likes || 0) + (zapCount || 0) + (reposts || 0) + (quoteCount || 0);
};
createEffect(() => {
updateReactionsState('topZaps', () => [ ...(threadContext?.topZaps[props.note.post.id] || []) ]);
});
return (
<A
id={props.id}
class={`${styles.note} ${props.parent ? styles.parent : ''}`}
href={`/e/${props.note?.post.noteId}`}
onClick={() => navToThread(props.note)}
data-event={props.note.post.id}
data-event-bech32={props.note.post.noteId}
draggable={false}
>
<div class={styles.header}>
<Show when={repost()}>
<NoteRepostHeader note={props.note} />
</Show>
</div>
<div class={styles.content}>
<div class={styles.leftSide}>
<A href={`/p/${props.note.user.npub}`}>
<Avatar user={props.note.user} size="vs" />
</A>
<Show
when={props.parent}
>
<div class={styles.ancestorLine}></div>
</Show>
</div>
<Switch>
<Match when={noteType() === 'notification'}>
<A
id={props.id}
class={styles.noteNotificationLink}
href={`/e/${props.note?.post.noteId}`}
onClick={() => navToThread(props.note)}
data-event={props.note.post.id}
data-event-bech32={props.note.post.noteId}
>
<div class={styles.noteNotifications}>
<div class={styles.content}>
<div class={styles.message}>
<ParsedNote note={props.note} shorten={true} />
</div>
<div class={styles.rightSide}>
<NoteAuthorInfo
author={props.note.user}
time={props.note.post.created_at}
/>
<div class={styles.footer}>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
/>
</div>
</div>
</div>
</A>
</Match>
<NoteReplyToHeader note={props.note} />
<Match when={noteType() === 'primary'}>
<div
id={props.id}
class={styles.notePrimary}
data-event={props.note.post.id}
data-event-bech32={props.note.post.noteId}
>
<div class={styles.border}></div>
<div class={styles.message}>
<ParsedNote
note={props.note}
shorten={props.shorten}
width={Math.min(528, window.innerWidth - 72)}
<NoteHeader note={props.note} primary={true} />
<div class={styles.upRightFloater}>
<NoteContextTrigger
ref={noteContextMenu}
onClick={onContextMenuTrigger}
/>
</div>
<NoteFooter note={props.note} />
<div class={styles.content}>
<div class={styles.message}>
<ParsedNote note={props.note} width={Math.min(574, window.innerWidth)} />
</div>
<NoteTopZaps
topZaps={reactionsState.topZaps}
zapCount={reactionsState.zapCount}
action={() => openReactionModal('zaps')}
/>
<div
class={styles.time}
title={date(props.note.post?.created_at).date.toLocaleString()}
>
<span>
{veryLongDate(props.note.post?.created_at).replace('at', '·')}
</span>
<button
class={styles.reactSummary}
onClick={() => openReactionModal()}
>
<span class={styles.number}>{reactionSum()}</span> Reactions
</button>
</div>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
wide={true}
large={true}
onZapAnim={addTopZap}
/>
</div>
</div>
</div>
</A>
)
</Match>
<Match when={noteType() === 'feed'}>
<A
id={props.id}
class={`${styles.note} ${props.parent ? styles.parent : ''}`}
href={`/e/${props.note?.post.noteId}`}
onClick={() => navToThread(props.note)}
data-event={props.note.post.id}
data-event-bech32={props.note.post.noteId}
draggable={false}
>
<div class={styles.header}>
<Show when={repost()}>
<NoteRepostHeader note={props.note} />
</Show>
</div>
<div class={styles.content}>
<div class={styles.leftSide}>
<A href={`/p/${props.note.user.npub}`}>
<Avatar user={props.note.user} size="vs" />
</A>
<Show
when={props.parent}
>
<div class={styles.ancestorLine}></div>
</Show>
</div>
<div class={styles.rightSide}>
<NoteAuthorInfo
author={props.note.user}
time={props.note.post.created_at}
/>
<div class={styles.upRightFloater}>
<NoteContextTrigger
ref={noteContextMenu}
onClick={onContextMenuTrigger}
/>
</div>
<NoteReplyToHeader note={props.note} />
<div class={styles.message}>
<ParsedNote
note={props.note}
shorten={props.shorten}
width={Math.min(528, window.innerWidth - 72)}
/>
</div>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
/>
</div>
</div>
</A>
</Match>
<Match when={noteType() === 'reaction'}>
<A
id={props.id}
class={`${styles.note} ${styles.reactionNote}`}
href={`/e/${props.note?.post.noteId}`}
onClick={() => navToThread(props.note)}
data-event={props.note.post.id}
data-event-bech32={props.note.post.noteId}
draggable={false}
>
<div class={styles.content}>
<div class={styles.leftSide}>
<A href={`/p/${props.note.user.npub}`}>
<Avatar user={props.note.user} size="vs" />
</A>
<Show
when={props.parent}
>
<div class={styles.ancestorLine}></div>
</Show>
</div>
<div class={styles.rightSide}>
<NoteAuthorInfo
author={props.note.user}
time={props.note.post.created_at}
/>
<NoteReplyToHeader note={props.note} />
<div class={styles.message}>
<ParsedNote
note={props.note}
shorten={props.shorten}
width={Math.min(528, window.innerWidth - 72)}
noLightbox={true}
altEmbeds={true}
/>
</div>
</div>
</div>
</A>
</Match>
</Switch>
);
}
export default hookForDev(Note);

View File

@ -1,18 +1,11 @@
import { A } from '@solidjs/router';
import { Component, createSignal, Show } from 'solid-js';
import { PrimalNote, PrimalUser } from '../../types/primal';
import ParsedNote from '../ParsedNote/ParsedNote';
import NoteFooter from './NoteFooter/NoteFooter';
import NoteHeader from './NoteHeader/NoteHeader';
import { Component, Show } from 'solid-js';
import { PrimalUser } from '../../types/primal';
import styles from './Note.module.scss';
import { useThreadContext } from '../../contexts/ThreadContext';
import { useIntl } from '@cookbook/solid-intl';
import { authorName, nip05Verification, truncateNpub } from '../../stores/profile';
import { note as t } from '../../translations';
import { authorName, nip05Verification } from '../../stores/profile';
import { hookForDev } from '../../lib/devTools';
import NoteReplyHeader from './NoteHeader/NoteReplyHeader';
import Avatar from '../Avatar/Avatar';
import { date } from '../../lib/dates';
import VerificationCheck from '../VerificationCheck/VerificationCheck';

View File

@ -1,27 +1,17 @@
import { A } from '@solidjs/router';
import { Component, createEffect, createSignal, Show } from 'solid-js';
import { MenuItem, NostrRelaySignedEvent, PrimalNote, PrimalRepost, PrimalUser } from '../../types/primal';
import ParsedNote from '../ParsedNote/ParsedNote';
import NoteFooter from './NoteFooter/NoteFooter';
import NoteHeader from './NoteHeader/NoteHeader';
import { Component, createEffect, createSignal } from 'solid-js';
import { MenuItem, NostrRelaySignedEvent } from '../../types/primal';
import styles from './Note.module.scss';
import { useThreadContext } from '../../contexts/ThreadContext';
import { useIntl } from '@cookbook/solid-intl';
import { authorName, nip05Verification, truncateNpub, userName } from '../../stores/profile';
import { authorName, userName } from '../../stores/profile';
import { note as t, actions as tActions, toast as tToast } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import NoteReplyHeader from './NoteHeader/NoteReplyHeader';
import Avatar from '../Avatar/Avatar';
import { date } from '../../lib/dates';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import PrimalMenu from '../PrimalMenu/PrimalMenu';
import { useAccountContext } from '../../contexts/AccountContext';
import { APP_ID } from '../../App';
import { reportUser } from '../../lib/profile';
import { useToastContext } from '../Toaster/Toaster';
import { broadcastEvent } from '../../lib/notes';
import { getScreenCordinates } from '../../utils';
import { NoteContextMenuInfo } from '../../contexts/AppContext';
import ConfirmModal from '../ConfirmModal/ConfirmModal';
@ -51,6 +41,7 @@ const NoteContextMenu: Component<{
if (!props.open) {
context.setAttribute('style',`top: -1024px; left: -1034px;`);
return;
}
const docRect = document.documentElement.getBoundingClientRect();

View File

@ -0,0 +1,28 @@
import { Component } from 'solid-js';
import { hookForDev } from '../../lib/devTools';
import styles from './Note.module.scss';
const NoteContextTrigger: Component<{
ref: HTMLDivElement | undefined,
id?: string,
onClick?: () => void,
}> = (props) => {
return (
<div ref={props.ref} class={styles.context}>
<button
class={styles.contextButton}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onClick && props.onClick();
}}
>
<div class={styles.contextIcon} ></div>
</button>
</div>
)
}
export default hookForDev(NoteContextTrigger);

View File

@ -1,12 +1,24 @@
@mixin statIcon {
width: 16px;
height: 16px;
width: 18px;
height: 18px;
background-color: var(--text-tertiary-2);
&.large {
width: 22px;
height: 22px;
}
}
@mixin typeDiv {
display: flex;
align-items: center;
font-weight: 400;
font-size: 14px;
line-height: 16px;
&.large {
font-size: 16px;
}
}
.contextButton {
@ -43,12 +55,12 @@
.footer {
display: grid;
grid-template-columns: 128px 128px 128px 128px 16px;
grid-template-columns: 125px 125px 125px 125px auto;
position: relative;
width: 100%;
&.wide {
grid-template-columns: 140px 140px 140px 138px 16px;
grid-template-columns: 137px 137px 137px 135px auto;
}
.context {
@ -59,9 +71,6 @@
}
.stat {
font-weight: 400;
font-size: 14px;
line-height: 16px;
align-items: center;
margin: 0px;
padding: 0px;
@ -102,8 +111,8 @@
@include typeDiv;
.icon {
@include statIcon;
-webkit-mask: url(../../../assets/icons/feed_zap.svg) no-repeat 0 / 100%;
mask: url(../../../assets/icons/feed_zap.svg) no-repeat 0 / 100%;
-webkit-mask: url(../../../assets/icons/feed_zap_2.svg) no-repeat center 0 / auto 100%;
mask: url(../../../assets/icons/feed_zap_2.svg) no-repeat center 0 / auto 100%;
}
}
@ -114,8 +123,8 @@
}
.icon {
background-color: var(--active-zap);
-webkit-mask: url(../../../assets/icons/feed_zap_fill.svg) no-repeat 0 / 100%;
mask: url(../../../assets/icons/feed_zap_fill.svg) no-repeat 0 / 100%;
-webkit-mask: url(../../../assets/icons/feed_zap_fill_2.svg) no-repeat center 0 / auto 100%;
mask: url(../../../assets/icons/feed_zap_fill_2.svg) no-repeat center 0 / auto 100%;
}
}
@ -162,23 +171,28 @@
padding-left: 7px;
}
}
.bookmarkFoot {
display: flex;
justify-content: flex-end;
}
}
.smallZapLottie {
width: 32px;
height: 32px;
.largeZapLottie {
width: 435px;
height: 58px;
position: absolute;
z-index: 20;
// background-color: red;
}
.mediumZapLottie {
width: 341px;
height: 91px;
width: 360px;
height: 48px;
position: absolute;
z-index: 20;
}
@media only screen and (max-width: 720px) {
.footer {
width: 100%;

View File

@ -1,6 +1,6 @@
import { Component, createEffect, createSignal, Show } from 'solid-js';
import { batch, Component, createEffect, Show } from 'solid-js';
import { MenuItem, PrimalNote, ZapOption } from '../../../types/primal';
import { sendRepost } from '../../../lib/notes';
import { sendRepost, triggerImportEvents } from '../../../lib/notes';
import styles from './NoteFooter.module.scss';
import { useAccountContext } from '../../../contexts/AccountContext';
@ -9,20 +9,32 @@ import { useIntl } from '@cookbook/solid-intl';
import { truncateNumber } from '../../../lib/notifications';
import { canUserReceiveZaps, zapNote } from '../../../lib/zap';
import CustomZap from '../../CustomZap/CustomZap';
import { useSettingsContext } from '../../../contexts/SettingsContext';
import zapMD from '../../../assets/lottie/zap_md.json';
import zapMD from '../../../assets/lottie/zap_md_2.json';
import { toast as t } from '../../../translations';
import PrimalMenu from '../../PrimalMenu/PrimalMenu';
import { hookForDev } from '../../../lib/devTools';
import NoteContextMenu from '../NoteContextMenu';
import { getScreenCordinates } from '../../../utils';
import ZapAnimation from '../../ZapAnimation/ZapAnimation';
import ReactionsModal from '../../ReactionsModal/ReactionsModal';
import { CustomZapInfo, useAppContext } from '../../../contexts/AppContext';
import NoteFooterActionButton from './NoteFooterActionButton';
import { NoteReactionsState } from '../Note';
import { SetStoreFunction } from 'solid-js/store';
import BookmarkNote from '../../BookmarkNote/BookmarkNote';
const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = (props) => {
export const lottieDuration = () => zapMD.op * 1_000 / zapMD.fr;
const NoteFooter: Component<{
note: PrimalNote,
wide?: boolean,
id?: string,
state: NoteReactionsState,
updateState: SetStoreFunction<NoteReactionsState>,
customZapInfo: CustomZapInfo,
large?: boolean,
onZapAnim?: (zapOption: ZapOption) => void,
}> = (props) => {
const account = useAccountContext();
const toast = useToastContext();
@ -32,21 +44,9 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
let medZapAnimation: HTMLElement | undefined;
const [liked, setLiked] = createSignal(props.note.post.noteActions.liked);
const [zapped, setZapped] = createSignal(props.note.post.noteActions.zapped);
const [replied, setReplied] = createSignal(props.note.post.noteActions.replied);
const [reposted, setReposted] = createSignal(props.note.post.noteActions.reposted);
const [likes, setLikes] = createSignal(props.note.post.likes);
const [reposts, setReposts] = createSignal(props.note.post.reposts);
const [replies, setReplies] = createSignal(props.note.post.replies);
const [zapCount, setZapCount] = createSignal(props.note.post.zaps);
const [zaps, setZaps] = createSignal(props.note.post.satszapped);
const [isRepostMenuVisible, setIsRepostMenuVisible] = createSignal(false);
let quickZapDelay = 0;
let footerDiv: HTMLDivElement | undefined;
let noteContextMenu: HTMLDivElement | undefined;
let repostMenu: HTMLDivElement | undefined;
const repostMenuItems: MenuItem[] = [
{
@ -65,12 +65,12 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
if (
!document?.getElementById(`repost_menu_${props.note.post.id}`)?.contains(e.target as Node)
) {
setIsRepostMenuVisible(false);
props.updateState('isRepostMenuVisible', () => false);
}
}
createEffect(() => {
if (isRepostMenuVisible()) {
if (props.state.isRepostMenuVisible) {
document.addEventListener('click', onClickOutside);
}
else {
@ -80,7 +80,7 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
const showRepostMenu = (e: MouseEvent) => {
e.preventDefault();
setIsRepostMenuVisible(true);
props.updateState('isRepostMenuVisible', () => true);
};
const doQuote = () => {
@ -88,7 +88,7 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
account?.actions.showGetStarted();
return;
}
setIsRepostMenuVisible(false);
props.updateState('isRepostMenuVisible', () => false);
account?.actions?.quoteNote(`nostr:${props.note.post.noteId}`);
account?.actions?.showNewNoteForm();
};
@ -110,13 +110,16 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
return;
}
setIsRepostMenuVisible(false);
props.updateState('isRepostMenuVisible', () => false);
const { success } = await sendRepost(props.note, account.relays, account.relaySettings);
if (success) {
setReposts(reposts() + 1);
setReposted(true);
batch(() => {
props.updateState('reposts', (r) => r + 1);
props.updateState('reposted', () => true);
});
toast?.sendSuccess(
intl.formatMessage(t.repostSuccess),
);
@ -153,58 +156,20 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
const success = await account.actions.addLike(props.note);
if (success) {
setLikes(likes() + 1);
setLiked(true);
batch(() => {
props.updateState('likes', (l) => l + 1);
props.updateState('liked', () => true);
});
}
};
let quickZapDelay = 0;
const [isZapping, setIsZapping] = createSignal(false);
const customZapInfo: CustomZapInfo = {
note: props.note,
onConfirm: (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
setZappedAmount(() => zapOption.amount || 0);
setZappedNow(true);
setZapped(true);
animateZap();
},
onSuccess: (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
setIsZapping(false);
setZappedNow(false);
setShowZapAnim(false);
setHideZapIcon(false);
setZapped(true);
},
onFail: (zapOption: ZapOption) => {
setZappedAmount(() => -(zapOption.amount || 0));
setZappedNow(true);
app?.actions.closeCustomZapModal();
setIsZapping(false);
setShowZapAnim(false);
setHideZapIcon(false);
setZapped(props.note.post.noteActions.zapped);
},
onCancel: (zapOption: ZapOption) => {
setZappedAmount(() => -(zapOption.amount || 0));
setZappedNow(true);
app?.actions.closeCustomZapModal();
setIsZapping(false);
setShowZapAnim(false);
setHideZapIcon(false);
setZapped(props.note.post.noteActions.zapped);
},
};
const startZap = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
e.stopPropagation();
if (!account?.hasPublicKey()) {
account?.actions.showGetStarted()
setIsZapping(false);
account?.actions.showGetStarted();
props.updateState('isZapping', () => false);
return;
}
@ -219,13 +184,13 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
toast?.sendWarning(
intl.formatMessage(t.zapUnavailable),
);
setIsZapping(false);
props.updateState('isZapping', () => false);
return;
}
quickZapDelay = setTimeout(() => {
app?.actions.openCustomZapModal(customZapInfo);
setIsZapping(true);
app?.actions.openCustomZapModal(props.customZapInfo);
props.updateState('isZapping', () => true);
}, 500);
};
@ -249,27 +214,31 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
}
};
const [zappedNow, setZappedNow] = createSignal(false);
const [zappedAmount, setZappedAmount] = createSignal(0);
const animateZap = () => {
setShowZapAnim(true);
setTimeout(() => {
setHideZapIcon(true);
props.updateState('hideZapIcon', () => true);
if (!medZapAnimation) {
return;
}
const newLeft = props.wide ? 36 : 24;
const newTop = props.wide ? -28 : -28;
let newLeft = props.wide ? 15 : 13;
let newTop = props.wide ? -6 : -6;
if (props.large) {
newLeft = 2;
newTop = -9;
}
medZapAnimation.style.left = `${newLeft}px`;
medZapAnimation.style.top = `${newTop}px`;
const onAnimDone = () => {
setShowZapAnim(false);
setHideZapIcon(false);
batch(() => {
props.updateState('showZapAnim', () => false);
props.updateState('hideZapIcon', () => false);
props.updateState('zapped', () => true);
});
medZapAnimation?.removeEventListener('complete', onAnimDone);
}
@ -293,19 +262,41 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
return;
}
setZappedAmount(() => settings?.defaultZap.amount || 0);
setZappedNow(true);
animateZap();
const success = await zapNote(props.note, account.publicKey, settings?.defaultZap.amount || 10, settings?.defaultZap.message || '', account.relays);
setIsZapping(false);
const amount = settings?.defaultZap.amount || 10;
const message = settings?.defaultZap.message || '';
const emoji = settings?.defaultZap.emoji;
if (success) {
return;
}
batch(() => {
props.updateState('isZapping', () => true);
props.updateState('satsZapped', (z) => z + amount);
props.updateState('showZapAnim', () => true);
});
console.log('QUICK ZAP: ', props.onZapAnim)
props.onZapAnim && props.onZapAnim({ amount, message, emoji })
setTimeout(async () => {
const success = await zapNote(props.note, account.publicKey, amount, message, account.relays);
props.updateState('isZapping', () => false);
if (success) {
props.customZapInfo.onSuccess({
emoji,
amount,
message,
});
return;
}
props.customZapInfo.onFail({
emoji,
amount,
message,
});
}, lottieDuration());
setZappedAmount(() => -(settings?.defaultZap.amount || 0));
setZappedNow(true);
setZapped(props.note.post.noteActions.zapped);
}
const buttonTypeClasses: Record<string, string> = {
@ -315,59 +306,12 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
repost: styles.repostType,
};
const actionButton = (opts: {
type: 'zap' | 'like' | 'reply' | 'repost',
disabled?: boolean,
highlighted?: boolean,
onClick?: (e: MouseEvent) => void,
onMouseDown?: (e: MouseEvent) => void,
onMouseUp?: (e: MouseEvent) => void,
onTouchStart?: (e: TouchEvent) => void,
onTouchEnd?: (e: TouchEvent) => void,
label: string | number,
hidden?: boolean,
title?: string,
}) => {
return (
<button
id={`btn_${opts.type}_${props.note.post.id}`}
class={`${styles.stat} ${opts.highlighted ? styles.highlighted : ''}`}
onClick={opts.onClick ?? (() => {})}
onMouseDown={opts.onMouseDown ?? (() => {})}
onMouseUp={opts.onMouseUp ?? (() => {})}
onTouchStart={opts.onTouchStart ?? (() => {})}
onTouchEnd={opts.onTouchEnd ?? (() => {})}
disabled={opts.disabled}
>
<div class={`${buttonTypeClasses[opts.type]}`}>
<div
class={styles.icon}
style={opts.hidden ? 'visibility: hidden': 'visibility: visible'}
></div>
<div class={styles.statNumber}>{opts.label || ''}</div>
</div>
</button>
);
};
createEffect(() => {
if (zappedNow()) {
setZapCount(c => c + 1);
setZaps((z) => z + zappedAmount());
setZapped(true);
setZappedNow(false);
if (props.state.showZapAnim) {
animateZap();
}
});
const [showZapAnim, setShowZapAnim] = createSignal(false);
const [hideZapIcon, setHideZapIcon] = createSignal(false);
let repostMenu: HTMLDivElement | undefined;
const determineOrient = () => {
const coor = getScreenCordinates(repostMenu);
const height = 100;
@ -377,99 +321,84 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> =
return (
<div id={props.id} class={`${styles.footer} ${props.wide ? styles.wide : ''}`} ref={footerDiv} onClick={(e) => {e.preventDefault();}}>
<Show when={showZapAnim()}>
<Show when={props.state.showZapAnim}>
<ZapAnimation
id={`note-med-zap-${props.note.post.id}`}
src={zapMD}
class={styles.mediumZapLottie}
class={props.large ? styles.largeZapLottie : styles.mediumZapLottie}
ref={medZapAnimation}
/>
</Show>
{actionButton({
onClick: doReply,
type: 'reply',
highlighted: replied(),
label: replies() === 0 ? '' : truncateNumber(replies(), 2),
title: replies().toLocaleString(),
})}
<NoteFooterActionButton
note={props.note}
onClick={doReply}
type="reply"
highlighted={props.state.replied}
label={props.state.replies === 0 ? '' : truncateNumber(props.state.replies, 2)}
title={props.state.replies.toLocaleString()}
large={props.large}
/>
{actionButton({
onClick: (e: MouseEvent) => e.preventDefault(),
onMouseDown: startZap,
onMouseUp: commitZap,
onTouchStart: startZap,
onTouchEnd: commitZap,
type: 'zap',
highlighted: zapped() || isZapping(),
label: zaps() === 0 ? '' : truncateNumber(zaps(), 2),
hidden: hideZapIcon(),
title: zaps().toLocaleString(),
})}
{actionButton({
onClick: doLike,
type: 'like',
highlighted: liked(),
label: likes() === 0 ? '' : truncateNumber(likes(), 2),
title: likes().toLocaleString(),
})}
<NoteFooterActionButton
note={props.note}
onClick={(e: MouseEvent) => e.preventDefault()}
onMouseDown={startZap}
onMouseUp={commitZap}
onTouchStart={startZap}
onTouchEnd={commitZap}
type="zap"
highlighted={props.state.zapped || props.state.isZapping}
label={props.state.satsZapped === 0 ? '' : truncateNumber(props.state.satsZapped, 2)}
hidden={props.state.hideZapIcon}
title={props.state.satsZapped.toLocaleString()}
large={props.large}
/>
<NoteFooterActionButton
note={props.note}
onClick={doLike}
type="like"
highlighted={props.state.liked}
label={props.state.likes === 0 ? '' : truncateNumber(props.state.likes, 2)}
title={props.state.likes.toLocaleString()}
large={props.large}
/>
<button
id={`btn_repost_${props.note.post.id}`}
class={`${styles.stat} ${reposted() ? styles.highlighted : ''}`}
class={`${styles.stat} ${props.state.reposted ? styles.highlighted : ''}`}
onClick={showRepostMenu}
title={reposts().toLocaleString()}
title={props.state.reposts.toLocaleString()}
>
<div
class={`${buttonTypeClasses.repost}`}
ref={repostMenu}
>
<div
class={styles.icon}
class={`${styles.icon} ${props.large ? styles.large : ''}`}
style={'visibility: visible'}
></div>
<div class={styles.statNumber}>
{reposts() === 0 ? '' : truncateNumber(reposts(), 2)}
{props.state.reposts === 0 ? '' : truncateNumber(props.state.reposts, 2)}
</div>
<PrimalMenu
id={`repost_menu_${props.note.post.id}`}
items={repostMenuItems}
position="note_footer"
orientation={determineOrient()}
hidden={!isRepostMenuVisible()}
hidden={!props.state.isRepostMenuVisible}
/>
</div>
</button>
<div ref={noteContextMenu} class={styles.context}>
<button
class={styles.contextButton}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
app?.actions.openContextMenu(
props.note,
noteContextMenu?.getBoundingClientRect(),
() => {
app?.actions.openCustomZapModal(customZapInfo);
},
() => {
app?.actions.openReactionModal(props.note.post.id, {
likes: likes(),
zaps: zapCount(),
reposts: reposts(),
quotes: 0,
});
}
);
}}
>
<div class={styles.contextIcon} ></div>
</button>
<div class={styles.bookmarkFoot}>
<BookmarkNote
note={props.note}
large={props.large}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,51 @@
import { Component, createEffect, onCleanup } from 'solid-js';
import { PrimalNote } from '../../../types/primal';
import styles from './NoteFooter.module.scss';
const buttonTypeClasses: Record<string, string> = {
zap: styles.zapType,
like: styles.likeType,
reply: styles.replyType,
repost: styles.repostType,
};
const NoteFooterActionButton: Component<{
type: 'zap' | 'like' | 'reply' | 'repost',
note: PrimalNote,
disabled?: boolean,
highlighted?: boolean,
onClick?: (e: MouseEvent) => void,
onMouseDown?: (e: MouseEvent) => void,
onMouseUp?: (e: MouseEvent) => void,
onTouchStart?: (e: TouchEvent) => void,
onTouchEnd?: (e: TouchEvent) => void,
label: string | number,
hidden?: boolean,
title?: string,
large?: boolean,
}> = (props) => {
return (
<button
id={`btn_${props.type}_${props.note.post.id}`}
class={`${styles.stat} ${props.highlighted ? styles.highlighted : ''}`}
onClick={props.onClick ?? (() => {})}
onMouseDown={props.onMouseDown ?? (() => {})}
onMouseUp={props.onMouseUp ?? (() => {})}
onTouchStart={props.onTouchStart ?? (() => {})}
onTouchEnd={props.onTouchEnd ?? (() => {})}
disabled={props.disabled}
>
<div class={`${buttonTypeClasses[props.type]} ${props.large ? styles.large : ''}`}>
<div
class={`${styles.icon} ${props.large ? styles.large : ''}`}
style={props.hidden ? 'visibility: hidden': 'visibility: visible'}
></div>
<div class={styles.statNumber}>{props.label || ''}</div>
</div>
</button>
)
}
export default NoteFooterActionButton;

View File

@ -222,17 +222,7 @@ const NoteHeader: Component<{
<VerificationCheck
user={props.note.user}
fallback={<div class={styles.ellipsisIcon}></div>}
/>
<span
class={styles.time}
title={date(props.note.post?.created_at).date.toLocaleString()}
>
{props.primary ?
longDate(props.note.post?.created_at) :
date(props.note.post?.created_at).label}
</span>
</div>
<Show
when={props.note.user?.nip05}

View File

@ -1,14 +1,9 @@
import { A } from '@solidjs/router';
import { Component, createMemo, createSignal, Show } from 'solid-js';
import { PrimalNote, PrimalRepost, PrimalUser } from '../../types/primal';
import ParsedNote from '../ParsedNote/ParsedNote';
import NoteFooter from './NoteFooter/NoteFooter';
import NoteHeader from './NoteHeader/NoteHeader';
import { Component, createMemo, Show } from 'solid-js';
import { PrimalNote } from '../../types/primal';
import styles from './Note.module.scss';
import { useThreadContext } from '../../contexts/ThreadContext';
import { useIntl } from '@cookbook/solid-intl';
import { authorName, nip05Verification, truncateNpub, userName } from '../../stores/profile';
import { note as t } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import MentionedUserLink from './MentionedUserLink/MentionedUserLink';

View File

@ -0,0 +1,111 @@
import { Component, createMemo, createSignal, For, Show } from "solid-js";
import { hookForDev } from "../../lib/devTools";
import { TopZap, useThreadContext } from "../../contexts/ThreadContext";
import Avatar from "../Avatar/Avatar";
import { TransitionGroup } from 'solid-transition-group';
import styles from "./Note.module.scss";
const NoteTopZaps: Component<{
topZaps: TopZap[],
zapCount: number,
action: () => void,
id?: string,
}> = (props) => {
const threadContext = useThreadContext();
const [hasMoreZaps, setHasMoreZaps] = createSignal(false);
const topZaps = () => {
const zaps = [...props.topZaps];
let limit = 0;
let digits = 0;
for (let i=0; i< zaps.length; i++) {
const amount = zaps[i].amount || 0;
const length = Math.log(amount) * Math.LOG10E + 1 | 0;
digits += length;
if (digits > 25 || limit > 7) break;
limit++;
}
const highlights = zaps.slice(0, limit);
setHasMoreZaps(() => highlights.length < props.zapCount - 1);
return highlights;
}
const zapSender = (zap: TopZap) => {
return threadContext?.users.find(u => u.pubkey === zap.pubkey);
};
return (
<Show
when={!threadContext?.isFetchingTopZaps}
fallback={
<div class={styles.topZapsLoading}>
<div class={styles.firstZap}></div>
<div class={styles.topZaps}>
<div class={styles.zapList}>
<div class={styles.topZap}></div>
<div class={styles.topZap}></div>
<div class={styles.topZap}></div>
<div class={styles.topZap}></div>
<div class={styles.topZap}></div>
</div>
</div>
</div>
}
>
<div class={`${styles.zapHighlights}`}>
<TransitionGroup
name="top-zaps"
enterClass={styles.topZapEnterTransition}
exitClass={styles.topZapExitTransition}
>
<For each={topZaps()}>
{(zap, index) => (
<>
<button
class={`${styles.topZap}`}
onClick={() => props.action()}
style={`z-index: ${12 - index()};`}
>
<Avatar user={zapSender(zap)} size="micro" />
<div class={styles.amount}>
{zap.amount.toLocaleString()}
</div>
<Show when={index() === 0}>
<div class={styles.description}>
{zap.message}
</div>
</Show>
</button>
<Show when={index() === 0 && props.topZaps.length > 3}>
<div class={styles.break}></div>
</Show>
</>
)}
</For>
<Show when={hasMoreZaps()}>
<button
class={styles.moreZaps}
onClick={() => props.action()}
>
<div class={styles.contextIcon}></div>
</button>
</Show>
</TransitionGroup>
</div>
</Show>
);
}
export default hookForDev(NoteTopZaps);

View File

@ -26,8 +26,6 @@ const NoteImage: Component<{
const [src, setSrc] = createSignal<string | undefined>();
// const src = () => props.media?.media_url || props.src;
const isCached = () => !props.isDev || props.media;
const onError = (event: any) => {
@ -35,7 +33,7 @@ const NoteImage: Component<{
if (image.src === props.altSrc || !props.altSrc) {
// @ts-ignore
props.onError(event);
props.onError && props.onError(event);
return true;
}
@ -134,6 +132,7 @@ const NoteImage: Component<{
data-pswp-height={zoomH()}
data-image-group={props.imageGroup}
data-cropped={true}
target="_blank"
>
<img
id={imgId}

View File

@ -5,7 +5,7 @@
padding-top: 12px;
padding-bottom: 17px;
border-bottom: 1px solid var(--subtile-devider);
border-bottom: 1px solid var(--devider);
.newBubble {
position: absolute;

View File

@ -33,6 +33,7 @@ import NotificationNote from '../Note/NotificationNote/NotificationNote';
import NotificationAvatar from '../NotificationAvatar/NotificationAvatar';
import { notificationsNew as t } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import Note from '../Note/Note';
const typeIcons: Record<string, string> = {
[NotificationType.NEW_USER_FOLLOWED_YOU]: userFollow,
@ -183,9 +184,10 @@ const NotificationItem: Component<NotificationItemProps> = (props) => {
>
<div class={styles.reference}>
<Show when={props.note}>
<NotificationNote
<Note
// @ts-ignore
note={props.note}
noteType="notification"
/>
</Show>
</div>

View File

@ -33,6 +33,7 @@ import NotificationNote from '../Note/NotificationNote/NotificationNote';
import { truncateNumber } from '../../lib/notifications';
import { notificationsOld as t } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import Note from '../Note/Note';
const typeIcons: Record<string, string> = {
[NotificationType.NEW_USER_FOLLOWED_YOU]: userFollow,
@ -148,9 +149,10 @@ const NotificationItemOld: Component<NotificationItemProps> = (props) => {
>
<div class={styles.reference}>
<Show when={note()}>
<NotificationNote
<Note
// @ts-ignore
note={note()}
noteType="notification"
/>
</Show>
</div>

View File

@ -103,3 +103,17 @@
margin-left: 2px;
}
}
.reactionNote {
display: flex;
width: fit-content;
align-items: center;
justify-content: center;
height: 48px;
font-weight: 700;
margin-block: 4px;
padding-inline: 12px;
border: 1px solid var(--subtile-devider);
border-radius: var(--border-radius-small);
}

View File

@ -1,12 +1,15 @@
import { A } from '@solidjs/router';
import { hexToNpub } from '../../lib/keys';
import {
addLinkPreviews,
getLinkPreview,
isAddrMention,
isAppleMusic,
isCustomEmoji,
isHashtag,
isImage,
isInterpunction,
isLnbc,
isMixCloud,
isMp4Video,
isNoteMention,
@ -15,16 +18,17 @@ import {
isSpotify,
isTagMention,
isTwitch,
isUnitifedLnAddress,
isUrl,
isUserMention,
isWavelake,
isWebmVideo,
isYouTube,
} from '../../lib/notes';
import { truncateNpub, userName } from '../../stores/profile';
import { authorName, truncateNpub, userName } from '../../stores/profile';
import EmbeddedNote from '../EmbeddedNote/EmbeddedNote';
import {
Component, createSignal, For, JSXElement, onMount, Show,
Component, createResource, createSignal, For, JSXElement, onMount, Show, Suspense,
} from 'solid-js';
import {
PrimalNote,
@ -40,14 +44,20 @@ import { hookForDev } from '../../lib/devTools';
import { getMediaUrl as getMediaUrlDefault } from "../../lib/media";
import NoteImage from '../NoteImage/NoteImage';
import { createStore, unwrap } from 'solid-js/store';
import { hashtagCharsRegex, linebreakRegex, shortMentionInWords, shortNoteWords, specialCharsRegex, urlExtractRegex } from '../../constants';
import { hashtagCharsRegex, Kind, linebreakRegex, lnUnifiedRegex, shortMentionInWords, shortNoteWords, specialCharsRegex, urlExtractRegex } from '../../constants';
import { useIntl } from '@cookbook/solid-intl';
import { actions } from '../../translations';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import Lnbc from '../Lnbc/Lnbc';
const groupGridLimit = 7;
export type NoteContent = {
type: string,
tokens: string[],
meta?: Record<string, any>,
};
export const groupGalleryImages = (noteHolder: HTMLDivElement | undefined) => {
@ -134,6 +144,8 @@ const ParsedNote: Component<{
shorten?: boolean,
isEmbeded?: boolean,
width?: number,
noLightbox?: boolean,
altEmbeds?: boolean,
}> = (props) => {
const intl = useIntl();
@ -162,6 +174,8 @@ const ParsedNote: Component<{
});
onMount(() => {
if (props.noLightbox) return;
lightbox.init();
});
@ -195,13 +209,7 @@ const ParsedNote: Component<{
setTokens(() => [...tokens]);
}
type NoteContent = {
type: string,
tokens: string[],
meta?: Record<string, any>,
};
const removeLinebreaks = () => {
const removeLinebreaks = (type: string) => {
if (lastSignificantContent === 'LB') {
const lastIndex = content.length - 1;
const lastGroup = content[lastIndex];
@ -209,7 +217,11 @@ const ParsedNote: Component<{
setContent(lastIndex, () => ({
type: lastGroup.type,
tokens: [],
meta: lastGroup.meta,
meta: {
...lastGroup.meta,
removedBy: type,
removedTokens: [...lastGroup.tokens],
},
}));
}
};
@ -217,20 +229,24 @@ const ParsedNote: Component<{
const [content, setContent] = createStore<NoteContent[]>([]);
const updateContent = (contentArray: NoteContent[], type: string, token: string, meta?: Record<string, any>) => {
if (contentArray.length > 0 && contentArray[contentArray.length -1].type === type) {
const len = contentArray.length;
const index = contentArray.length -1
setContent(content.length -1, 'tokens' , (els) => [...els, token]);
if (len > 0 && contentArray[len -1].type === type) {
meta && setContent(content.length -1, 'meta' , () => ({ ...meta }));
setContent(index, 'tokens' , (els) => [...els, token]);
meta && setContent(index, 'meta' , () => ({ ...meta }));
return;
}
setContent(content.length, () => ({ type, tokens: [token], meta }));
setContent(len, () => ({ type, tokens: [token], meta }));
}
let lastSignificantContent = 'text';
let isAfterEmbed = false;
let totalLinks = 0;
const parseToken = (token: string) => {
if (token === '__LB__') {
@ -244,7 +260,7 @@ const ParsedNote: Component<{
}
if (token === '__SP__') {
if (!['image', 'video', 'link', 'LB'].includes(lastSignificantContent)) {
if (!['image', 'video', 'LB'].includes(lastSignificantContent)) {
updateContent(content, 'text', ' ');
}
return;
@ -281,70 +297,89 @@ const ParsedNote: Component<{
}
if (!props.ignoreMedia) {
removeLinebreaks();
isAfterEmbed = true;
if (isImage(token)) {
removeLinebreaks('image');
isAfterEmbed = true;
lastSignificantContent = 'image';
updateContent(content, 'image', token);
return;
}
if (isMp4Video(token)) {
removeLinebreaks('video');
isAfterEmbed = true;
lastSignificantContent = 'video';
updateContent(content, 'video', token, { videoType: 'video/mp4'});
return;
}
if (isOggVideo(token)) {
removeLinebreaks('video');
isAfterEmbed = true;
lastSignificantContent = 'video';
updateContent(content, 'video', token, { videoType: 'video/ogg'});
return;
}
if (isWebmVideo(token)) {
removeLinebreaks('video');
isAfterEmbed = true;
lastSignificantContent = 'video';
updateContent(content, 'video', token, { videoType: 'video/webm'});
return;
}
if (isYouTube(token)) {
removeLinebreaks('youtube');
isAfterEmbed = true;
lastSignificantContent = 'youtube';
updateContent(content, 'youtube', token);
return;
}
if (isSpotify(token)) {
removeLinebreaks('spotify');
isAfterEmbed = true;
lastSignificantContent = 'spotify';
updateContent(content, 'spotify', token);
return;
}
if (isTwitch(token)) {
removeLinebreaks('twitch');
isAfterEmbed = true;
lastSignificantContent = 'twitch';
updateContent(content, 'twitch', token);
return;
}
if (isMixCloud(token)) {
removeLinebreaks('mixcloud');
isAfterEmbed = true;
lastSignificantContent = 'mixcloud';
updateContent(content, 'mixcloud', token);
return;
}
if (isSoundCloud(token)) {
removeLinebreaks('soundcloud');
isAfterEmbed = true;
lastSignificantContent = 'soundcloud';
updateContent(content, 'soundcloud', token);
return;
}
if (isAppleMusic(token)) {
removeLinebreaks('applemusic');
isAfterEmbed = true;
lastSignificantContent = 'applemusic';
updateContent(content, 'applemusic', token);
return;
}
if (isWavelake(token)) {
removeLinebreaks('wavelake');
isAfterEmbed = true;
lastSignificantContent = 'wavelake';
updateContent(content, 'wavelake', token);
return;
@ -357,16 +392,31 @@ const ParsedNote: Component<{
return;
}
removeLinebreaks();
isAfterEmbed = false;
lastSignificantContent = 'link';
const preview = getLinkPreview(token);
updateContent(content, 'link', token);
const hasMinimalPreviewData = !props.noPreviews &&
preview &&
preview.url &&
((!!preview.description && preview.description.length > 0) ||
!preview.images?.some((x:any) => x === '') ||
!!preview.title
);
if (hasMinimalPreviewData) {
removeLinebreaks('link');
updateContent(content, 'link', token, { preview });
} else {
updateContent(content, 'link', token);
}
lastSignificantContent = 'link';
isAfterEmbed = false;
totalLinks++;
return;
}
if (isNoteMention(token)) {
removeLinebreaks();
removeLinebreaks('notemention');
lastSignificantContent = 'notemention';
isAfterEmbed = true;
updateContent(content, 'notemention', token);
@ -379,6 +429,12 @@ const ParsedNote: Component<{
return;
}
if (isAddrMention(token)) {
lastSignificantContent = 'comunity';
updateContent(content, 'comunity', token);
return;
}
if (isTagMention(token)) {
lastSignificantContent = 'tagmention';
updateContent(content, 'tagmention', token);
@ -397,6 +453,32 @@ const ParsedNote: Component<{
return;
}
if (isUnitifedLnAddress(token)) {
lastSignificantContent = 'lnbc';
const match = token.match(lnUnifiedRegex);
let lnbcToken = match?.find(m => m.startsWith('lnbc'));
if (lnbcToken) {
removeLinebreaks('lnbc');
updateContent(content, 'lnbc', lnbcToken);
}
else {
updateContent(content, 'text', token);
}
return;
}
if (isLnbc(token)) {
lastSignificantContent = 'lnbc';
removeLinebreaks('lnbc');
updateContent(content, 'lnbc', token);
return;
}
lastSignificantContent = 'text';
updateContent(content, 'text', token);
return;
@ -427,8 +509,12 @@ const ParsedNote: Component<{
const renderLinebreak = (item: NoteContent) => {
if (isNoteTooLong()) return;
let tokens = item.meta?.removedBy === 'link' && totalLinks > 1 ?
(item.meta?.removedTokens || []) :
item.tokens;
// Allow max consecutive linebreak
const len = Math.min(2, item.tokens.length);
const len = Math.min(2, tokens.length);
const lineBreaks = Array(len).fill(<br/>)
@ -758,31 +844,84 @@ const ParsedNote: Component<{
{(token) => {
if (isNoteTooLong()) return;
const preview = getLinkPreview(token);
const hasMinimalPreviewData = !props.noPreviews &&
preview &&
preview.url &&
((!!preview.description && preview.description.length > 0) ||
!preview.images?.some((x:any) => x === '') ||
!!preview.title
);
if (hasMinimalPreviewData) {
if (item.meta && item.meta.preview && totalLinks < 2) {
setWordsDisplayed(w => w + shortMentionInWords);
return <LinkPreview
preview={preview}
bordered={props.isEmbeded}
isLast={index === content.length-1}
/>;
return (
<LinkPreview
preview={item.meta.preview}
bordered={props.isEmbeded}
isLast={index === content.length-1}
/>
);
}
setWordsDisplayed(w => w + 1);
return <span data-url={token}><a link href={token} target="_blank" >{token}</a></span>;
return (
<span data-url={token}>
<a link href={token} target="_blank" >{token}</a>
</span>
);
}}
</For>
};
const renderComunityMention = (item: NoteContent, index?: number) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
let [_, id] = token.split(':');
if (!id) {
return <>{token}</>;
}
let end = '';
let match = specialCharsRegex.exec(id);
if (match) {
const i = match.index;
end = id.slice(i);
id = id.slice(0, i);
}
setWordsDisplayed(w => w + 1);
const url = `https://highlighter.com/a/${id}`;
return <a href={url} target="_blank" >{token}</a>;
}}
</For>
}
const renderLongFormMention = (mention: PrimalNote | undefined, index?: number) => {
if(!mention) return <></>;
const url = `https://highlighter.com/${mention.user.npub}/${mention.post.noteId}`
if (props.noPreviews) {
return renderLinks({
type: 'link',
tokens: [`https://highlighter.com/${mention.user.npub}/${mention.post.noteId}`],
});
}
const preview = {
url,
description: (mention.post.tags.find(t => t[0] === 'summary') || [])[1] || mention.post.content.slice(0, 100),
images: [(mention.post.tags.find(t => t[0] === 'image') || [])[1] || mention.user.picture],
title: (mention.post.tags.find(t => t[0] === 'title') || [])[1] || authorName(mention.user),
}
return <LinkPreview
preview={preview}
bordered={props.isEmbeded}
isLast={index === content.length-1}
/>;
};
const renderNoteMention = (item: NoteContent, index?: number) => {
return <For each={item.tokens}>
{(token) => {
@ -825,13 +964,20 @@ const ParsedNote: Component<{
if (ment) {
setWordsDisplayed(w => w + shortMentionInWords);
link = <div>
<EmbeddedNote
note={ment}
mentionedUsers={props.note.mentionedUsers || {}}
isLast={index === content.length-1}
/>
</div>;
if (ment.post.kind === Kind.LongForm) {
link = renderLongFormMention(ment, index)
}
else {
link = <div>
<EmbeddedNote
note={ment}
mentionedUsers={props.note.mentionedUsers || {}}
isLast={index === content.length-1}
alternativeBackground={props.altEmbeds}
/>
</div>;
}
}
}
@ -1030,8 +1176,21 @@ const ParsedNote: Component<{
</For>
};
const renderLnbc = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + 100);
return <Lnbc lnbc={token} />
}}
</For>
}
const renderContent = (item: NoteContent, index: number) => {
const renderers: Record<string, (item: NoteContent, index?: number) => JSXElement> = {
linebreak: renderLinebreak,
text: renderText,
@ -1047,9 +1206,11 @@ const ParsedNote: Component<{
link: renderLinks,
notemention: renderNoteMention,
usermention: renderUserMention,
comunity: renderComunityMention,
tagmention: renderTagMention,
hashtag: renderHashtag,
emoji: renderEmoji,
lnbc: renderLnbc,
}
return renderers[item.type] ?

View File

@ -0,0 +1,45 @@
import { A } from '@solidjs/router';
import { Component, For, Show } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import { authorName, nip05Verification, truncateNpub } from '../../stores/profile';
import { PrimalNote, PrimalUser } from '../../types/primal';
import Avatar from '../Avatar/Avatar';
import FollowButton from '../FollowButton/FollowButton';
import MentionedPerson from './MentionedPerson';
import styles from './PeopleList.module.scss';
const MentionedPeople: Component<{
mentioned: PrimalUser[],
label: string,
id?: string,
author: PrimalUser,
}> = (props) => {
const account = useAccountContext();
const author = () => props.author;
const mentioned = () => props.mentioned;
return (
<>
<div class={styles.heading}>{props.label}</div>
<div id="trending_section" class={styles.authorSection}>
<MentionedPerson
person={author()}
/>
<For each={mentioned()}>
{(person) =>
<MentionedPerson
person={person}
/>
}
</For>
</div>
</>
);
}
export default hookForDev(MentionedPeople);

View File

@ -0,0 +1,57 @@
import { A } from '@solidjs/router';
import { Component, For, Show } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import { authorName, nip05Verification, truncateNpub } from '../../stores/profile';
import { PrimalNote, PrimalUser } from '../../types/primal';
import Avatar from '../Avatar/Avatar';
import FollowButton from '../FollowButton/FollowButton';
import styles from './PeopleList.module.scss';
const MentionedPerson: Component<{
person: PrimalUser,
id?: string,
noAbout?: boolean,
}> = (props) => {
const account = useAccountContext();
return (
<A href={`/p/${props.person?.npub}`} class={styles.mentionedPerson}>
<div class={styles.header}>
<Avatar
size="sm"
user={props.person}
/>
<div class={styles.content}>
<div class={styles.name}>
{authorName(props.person)}
</div>
<div class={styles.verification} title={props.person?.nip05}>
<Show when={props.person?.nip05}>
<span
class={styles.verifiedBy}
title={props.person?.nip05}
>
{nip05Verification(props.person)}
</span>
</Show>
</div>
</div>
<Show when={account?.publicKey !== props.person?.pubkey || !account?.following.includes(props.person?.pubkey || '')}>
<FollowButton person={props.person} />
</Show>
</div>
<Show when={!props.noAbout}>
<div class={styles.about}>
{props.person.about || ''}
</div>
</Show>
</A>
);
}
export default hookForDev(MentionedPerson);

View File

@ -5,7 +5,7 @@
width: 100%;
height: 44px;
z-index: 5;
padding-bottom: 22px;
padding-block: 22px;
display:flex;
flex-direction: row;
align-items: center;
@ -25,6 +25,13 @@
}
}
.authorSection {
// position: -webkit-sticky;
// position: sticky;
// top: 0px;
// padding-top: 44px;
}
.trendingSection {
// position: -webkit-sticky;
// position: sticky;
@ -48,25 +55,42 @@
@include heading();
}
@mixin followButton {
grid-area: follow;
align-items: center;
display: flex;
align-items: center;
button {
width: 72px;
height: 28px;
background: var(--brand-gradient);
border-radius: 6px;
padding: 0px;
font-size: 12px;
font-weight: 600;
line-height: 16px;
margin: 0px;
}
}
.peopleList {
margin-bottom: 16px;
display: grid;
grid-template-columns: 52px 148px 72px;
grid-template-rows: 1fr;
grid-template-areas: "avatar content follow";
grid-column-gap: 14px;
display: flex;
gap: 8px;
text-decoration: none;
align-items: center;
width: 300px;
.avatar {
grid-area: avatar;
display: grid;
justify-items: center;
height: 52px;
height: 44px;
.avatarImg {
width: 52px;
height: 52px;
width: 44px;
height: 44px;
border-radius: 50%;
}
}
@ -75,13 +99,16 @@
grid-area: content;
display: flex;
flex-direction: column;
justify-content: flex-start;
justify-content: center;
gap: 4px;
flex-grow: 1;
.name {
color: var(--text-primary);
font-weight: 400;
font-size: 12px;
line-height: 12px;
font-size: 16px;
font-weight: 700;
line-height: 16px;
max-width: 168px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
@ -117,6 +144,22 @@
overflow: hidden;
}
.about {
font-size: 12px;
line-height: 16px;
font-weight: 400;
color: var(--text-tertiary-2);
overflow: hidden;
display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
-moz-box-orient: vertical;
-ms-box-orient: vertical;
text-overflow: ellipsis;
}
&:hover {
.message, .name, .time {
color: var(--text-primary);
@ -127,25 +170,6 @@
}
}
@mixin followButton {
grid-area: follow;
align-items: center;
display: flex;
align-items: center;
button {
width: 72px;
height: 28px;
background: var(--brand-gradient);
border-radius: 6px;
padding: 0px;
font-size: 12px;
font-weight: 600;
line-height: 16px;
margin: 0px;
}
}
.follow {
@include followButton;
}
@ -168,6 +192,118 @@
}
}
.mentionedPerson {
display: flex;
flex-direction: column;
width: 300px;
gap: 8px;
padding-block: 12px;
border-bottom: 1px solid var(--devider);
text-decoration: none;
.header {
display: flex;
gap: 8px;
align-items: center;
.content {
grid-area: content;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
flex-grow: 1;
.name {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 18px;
max-width: 168px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.verification {
font-size: 15px;
font-weight: 400;
line-height: 18px;
color: var(--text-tertiary-2);
max-width: 168px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.verifiedName {
font-size: 12px;
line-height: 16px;
font-weight: 700;
color: var(--text-tertiary-2);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.npub {
font-size: 12px;
line-height: 16px;
font-weight: 400;
color: var(--text-tertiary-2);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
.follow {
@include followButton;
}
.unfollow {
@include followButton;
button {
background-color: var(--background-card);
background: linear-gradient(var(--background-card), var(--background-card)) padding-box,
var(--brand-gradient) border-box;
border: 1px solid transparent;
}
}
}
.about {
width: 300px;
font-size: 14px;
font-weight: 400;
line-height: 18px;
color: var(--text-tertiary-2);
overflow: hidden;
display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
-moz-box-orient: vertical;
-ms-box-orient: vertical;
text-overflow: ellipsis;
}
&:hover:not(:has(button:hover)) {
.content {
.name {
color: var(--text-primary);
text-decoration: underline;
}
}
cursor: pointer;
}
}
.verifiedIcon {
width:13px;
height: 12px;

View File

@ -1,55 +1,52 @@
import { A } from '@solidjs/router';
import { Component, For, Show } from 'solid-js';
import { Component, Show } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import { authorName, nip05Verification, truncateNpub } from '../../stores/profile';
import { PrimalUser } from '../../types/primal';
import Avatar from '../Avatar/Avatar';
import FollowButton from '../FollowButton/FollowButton';
import { PrimalNote, PrimalUser } from '../../types/primal';
import MentionedPeople from './MentionedPeople';
import styles from './PeopleList.module.scss';
import Repliers from './Repliers';
const PeopleList: Component<{ people: PrimalUser[], label: string, id?: string }> = (props) => {
const people = () => props.people;
const PeopleList: Component<{
people: PrimalUser[],
label?: string,
mentionLabel?: string,
id?: string,
note?: PrimalNote,
}> = (props) => {
const author = () => props.note?.user;
const mentioned = () => {
if (!props.note) return [];
return props.people.filter(p => p.pubkey !== author()?.pubkey && (props.note?.mentionedUsers || {})[p.pubkey] !== undefined);
};
const repliers = () => {
if (!props.note) return props.people;
return props.people.filter(p => p.pubkey !== author()?.pubkey && (props.note?.mentionedUsers || {})[p.pubkey] === undefined);
}
return (
<div id={props.id} class={styles.stickyWrapper}>
<div class={styles.heading}>{props.label}</div>
<div id="trending_section" class={styles.trendingSection}>
<For each={people()}>
{
(person) =>
<A href={`/p/${person?.npub}`} class={styles.peopleList}>
<div class={styles.avatar}>
<Avatar
size="md"
user={person}
/>
</div>
<div class={styles.content}>
<div class={styles.name}>
{authorName(person)}
</div>
<div class={styles.verification} title={person?.nip05}>
<Show when={person?.nip05}>
<span
class={styles.verifiedBy}
title={person?.nip05}
>
{nip05Verification(person)}
</span>
</Show>
</div>
<div class={styles.npub} title={person?.npub}>
{truncateNpub(person?.npub)}
</div>
</div>
<FollowButton person={person} />
</A>
}
</For>
<div id={props.id} class={styles.stickyWrapper}>
<Show when={author()}>
<MentionedPeople
mentioned={mentioned()}
author={author()}
label={props.mentionLabel || ''}
/>
</Show>
<Show when={repliers().length > 0}>
<Repliers
people={repliers()}
label={props.label || ''}
/>
</Show>
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { A } from '@solidjs/router';
import { Component, For, Show } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import { authorName, nip05Verification, truncateNpub } from '../../stores/profile';
import { PrimalNote, PrimalUser } from '../../types/primal';
import Avatar from '../Avatar/Avatar';
import FollowButton from '../FollowButton/FollowButton';
import MentionedPerson from './MentionedPerson';
import styles from './PeopleList.module.scss';
const Repliers: Component<{
people: PrimalUser[],
label: string,
id?: string,
}> = (props) => {
const account = useAccountContext();
const people = () => props.people;
return (
<>
<div class={styles.heading}>{props.label}</div>
<div id="trending_section" class={styles.trendingSection}>
<For each={people()}>
{
(person) => <MentionedPerson person={person} noAbout={true} />
}
</For>
</div>
</>
);
}
export default hookForDev(Repliers);

View File

@ -0,0 +1,285 @@
import { A } from '@solidjs/router';
import { nip19 } from 'nostr-tools';
import { Component, createEffect, For, JSXElement, onCleanup, Show } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { APP_ID } from '../../App';
import { linebreakRegex, urlExtractRegex, specialCharsRegex, hashtagCharsRegex, profileRegexG, Kind } from '../../constants';
import { hexToNpub, npubToHex } from '../../lib/keys';
import { isInterpunction, isUrl, isUserMention, isHashtag } from '../../lib/notes';
import { getUserProfiles } from '../../lib/profile';
import { subscribeTo } from '../../sockets';
import { userName, truncateNpub } from '../../stores/profile';
import { NostrUserContent } from '../../types/primal';
import MentionedUserLink from '../Note/MentionedUserLink/MentionedUserLink';
import { NoteContent } from '../ParsedNote/ParsedNote';
import styles from '../../pages/Profile.module.scss';
const ProfileAbout: Component<{about: string | undefined }> = (props) => {
const [usersMentionedInAbout, setUsersMentionedInAbout] = createStore<Record<string, any>>({});
const [aboutTokens, setAboutTokens] = createStore<string[]>([]);
const [aboutContent, setAboutContent] = createStore<NoteContent[]>([]);
let lastSignificantContent = 'text';
const tokenizeAbout = (about: string) => {
const content = about.replace(linebreakRegex, ' __LB__ ').replace(/\s+/g, ' __SP__ ');
const tokens = content.split(/[\s]+/);
setAboutTokens(() => [...tokens]);
}
const updateAboutContent = (type: string, token: string, meta?: Record<string, any>) => {
setAboutContent((contentArray) => {
if (contentArray.length > 0 && contentArray[contentArray.length -1].type === type) {
const c = { ...contentArray[contentArray.length - 1] };
c.tokens = [...c.tokens, token];
if (meta) {
c.meta = { ...meta };
}
return [ ...contentArray.slice(0, contentArray.length - 1), { ...c }];
}
return [...contentArray, { type, tokens: [token], meta: { ...meta } }]
});
}
const parseAboutToken = (token: string) => {
if (token === '__LB__') {
updateAboutContent('linebreak', token);
lastSignificantContent = 'LB';
return;
}
if (token === '__SP__') {
if (!['LB'].includes(lastSignificantContent)) {
updateAboutContent('text', ' ');
}
return;
}
if (isInterpunction(token)) {
lastSignificantContent = 'text';
updateAboutContent('text', token);
return;
}
if (isUrl(token)) {
const index = token.indexOf('http');
if (index > 0) {
const prefix = token.slice(0, index);
const matched = (token.match(urlExtractRegex) || [])[0];
if (matched) {
const suffix = token.substring(matched.length + index, token.length);
parseAboutToken(prefix);
parseAboutToken(matched);
parseAboutToken(suffix);
return;
} else {
parseAboutToken(prefix);
parseAboutToken(token.slice(index));
return;
}
}
lastSignificantContent = 'link';
updateAboutContent('link', token);
return;
}
if (isUserMention(token)) {
lastSignificantContent = 'usermention';
updateAboutContent('usermention', token);
return;
}
if (isHashtag(token)) {
lastSignificantContent = 'hashtag';
updateAboutContent('hashtag', token);
return;
}
lastSignificantContent = 'text';
updateAboutContent('text', token);
return;
};
const renderLinebreak = (item: NoteContent) => {
// Allow max consecutive linebreak
const len = Math.min(2, item.tokens.length);
const lineBreaks = Array(len).fill(<br/>)
return <For each={lineBreaks}>{_ => <br/>}</For>
};
const renderText = (item: NoteContent) => {
let tokens = [];
for (let i=0;i<item.tokens.length;i++) {
const token = item.tokens[i];
tokens.push(token)
}
const text = tokens.join(' ').replaceAll('&lt;', '<').replaceAll('&gt;', '>');
return <>{text}</>;
};
const renderLinks = (item: NoteContent, index?: number) => {
return <For each={item.tokens}>
{(token) => {
return <span data-url={token}><a link href={token} target="_blank" >{token}</a></span>;
}}
</For>
};
const renderUserMention = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
let [_, id] = token.split(':');
if (!id) {
return <>{token}</>;
}
let end = '';
let match = specialCharsRegex.exec(id);
if (match) {
const i = match.index;
end = id.slice(i);
id = id.slice(0, i);
}
try {
const profileId = nip19.decode(id).data as string | nip19.ProfilePointer;
const hex = typeof profileId === 'string' ? profileId : profileId.pubkey;
const npub = hexToNpub(hex);
const path = `/p/${npub}`;
let user = usersMentionedInAbout && usersMentionedInAbout[hex];
const label = user ? userName(user) : truncateNpub(npub);
return !user ?
<><A href={path}>@{label}</A>{end}</> :
<>{MentionedUserLink({ user, npub })}{end}</>;
} catch (e) {
return <span class={styles.error}> {token}</span>;
}
}}
</For>
};
const renderHashtag = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
let [_, term] = token.split('#');
let end = '';
let match = hashtagCharsRegex.exec(term);
if (match) {
const i = match.index;
end = term.slice(i);
term = term.slice(0, i);
}
const embeded = <A href={`/search/%23${term}`}>#{term}</A>;
return <span class="whole"> {embeded}{end}</span>;
}}
</For>
};
const renderAboutContent = (item: NoteContent, index: number) => {
const renderers: Record<string, (item: NoteContent, index?: number) => JSXElement> = {
linebreak: renderLinebreak,
text: renderText,
link: renderLinks,
usermention: renderUserMention,
hashtag: renderHashtag,
}
return renderers[item.type] ?
renderers[item.type](item, index) :
<></>;
};
createEffect(() => {
if (aboutTokens.length === 0) return;
for (let i=0; i < aboutTokens.length; i++) {
parseAboutToken(aboutTokens[i]);
}
});
const parseForMentions = (about: string) => {
let userMentions = [];
let m;
do {
m = profileRegexG.exec(about);
if (m) {
userMentions.push(npubToHex(m[1]))
}
} while (m);
const subId = `pa_u_${APP_ID}`;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EVENT' && content?.kind === Kind.Metadata) {
const user = content as NostrUserContent;
const profile = JSON.parse(user.content);
setUsersMentionedInAbout(() => ({[user.pubkey]: ({ ...profile })}));
}
if(type === 'EOSE') {
unsub();
tokenizeAbout(about);
}
});
getUserProfiles(userMentions, subId);
};
createEffect(() => {
if (props.about && props.about.length > 0) {
setAboutContent([]);
setAboutTokens([]);
setUsersMentionedInAbout(reconcile({}));
parseForMentions(props.about);
}
});
return (
<Show when={aboutContent.length > 0}>
<div class={styles.profileAbout}>
<For each={aboutContent}>
{(item, index) => renderAboutContent(item, index())}
</For>
</div>
</Show>
);
}
export default ProfileAbout;

View File

@ -75,3 +75,7 @@
}
}
}
.placeholderDiv {
width: 72px;
}

View File

@ -14,6 +14,7 @@ import Avatar from '../Avatar/Avatar';
import FollowButton from '../FollowButton/FollowButton';
import { A } from '@solidjs/router';
import { humanizeNumber } from '../../lib/stats';
import { useAccountContext } from '../../contexts/AccountContext';
const ProfileContact: Component<{
@ -24,6 +25,7 @@ const ProfileContact: Component<{
}> = (props) => {
const intl = useIntl();
const account = useAccountContext();
return (
<div id={props.id} class={styles.profileContact}>
@ -57,7 +59,12 @@ const ProfileContact: Component<{
</div>
</div>
</Show>
<FollowButton person={props.profile} postAction={props.postAction} />
<Show
when={account?.publicKey !== props.profile?.pubkey || !account?.following.includes(props.profile?.pubkey || '')}
fallback={<div class={styles.placeholderDiv}></div>}
>
<FollowButton person={props.profile} postAction={props.postAction} />
</Show>
</div>
</div>
);

View File

@ -18,7 +18,7 @@
.userInfo {
display: flex;
justify-content: flex-start;
align-items: flex-start;
align-items: flex-end;
.avatar {
display: flex;
@ -30,7 +30,7 @@
display: flex;
flex-direction: column;
margin-left: 8px;
justify-content: center;
justify-content: space-between;
height: 44px;
.name {

View File

@ -1,23 +1,15 @@
import { useIntl } from '@cookbook/solid-intl';
import { Tabs } from '@kobalte/core';
import { Component, createEffect, createSignal, For, Show } from 'solid-js';
import { defaultZap, defaultZapOptions } from '../../constants';
import { useAccountContext } from '../../contexts/AccountContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { Component, For, Show } from 'solid-js';
import { hookForDev } from '../../lib/devTools';
import { truncateNumber } from '../../lib/notifications';
import { zapNote, zapProfile } from '../../lib/zap';
import { authorName, nip05Verification, truncateNpub, userName } from '../../stores/profile';
import { toastZapFail, zapCustomOption, actions as tActions, placeholders as tPlaceholders, zapCustomAmount } from '../../translations';
import { PrimalNote, PrimalUser, ZapOption } from '../../types/primal';
import { debounce } from '../../utils';
import { hexToNpub } from '../../lib/keys';
import { authorName, nip05Verification, truncateNpub } from '../../stores/profile';
import { profile as tProfile } from '../../translations';
import { PrimalUser } from '../../types/primal';
import Avatar from '../Avatar/Avatar';
import ButtonCopy from '../Buttons/ButtonCopy';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import QrCode from '../QrCode/QrCode';
import TextInput from '../TextInput/TextInput';
import { useToastContext } from '../Toaster/Toaster';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './ProfileQrCodeModal.module.scss';
@ -29,19 +21,22 @@ const ProfileQrCodeModal: Component<{
onClose?: () => void,
}> = (props) => {
const toast = useToastContext();
const account = useAccountContext();
const intl = useIntl();
const settings = useSettingsContext();
const profileData = () => Object.entries({
pubkey: {
title: 'Public key',
data: props.profile.npub || props.profile.pubkey,
title: intl.formatMessage(tProfile.qrModal.pubkey),
data: `nostr:${props.profile.npub || hexToNpub(props.profile.pubkey)}`,
dataLabel: props.profile.npub || hexToNpub(props.profile.pubkey) || '',
type: 'nostr',
test: props.profile.npub || hexToNpub(props.profile.pubkey),
},
lnAddress: {
title: 'Lightning address',
data: props.profile.lud16 || props.profile.lud06,
title: intl.formatMessage(tProfile.qrModal.ln),
data: `lightning:${props.profile.lud16 || props.profile.lud06}`,
dataLabel: props.profile.lud16 || props.profile.lud06 || '',
type: 'lightning',
test: props.profile.lud16 || props.profile.lud06,
}
});
@ -82,7 +77,7 @@ const ProfileQrCodeModal: Component<{
<Tabs.List class={styles.tabs}>
<For each={profileData()}>
{([key, info]) =>
<Show when={info.data}>
<Show when={info.test}>
<Tabs.Trigger class={styles.tab} value={key} >
{info.title}
</Tabs.Trigger>
@ -95,9 +90,9 @@ const ProfileQrCodeModal: Component<{
<For each={profileData()}>
{([key, info]) =>
<Show when={info.data}>
<Show when={info.test}>
<Tabs.Content class={styles.tabContent} value={key}>
<QrCode data={info.data} />
<QrCode data={info.data} type={info.type} />
</Tabs.Content>
</Show>
}
@ -109,7 +104,7 @@ const ProfileQrCodeModal: Component<{
<For each={profileData()}>
{([key, info]) =>
<Show when={info.data}>
<Show when={info.test}>
<div class={styles.keyEntry}>
<div class={styles.label}>
{info.title}:
@ -117,9 +112,9 @@ const ProfileQrCodeModal: Component<{
<div class={styles.value}>
<ButtonCopy
light={true}
copyValue={info.data}
copyValue={info.dataLabel}
labelBeforeIcon={true}
label={truncateNpub(info.data)}
label={truncateNpub(info.dataLabel)}
/>
</div>
</div>

View File

@ -72,6 +72,7 @@
line-height: 20px;
color: var(--text-secondary);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px;
@ -79,7 +80,7 @@
line-break: anywhere;
button {
margin: 0;
margin: 8px;
padding: 0;
margin-left: 8px;
background: none;
@ -90,6 +91,10 @@
color: var(--accent-2);
width: auto;
height: auto;
&:hover {
color: var(--text-primary);
}
}
}
@ -145,7 +150,7 @@
font-size: 14px;
font-weight: 400;
line-height: 14px;
width: min(560px, 100%);
max-width: 460px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -269,4 +274,10 @@
display: none;
}
}
.zapInfo {
max-width: 86%;
}
.zapMessage {
max-width: 80% !important;
}
}

View File

@ -276,9 +276,12 @@ const ProfileTabs: Component<{
<Note note={note} shorten={true} />
)}
</For>
<Paginator loadNextPage={() => {
profile?.actions.fetchNextPage();
}}/>
<Paginator
loadNextPage={() => {
profile?.actions.fetchNextPage();
}}
isSmall={true}
/>
</Match>
</Switch>
</div>
@ -329,9 +332,12 @@ const ProfileTabs: Component<{
<Note note={reply} shorten={true} />
)}
</For>
<Paginator loadNextPage={() => {
profile?.actions.fetchNextRepliesPage();
}}/>
<Paginator
loadNextPage={() => {
profile?.actions.fetchNextRepliesPage();
}}
isSmall={true}
/>
</Match>
</Switch>
</div>
@ -364,7 +370,7 @@ const ProfileTabs: Component<{
/>
</div>}
</For>
<Paginator loadNextPage={loadMoreFollows}/>
<Paginator loadNextPage={loadMoreFollows} isSmall={true} />
</Show>
</div>
</Tabs.Content>
@ -374,9 +380,9 @@ const ProfileTabs: Component<{
<Show
when={!profile?.isFetchingFollowers}
fallback={
<div style="margin-top: 40px;">
<Loader />
</div>
<div style="margin-top: 40px;">
<Loader />
</div>
}
>
<For
@ -400,7 +406,7 @@ const ProfileTabs: Component<{
</div>
}
</For>
<Paginator loadNextPage={loadMoreFollowers}/>
<Paginator loadNextPage={loadMoreFollowers} isSmall={true} />
</Show>
</div>
</Tabs.Content>
@ -475,7 +481,7 @@ const ProfileTabs: Component<{
</A>
}
</For>
<Paginator loadNextPage={profile?.actions.fetchNextZapsPage}/>
<Paginator loadNextPage={profile?.actions.fetchNextZapsPage} isSmall={true} />
</Show>
</div>
</Tabs.Content>

View File

@ -1,19 +1,30 @@
import QRCodeStyling from 'qr-code-styling';
import { Component, createEffect, onMount } from 'solid-js';
import { Component, createEffect } from 'solid-js';
import primalLogoFire from '../../assets/icons/logo_fire.svg'
import primalLogoIce from '../../assets/icons/logo_ice.svg'
import { useSettingsContext } from '../../contexts/SettingsContext';
import qrNostrich from '../../assets/icons/qr_nostrich.svg'
import qrLightning from '../../assets/icons/qr_lightning.svg'
import styles from './QrCode.module.scss';
const QrCode: Component<{ data: string }> = (props) => {
const QrCode: Component<{ data: string, type?: string }> = (props) => {
let qrSlot: HTMLDivElement | undefined;
const settings = useSettingsContext();
const qrTypes = ['nostr', 'lightning'];
const isIce = () => ['midnight', 'ice'].includes(settings?.theme || '');
const qrType = () => {
const t = props.type && qrTypes.includes(props.type) ?
props.type :
'none';
const qrImages: Record<string, string | undefined> = {
nostr: qrNostrich,
lightning: qrLightning,
none: undefined,
}
return qrImages[t];
};
createEffect(() => {
const qrCode = new QRCodeStyling({
@ -21,17 +32,17 @@ const QrCode: Component<{ data: string }> = (props) => {
height: 280,
type: "svg",
data: props.data,
margin: 6,
image: isIce() ? primalLogoIce : primalLogoFire,
margin: 1,
image: qrType(),
qrOptions: {
typeNumber: 0,
mode: "Byte",
errorCorrectionLevel :"Q",
},
imageOptions: {
hideBackgroundDots: true,
hideBackgroundDots: false,
imageSize:0.2,
margin: 4,
margin: 0,
},
dotsOptions:{
type: "rounded",

View File

@ -7,7 +7,7 @@
display: flex;
flex-direction: column;
padding: 20px;
padding: 16px;
.header {
display: flex;
@ -73,7 +73,7 @@
.tab {
position: relative;
display: inline-block;
padding-inline: 14px;
padding-inline: 12px;
padding-block: 2px;
border: none;
background: none;
@ -107,13 +107,14 @@
height: 440px;
overflow-x: hidden;
overflow-y: scroll;
padding-right: 8px;
@keyframes fadeIn {
from {
opacity:0;
opacity:0;
}
to {
opacity:1;
opacity:1;
}
}
@ -235,7 +236,7 @@
font-size: 15px;
font-weight: 400;
line-height: 18px;
max-width: 248px;
max-width: 448px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@ -1,27 +1,33 @@
import { useIntl } from '@cookbook/solid-intl';
import { Tabs } from '@kobalte/core';
import { A } from '@solidjs/router';
import { Component, createEffect, createSignal, For, Show } from 'solid-js';
import { Component, createEffect, createSignal, For, Match, onMount, Show, Switch } from 'solid-js';
import { createStore } from 'solid-js/store';
import { APP_ID } from '../../App';
import { Kind } from '../../constants';
import { useAccountContext } from '../../contexts/AccountContext';
import { ReactionStats } from '../../contexts/AppContext';
import { hookForDev } from '../../lib/devTools';
import { hexToNpub } from '../../lib/keys';
import { getEventReactions } from '../../lib/notes';
import { truncateNumber, truncateNumber2 } from '../../lib/notifications';
import { getEventQuotes, getEventQuoteStats, getEventReactions, getEventZaps, setLinkPreviews } from '../../lib/notes';
import { truncateNumber2 } from '../../lib/notifications';
import { updateStore } from '../../services/StoreService';
import { subscribeTo } from '../../sockets';
import { convertToNotes } from '../../stores/note';
import { userName } from '../../stores/profile';
import { actions as tActions, placeholders as tPlaceholders } from '../../translations';
import { actions as tActions, placeholders as tPlaceholders, reactionsModal } from '../../translations';
import { FeedPage, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalNote, PrimalUser } from '../../types/primal';
import { parseBolt11 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import Loader from '../Loader/Loader';
import Modal from '../Modal/Modal';
import Note from '../Note/Note';
import Paginator from '../Paginator/Paginator';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './ReactionsModal.module.scss';
const ReactionsModal: Component<{
id?: string,
noteId: string | undefined,
@ -30,18 +36,42 @@ const ReactionsModal: Component<{
}> = (props) => {
const intl = useIntl();
const account = useAccountContext();
const [selectedTab, setSelectedTab] = createSignal('likes');
const [likeList, setLikeList] = createStore<any[]>([]);
const [zapList, setZapList] = createStore<any[]>([]);
const [repostList, setRepostList] = createStore<any[]>([]);
const [quotesList, setQuotesList] = createStore<PrimalNote[]>([]);
const [quoteCount, setQuoteCount] = createSignal(0);
const [isFetching, setIsFetching] = createSignal(false);
let loadedLikes = 0;
let loadedZaps = 0;
let loadedReposts = 0;
let loadedQuotes = 0;
createEffect(() => {
const count = quoteCount();
if (count === 0 && props.stats.quotes > 0) {
setQuoteCount(props.stats.quotes);
}
})
createEffect(() => {
if (props.noteId && props.stats.openOn) {
setSelectedTab(props.stats.openOn);
}
});
createEffect(() => {
if (props.noteId) {
getQuoteCount();
}
});
createEffect(() => {
switch (selectedTab()) {
@ -54,6 +84,9 @@ const ReactionsModal: Component<{
case 'reposts':
loadedReposts === 0 && getReposts();
break;
case 'quotes':
loadedQuotes === 0 && getQuotes();
break;
}
});
@ -63,10 +96,13 @@ const ReactionsModal: Component<{
setZapList(() => []);
setRepostList(() => []);
setSelectedTab(() => 'likes');
setQuotesList(() => []);
setQuoteCount(() => 0);
loadedLikes = 0;
loadedZaps = 0;
loadedReposts = 0;
loadedQuotes = 0;
}
});
@ -178,7 +214,8 @@ const ReactionsModal: Component<{
});
setIsFetching(() => true);
getEventReactions(props.noteId, Kind.Zap, subId, offset);
getEventZaps(props.noteId, account?.publicKey, subId, 20, offset);
// getEventReactions(props.noteId, Kind.Zap, subId, offset);
};
const getReposts = (offset = 0) => {
@ -189,6 +226,7 @@ const ReactionsModal: Component<{
const users: any[] = [];
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
setRepostList((reposts) => [...reposts, ...users]);
loadedReposts = repostList.length;
@ -218,7 +256,131 @@ const ReactionsModal: Component<{
getEventReactions(props.noteId, Kind.Repost, subId, offset);
};
const totalCount = () => props.stats.likes + props.stats.quotes + props.stats.reposts + props.stats.zaps;
const getQuotes = (offset = 0) => {
if (!props.noteId) return;
const subId = `nr_q_${props.noteId}_${APP_ID}`;
let page: FeedPage = {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
};
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
const pageNotes = convertToNotes(page);
setQuotesList((notes) => [...notes, ...pageNotes]);
loadedQuotes = quotesList.length;
setIsFetching(() => false);
unsub();
}
if (type === 'EVENT') {
if (content?.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if (content?.kind === Kind.Text) {
const message = content as NostrNoteContent;
const isAlreadyInPage = page.messages.find(m => m.id === message.id);
const isAlreadyInTheList = quotesList.find(n => n.id === message.id);
if (isAlreadyInPage || isAlreadyInTheList) {
return;
}
page.messages.push(message);
return;
}
if (content?.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
page.postStats[stat.event_id] = { ...stat };
return;
}
if (content?.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (!page.mentions) {
page.mentions = {};
}
page.mentions[mention.id] = { ...mention };
return;
}
if (content?.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
page.noteActions[noteActions.event_id] = { ...noteActions };
return;
}
if (content?.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
}
});
setIsFetching(() => true);
getEventQuotes(props.noteId, subId, offset);
};
const getQuoteCount = () => {
if (!props.noteId) return;
const subId = `nr_qc_${props.noteId}_${APP_ID}`;
const unsub = subscribeTo(subId, (type,_, content) => {
if (type === 'EOSE') {
unsub();
}
if (type === 'EVENT') {
if (content?.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
setQuoteCount(() => quoteStats.count || 0);
}
}
});
getEventQuoteStats(props.noteId, subId);
}
const totalCount = () => props.stats.likes + (quoteCount() || props.stats.quotes || 0) + props.stats.reposts + props.stats.zaps;
return (
<Modal
@ -236,27 +398,33 @@ const ReactionsModal: Component<{
</button>
</div>
<Switch>
<Match when={!isFetching && totalCount() === 0}>
{intl.formatMessage(tPlaceholders.noReactionDetails)}
</Match>
</Switch>
<div class={styles.description}>
<Tabs.Root value={selectedTab()} onChange={setSelectedTab}>
<Tabs.List class={styles.tabs}>
<Show when={props.stats.likes > 0}>
<Tabs.Trigger class={styles.tab} value={'likes'} >
Likes ({props.stats.likes})
{intl.formatMessage(reactionsModal.tabs.likes, { count: props.stats.likes })}
</Tabs.Trigger>
</Show>
<Show when={props.stats.zaps > 0}>
<Tabs.Trigger class={styles.tab} value={'zaps'} >
Zaps ({props.stats.zaps})
{intl.formatMessage(reactionsModal.tabs.zaps, { count: props.stats.zaps })}
</Tabs.Trigger>
</Show>
<Show when={props.stats.reposts > 0}>
<Tabs.Trigger class={styles.tab} value={'reposts'} >
Reposts ({props.stats.reposts})
{intl.formatMessage(reactionsModal.tabs.reposts, { count: props.stats.reposts })}
</Tabs.Trigger>
</Show>
<Show when={props.stats.quotes > 0}>
<Show when={quoteCount() > 0}>
<Tabs.Trigger class={styles.tab} value={'quotes'} >
Quotes ({props.stats.quotes})
{intl.formatMessage(reactionsModal.tabs.quotes, { count: quoteCount() })}
</Tabs.Trigger>
</Show>
@ -268,7 +436,12 @@ const ReactionsModal: Component<{
each={likeList}
fallback={
<Show when={!isFetching()}>
{intl.formatMessage(tPlaceholders.noLikeDetails)}
<Show
when={totalCount() > 0}
fallback={intl.formatMessage(tPlaceholders.noReactionDetails)}
>
{intl.formatMessage(tPlaceholders.noLikeDetails)}
</Show>
</Show>
}
>
@ -295,7 +468,7 @@ const ReactionsModal: Component<{
loadNextPage={() => {
const len = likeList.length;
if (len === 0) return;
getLikes(len);
getLikes(len+1);
}}
isSmall={true}
/>
@ -313,13 +486,18 @@ const ReactionsModal: Component<{
each={zapList}
fallback={
<Show when={!isFetching()}>
{intl.formatMessage(tPlaceholders.noZapDetails)}
<Show
when={totalCount() > 0}
fallback={intl.formatMessage(tPlaceholders.noReactionDetails)}
>
{intl.formatMessage(tPlaceholders.noZapDetails)}
</Show>
</Show>
}
>
{zap =>
<A
href={`/p/${zap.npub}`}
href={`/p/${hexToNpub(zap.pubkey)}`}
class={styles.zapItem}
onClick={props.onClose}
>
@ -348,7 +526,7 @@ const ReactionsModal: Component<{
loadNextPage={() => {
const len = zapList.length;
if (len === 0) return;
getZaps(len);
getZaps(len+1);
}}
isSmall={true}
/>
@ -365,7 +543,12 @@ const ReactionsModal: Component<{
each={repostList}
fallback={
<Show when={!isFetching()}>
{intl.formatMessage(tPlaceholders.noRepostDetails)}
<Show
when={totalCount() > 0}
fallback={intl.formatMessage(tPlaceholders.noReactionDetails)}
>
{intl.formatMessage(tPlaceholders.noRepostDetails)}
</Show>
</Show>
}
>
@ -392,7 +575,7 @@ const ReactionsModal: Component<{
loadNextPage={() => {
const len = repostList.length;
if (len === 0) return;
getReposts(len);
getReposts(len+1);
}}
isSmall={true}
/>
@ -405,7 +588,36 @@ const ReactionsModal: Component<{
</Show>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value={'quotes'}>
All the quotes
<For
each={quotesList}
fallback={
<Show when={!isFetching()}>
<Show
when={totalCount() > 0}
fallback={intl.formatMessage(tPlaceholders.noReactionDetails)}
>
{intl.formatMessage(tPlaceholders.noQuoteDetails)}
</Show>
</Show>
}
>
{quote => (
<Note
note={quote}
shorten={true}
noteType="reaction"
onClick={props.onClose}
/>
)}
</For>
<Paginator
loadNextPage={() => {
const len = quotesList.length;
if (len === 0) return;
getQuotes(len+1);
}}
isSmall={true}
/>
</Tabs.Content>
</Tabs.Root>
</div>

View File

@ -86,7 +86,7 @@
padding: 12px;
background-color: var(--background-card);
border: none;
border-bottom: 1px solid var(--devider);
border-block: 1px solid var(--devider);
border-radius: 0;
outline: none;
display: flex;

View File

@ -41,7 +41,7 @@
.userName {
font-weight: 700;
font-size: 15px;
line-height: 16px;
line-height: 18px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
@ -56,7 +56,7 @@
.verification {
font-weight: 400;
font-size: 12px;
line-height: 12px;
line-height: 14px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;

View File

@ -1,4 +1,4 @@
import { ContentModeration, FeedPage, } from "./types/primal";
import { ContentModeration, FeedPage, LnbcInvoice, } from "./types/primal";
import logoFire from './assets/icons/logo_fire.svg';
import logoIce from './assets/icons/logo_ice.svg';
@ -101,10 +101,13 @@ export enum Kind {
ChannelHideMessage = 43,
ChannelMuteUser = 44,
Zap = 9735,
LongForm = 30_023,
Zap = 9_735,
MuteList = 10_000,
RelayList = 10_002,
Bookmarks = 10_003,
CategorizedPeople = 30_000,
Settings = 30_078,
@ -130,11 +133,16 @@ export enum Kind {
Releases = 10_000_124,
ImportResponse = 10_000_127,
LinkMetadata = 10_000_128,
EventZapInfo = 10_000_129,
FilteringReason = 10_000_131,
UserFollowerCounts = 10_000_133,
SuggestedUsersByCategory = 10_000_134,
UploadChunk = 10_000_135,
UserRelays=10_000_139,
RelayHint=10_000_141,
NoteQuoteStats=10_000_143,
WALLET_OPERATION = 10_000_300,
}
export const relayConnectingTimeout = 1000;
@ -194,8 +202,8 @@ export const notificationTypeUserProps: Record<string, string> = {
[NotificationType.YOUR_POST_WAS_REPOSTED]: 'who_reposted_it',
[NotificationType.YOUR_POST_WAS_REPLIED_TO]: 'who_replied_to_it',
[NotificationType.YOU_WERE_MENTIONED_IN_POST]: 'you_were_mentioned_in',
[NotificationType.YOUR_POST_WAS_MENTIONED_IN_POST]: 'your_post_were_mentioned_in',
[NotificationType.YOU_WERE_MENTIONED_IN_POST]: 'you_were_mentioned_by',
[NotificationType.YOUR_POST_WAS_MENTIONED_IN_POST]: 'your_post_were_mentioned_by',
[NotificationType.POST_YOU_WERE_MENTIONED_IN_WAS_ZAPPED]: 'who_zapped_it',
[NotificationType.POST_YOU_WERE_MENTIONED_IN_WAS_LIKED]: 'who_liked_it',
@ -251,6 +259,8 @@ export const urlRegexG = /https?:\/\/(www\.)?[-a-zA-Z0-9\u00F0-\u02AF@:%._\+~#=]
export const urlExtractRegex = /https?:\/\/\S+\.[^()]+(?:\([^)]*\))*/;
export const interpunctionRegex = /^(\.|,|;|\?|\!)$/;
export const emojiRegex = /(?:\s|^)\:\w+\:/;
export const lnRegex = /lnbc[a-zA-Z0-9]*/;
export const lnUnifiedRegex = /bitcoin:[a-zA-Z0-9]*(\?.*)lightning=([a-zA-Z0-9]*)(&.*|$)/;
export const hashtagRegex = /(?:\s|^)#[^\s!@#$%^&*(),.?":{}|<>]+/i;
export const linebreakRegex = /(\r\n|\r|\n)/ig;
@ -259,6 +269,8 @@ export const noteRegex = /nostr:((note|nevent)1\w+)\b/g;
export const noteRegexLocal = /nostr:((note|nevent)1\w+)\b/;
export const profileRegex = /nostr:((npub|nprofile)1\w+)\b/;
export const profileRegexG = /nostr:((npub|nprofile)1\w+)\b/g;
export const addrRegex = /nostr:((naddr)1\w+)\b/;
export const addrRegexG = /nostr:((naddr)1\w+)\b/g;
export const editMentionRegex = /(?:\s|^)@\`(.*?)\`/ig;
export const specialCharsRegex = /[^A-Za-z0-9]/;
@ -384,3 +396,10 @@ export const uploadLimit = {
regular: 100,
premium: 1024,
}
export const emptyInvoice: LnbcInvoice = {
paymentRequest: '',
sections: [],
expiry: 0,
route_hints: [],
};

View File

@ -28,21 +28,21 @@ import { sendContacts, sendLike, sendMuteList, triggerImportEvents } from "../li
// @ts-ignore Bad types in nostr-tools
import { generatePrivateKey, Relay, getPublicKey as nostrGetPubkey, nip19 } from "nostr-tools";
import { APP_ID } from "../App";
import { getLikes, getFilterlists, getProfileContactList, getProfileMuteList, getUserProfiles, sendFilterlists, getAllowlist, sendAllowList, getRelays, sendRelays, extractRelayConfigFromTags } from "../lib/profile";
import { clearSec, getStorage, getStoredProfile, readEmojiHistory, readSecFromStorage, saveEmojiHistory, saveFollowing, saveLikes, saveMuted, saveMuteList, saveRelaySettings, setStoredProfile, storeSec } from "../lib/localStore";
import { getLikes, getFilterlists, getProfileContactList, getProfileMuteList, getUserProfiles, sendFilterlists, getAllowlist, sendAllowList, getRelays, sendRelays, extractRelayConfigFromTags, getBookmarks } from "../lib/profile";
import { clearSec, getStorage, getStoredProfile, readBookmarks, readEmojiHistory, readSecFromStorage, saveBookmarks, saveEmojiHistory, saveFollowing, saveLikes, saveMuted, saveMuteList, saveRelaySettings, setStoredProfile, storeSec } from "../lib/localStore";
import { connectRelays, connectToRelay, getDefaultRelays, getPreConfiguredRelays } from "../lib/relays";
import { getPublicKey } from "../lib/nostrAPI";
import { generateKeys } from "../lib/PrimalNostr";
import EnterPinModal from "../components/EnterPinModal/EnterPinModal";
import CreateAccountModal from "../components/CreateAccountModal/CreateAccountModal";
import LoginModal from "../components/LoginModal/LoginModal";
import { logError, logInfo, logWarning } from "../lib/logger";
import { useToastContext } from "../components/Toaster/Toaster";
import { useIntl } from "@cookbook/solid-intl";
import { account as tAccount, followWarning, forgotPin } from "../translations";
import { account as tAccount, followWarning, forgotPin, settings } from "../translations";
import { getMembershipStatus } from "../lib/membership";
import ConfirmModal from "../components/ConfirmModal/ConfirmModal";
export type AccountContextStore = {
likes: string[],
defaultRelays: string[],
@ -74,6 +74,7 @@ export type AccountContextStore = {
showLogin: boolean,
emojiHistory: EmojiOption[],
membershipStatus: MembershipStatus,
bookmarks: string[],
actions: {
showNewNoteForm: () => void,
hideNewNoteForm: () => void,
@ -101,6 +102,9 @@ export type AccountContextStore = {
showGetStarted: () => void,
saveEmoji: (emoji: EmojiOption) => void,
checkNostrKey: () => void,
fetchBookmarks: () => void,
updateBookmarks: (bookmarks: string[]) => void,
resetRelays: (relays: Relay[]) => void,
},
}
@ -134,6 +138,7 @@ const initialData = {
showLogin: false,
emojiHistory: [],
membershipStatus: {},
bookmarks: [],
};
export const AccountContext = createContext<AccountContextStore>();
@ -153,11 +158,11 @@ export function AccountProvider(props: { children: JSXElement }) {
let membershipSocket: WebSocket | undefined;
onMount(() => {
setInterval(() => {
checkNostrChange();
}, 1_000);
});
// onMount(() => {
// setInterval(() => {
// checkNostrChange();
// }, 1_000);
// });
const checkNostrChange = async () => {
if (location.pathname === '/') return;
@ -285,6 +290,10 @@ export function AccountProvider(props: { children: JSXElement }) {
updateStore('publicKey', () => pubkey);
localStorage.setItem('pubkey', pubkey);
checkMembershipStatus();
const bks = readBookmarks(pubkey);
updateStore('bookmarks', () => [...bks]);
fetchBookmarks();
}
else {
updateStore('publicKey', () => undefined);
@ -298,8 +307,14 @@ export function AccountProvider(props: { children: JSXElement }) {
return !!store.publicKey;
};
const setRelaySettings = (settings: NostrRelays, replace?: boolean) => {
const resetRelays = (relays: Relay[]) => {
const settings = relays.reduce((acc, r) => ({ ...acc, [r.url]: { write: true, read: true }}), {});
setRelaySettings({ ...settings }, true);
connectToRelays({ ...settings }, true);
};
const setRelaySettings = (settings: NostrRelays, replace?: boolean) => {
if (replace) {
for (let url in store.relaySettings) {
if (settings[url]) {
@ -316,7 +331,7 @@ export function AccountProvider(props: { children: JSXElement }) {
updateStore('relaySettings', () => ({...settings}));
saveRelaySettings(store.publicKey, settings);
return;
return true;
}
const rs = store.relaySettings;
@ -330,11 +345,12 @@ export function AccountProvider(props: { children: JSXElement }) {
}, rs);
if (Object.keys(toSave).length === 0) {
return;
return true;
}
updateStore('relaySettings', () => ({ ...toSave }));
saveRelaySettings(store.publicKey, toSave)
saveRelaySettings(store.publicKey, toSave);
return true;
}
const attachDefaultRelays = (relaySettings: NostrRelays) => {
@ -347,7 +363,7 @@ export function AccountProvider(props: { children: JSXElement }) {
updateStore('connectToPrimaryRelays', () => flag);
}
const connectToRelays = (relaySettings: NostrRelays) => {
const connectToRelays = (relaySettings: NostrRelays, sendRelayList?: boolean) => {
if (Object.keys(relaySettings).length === 0) {
getDefaultRelays(`default_relays_${APP_ID}`);
@ -367,6 +383,10 @@ export function AccountProvider(props: { children: JSXElement }) {
}
const onConnect = (connectedRelay: Relay) => {
if (sendRelayList) {
sendRelays([connectedRelay], relaySettings);
}
if (store.relays.find(r => r.url === connectedRelay.url)) {
return;
}
@ -419,6 +439,14 @@ export function AccountProvider(props: { children: JSXElement }) {
if (storedKey) {
setPublicKey(storedKey);
// Read profile from storage
const storedUser = getStoredProfile(storedKey);
if (storedUser) {
// If it exists, set it as active user
updateStore('activeUser', () => ({...storedUser}));
}
}
if (nostr === undefined) {
@ -457,14 +485,14 @@ export function AccountProvider(props: { children: JSXElement }) {
else {
if (key !== storedKey) {
setPublicKey(key);
}
// Read profile from storage
const storedUser = getStoredProfile(key);
// Read profile from storage
const storedUser = getStoredProfile(key);
if (storedUser) {
// If it exists, set it as active user
updateStore('activeUser', () => ({...storedUser}));
if (storedUser) {
// If it exists, set it as active user
updateStore('activeUser', () => ({...storedUser}));
}
}
// Fetch it anyway, maybe there is an update
@ -757,7 +785,11 @@ export function AccountProvider(props: { children: JSXElement }) {
}
const removeFollow = (pubkey: string, cb?: (remove: boolean, pubkey: string) => void) => {
if (!store.publicKey || !store.following.includes(pubkey)) {
if (
!store.publicKey ||
!store.following.includes(pubkey) ||
store.publicKey === pubkey
) {
return;
}
@ -1297,10 +1329,19 @@ export function AccountProvider(props: { children: JSXElement }) {
};
const checkNostrKey = () => {
if (store.publicKey) return;
updateStore('isKeyLookupDone', () => false);
fetchNostrKey();
};
const fetchBookmarks = () => {
getBookmarks(store.publicKey, `user_bookmarks_${APP_ID}`);
}
const updateBookmarks = (bookmarks: string[]) => {
updateStore('bookmarks', () => [...bookmarks]);
};
// EFFECTS --------------------------------------
createEffect(() => {
@ -1479,6 +1520,29 @@ export function AccountProvider(props: { children: JSXElement }) {
}
}
if (subId === `user_bookmarks_${APP_ID}`) {
if (type === 'EVENT' && content && content.kind === Kind.Bookmarks) {
if (!content.created_at || content.created_at < store.followingSince) {
return;
}
const notes = content.tags.reduce((acc, t) => {
if (t[0] === 'e') {
return [...acc, t[1]];
}
return [...acc];
}, []);
updateStore('bookmarks', () => [...notes]);
}
return;
}
if (type === 'EOSE') {
saveBookmarks(store.publicKey, store.bookmarks);
}
};
// STORES ---------------------------------------
@ -1513,6 +1577,9 @@ const [store, updateStore] = createStore<AccountContextStore>({
showGetStarted,
saveEmoji,
checkNostrKey,
fetchBookmarks,
updateBookmarks,
resetRelays,
},
});

View File

@ -8,12 +8,15 @@ import {
useContext
} from "solid-js";
import { PrimalNote, PrimalUser, ZapOption } from "../types/primal";
import { CashuMint } from "@cashu/cashu-ts";
export type ReactionStats = {
likes: number,
zaps: number,
reposts: number,
quotes: number,
openOn: string,
};
export type CustomZapInfo = {
@ -32,6 +35,21 @@ export type NoteContextMenuInfo = {
openReactions?: () => void,
};
export type ConfirmInfo = {
title: string,
description: string,
confirmLabel?: string,
abortLabel?: string,
onConfirm?: () => void,
onAbort?: () => void,
};
export type InvoiceInfo = {
invoice: string,
onPay?: () => void,
onCancel?: () => void,
};
export type AppContextStore = {
isInactive: boolean,
appState: 'sleep' | 'waking' | 'woke',
@ -41,13 +59,28 @@ export type AppContextStore = {
customZap: CustomZapInfo | undefined,
showNoteContextMenu: boolean,
noteContextMenuInfo: NoteContextMenuInfo | undefined,
showLnInvoiceModal: boolean,
lnbc: InvoiceInfo | undefined,
showCashuInvoiceModal: boolean,
cashu: InvoiceInfo | undefined,
showConfirmModal: boolean,
confirmInfo: ConfirmInfo | undefined,
cashuMints: Map<string, CashuMint>,
actions: {
openReactionModal: (noteId: string, stats: ReactionStats) => void,
closeReactionModal: () => void,
openCustomZapModal: (custonZapInfo: CustomZapInfo) => void,
closeCustomZapModal: () => void,
resetCustomZap: () => void,
openContextMenu: (note: PrimalNote, position: DOMRect | undefined, openCustomZapModal: () => void, openReactionModal: () => void) => void,
closeContextMenu: () => void,
openLnbcModal: (lnbc: string, onPay: () => void) => void,
closeLnbcModal: () => void,
openCashuModal: (cashu: string, onPay: () => void) => void,
closeCashuModal: () => void,
openConfirmModal: (confirmInfo: ConfirmInfo) => void,
closeConfirmModal: () => void,
getCashuMint: (url: string) => CashuMint | undefined,
},
}
@ -60,11 +93,19 @@ const initialData: Omit<AppContextStore, 'actions'> = {
zaps: 0,
reposts: 0,
quotes: 0,
openOn: 'likes',
},
showCustomZapModal: false,
customZap: undefined,
showNoteContextMenu: false,
noteContextMenuInfo: undefined,
showLnInvoiceModal: false,
lnbc: undefined,
showCashuInvoiceModal: false,
cashu: undefined,
showConfirmModal: false,
confirmInfo: undefined,
cashuMints: new Map(),
};
export const AppContext = createContext<AppContextStore>();
@ -101,7 +142,7 @@ export const AppProvider = (props: { children: JSXElement }) => {
};
const openCustomZapModal = (customZapInfo: CustomZapInfo) => {
updateStore('customZap', reconcile({ ...customZapInfo }));
updateStore('customZap', () => ({ ...customZapInfo }));
updateStore('showCustomZapModal', () => true);
};
@ -109,6 +150,10 @@ export const AppProvider = (props: { children: JSXElement }) => {
updateStore('showCustomZapModal', () => false);
};
const resetCustomZap = () => {
updateStore('customZap', () => undefined);
};
const openContextMenu = (
note: PrimalNote,
position: DOMRect | undefined,
@ -124,10 +169,58 @@ export const AppProvider = (props: { children: JSXElement }) => {
updateStore('showNoteContextMenu', () => true);
};
const openLnbcModal = (lnbc: string, onPay: () => void) => {
updateStore('showLnInvoiceModal', () => true);
updateStore('lnbc', () => ({
invoice: lnbc,
onPay,
onCancel: () => updateStore('showLnInvoiceModal', () => false),
}))
};
const closeLnbcModal = () => {
updateStore('showLnInvoiceModal', () => false);
updateStore('lnbc', () => undefined);
};
const openCashuModal = (cashu: string, onPay: () => void) => {
updateStore('showCashuInvoiceModal', () => true);
updateStore('cashu', () => ({
invoice: cashu,
onPay,
onCancel: () => updateStore('showCashuInvoiceModal', () => false),
}))
};
const closeCashuModal = () => {
updateStore('showCashuInvoiceModal', () => false);
updateStore('cashu', () => undefined);
};
const openConfirmModal = (confirmInfo: ConfirmInfo) => {
updateStore('showConfirmModal', () => true);
updateStore('confirmInfo', () => ({...confirmInfo }));
};
const closeConfirmModal = () => {
updateStore('showConfirmModal', () => false);
updateStore('confirmInfo', () => undefined);
};
const closeContextMenu = () => {
updateStore('showNoteContextMenu', () => false);
};
const getCashuMint = (url: string) => {
const formatted = new URL(url).toString();
if (!store.cashuMints.has(formatted)) {
const mint = new CashuMint(formatted);
store.cashuMints.set(formatted, mint);
}
return store.cashuMints.get(formatted);
};
// EFFECTS --------------------------------------
onMount(() => {
@ -170,8 +263,16 @@ export const AppProvider = (props: { children: JSXElement }) => {
closeReactionModal,
openCustomZapModal,
closeCustomZapModal,
resetCustomZap,
openContextMenu,
closeContextMenu,
openLnbcModal,
closeLnbcModal,
openConfirmModal,
closeConfirmModal,
openCashuModal,
closeCashuModal,
getCashuMint,
}
});

View File

@ -1,6 +1,7 @@
import { createStore, reconcile, unwrap } from "solid-js/store";
import { Kind, threadLenghtInMs } from "../constants";
import {
batch,
createContext,
createEffect,
onCleanup,
@ -36,14 +37,14 @@ import {
import { APP_ID } from "../App";
import { getMessageCounts, getNewMessages, getOldMessages, markAllAsRead, resetMessageCount, subscribeToMessagesStats, unsubscribeToMessagesStats } from "../lib/messages";
import { useAccountContext } from "./AccountContext";
import { convertToUser } from "../stores/profile";
import { convertToUser, emptyUser } from "../stores/profile";
import { getUserProfiles } from "../lib/profile";
import { getEvents } from "../lib/feed";
import { nip19 } from "nostr-tools";
import { convertToNotes } from "../stores/note";
import { sanitize, sendEvent } from "../lib/notes";
import { decrypt, encrypt } from "../lib/nostrAPI";
import { loadMsgContacts, saveMsgContacts } from "../lib/localStore";
import { loadDmCoversations, loadMsgContacts, saveDmConversations, saveMsgContacts } from "../lib/localStore";
import { useAppContext } from "./AppContext";
@ -155,10 +156,40 @@ export const MessagesProvider = (props: { children: ContextChildren }) => {
updateStore('activePubkey', () => account.publicKey)
// @ts-ignore
const contacts = loadMsgContacts(account?.publicKey);
// const contacts = loadMsgContacts(account?.publicKey);
const contacts = loadDmCoversations(account?.publicKey);
const ids = Object.keys(contacts.profiles);
let senders: Record<string, PrimalUser> = {};
let counts: Record<string, SenderMessageCount> = {};
const tests = {
follows: (id: string) => account?.following.includes(id),
other: (id: string) => !account?.following.includes(id),
any: () => true,
};
for (let i =0; i<ids.length; i++) {
const id = ids[i];
if (tests[store.senderRelation](id)) {
senders[id] = ({ ...contacts.profiles[id] });
counts[id] = ({ ...contacts.counts[id] });
}
}
batch(() => {
// @ts-ignore
// updateStore('senders', () => undefined);
// @ts-ignore
// updateStore('messageCountPerSender', () => undefined);
updateStore('senders', () => ({ ...senders}));
updateStore('messageCountPerSender', () => ({ ...counts }));
})
updateStore('senders', reconcile({ ...contacts.profiles[store.senderRelation]}));
updateStore('messageCountPerSender', () => ({ ...contacts.counts }));
// @ts-ignore
getMessageCounts(account.publicKey, store.senderRelation, subidMsgCountPerSender);
@ -574,7 +605,7 @@ export const MessagesProvider = (props: { children: ContextChildren }) => {
const orderedSenders = () => {
const senders = store.senders;
const senders: Record<string, PrimalUser> = store.senders;
if (!senders) {
return [];
@ -603,6 +634,9 @@ export const MessagesProvider = (props: { children: ContextChildren }) => {
// SOCKET HANDLERS ------------------------------
let emptyUsers: string[] = [];
let fetchedSenders: Record<string, PrimalUser> = {};
const onMessage = (event: MessageEvent) => {
const message: NostrEvent | NostrEOSE = JSON.parse(event.data);
@ -625,14 +659,17 @@ export const MessagesProvider = (props: { children: ContextChildren }) => {
if (content?.kind === Kind.MesagePerSenderStats) {
const senderCount = JSON.parse(content.content);
emptyUsers = Object.keys(senderCount).reduce<string[]>((acc, pk) => {
if (store.senders[pk]) return [ ...acc ];
return [ ...acc, pk];
}, []);
updateStore('messageCountPerSender', () => ({ ...senderCount }));
updateMessageTimings();
}
if (content?.kind === Kind.Metadata) {
if (store.senders[content.pubkey]) {
return;
}
const isFollowing = account?.following.includes(content.pubkey);
@ -642,9 +679,9 @@ export const MessagesProvider = (props: { children: ContextChildren }) => {
return;
}
const user = convertToUser(content);
updateStore('senders', () => ({ [user.pubkey]: { ...user } }));
fetchedSenders[user.pubkey] = { ...user };
// updateStore('senders', user.pubkey, () => ({ ...user }));
}
}
@ -652,7 +689,31 @@ export const MessagesProvider = (props: { children: ContextChildren }) => {
const keys = Object.keys(store.senders);
const cnt = keys.reduce((acc, k) => acc + (store.messageCountPerSender[k]?.cnt || 0) , 0);
saveMsgContacts(store.activePubkey, store.senders, store.messageCountPerSender, store.senderRelation);
let sendersToAdd: Record<string, PrimalUser> = {};
const pks = Object.keys(fetchedSenders)
for (let i=0; i < pks.length; i++) {
const pk = pks[i];
if (store.senders[pk]) continue;
sendersToAdd[pk] = fetchedSenders[pk];
}
for (let i=0; i < emptyUsers.length; i++) {
const pk = emptyUsers[i];
if (store.senders[pk] || sendersToAdd[pk]) continue;
sendersToAdd[pk] = emptyUser(pk);
}
updateStore('senders', () => ({ ...sendersToAdd }));
fetchedSenders = {};
// saveMsgContacts(store.activePubkey, store.senders, store.messageCountPerSender, store.senderRelation);
saveDmConversations(store.activePubkey, store.senders, store.messageCountPerSender);
if (store.messageCount > cnt) {
updateStore('hasMessagesInDifferentTab', () => true);

View File

@ -38,7 +38,17 @@ import {
} from "../types/primal";
import { APP_ID } from "../App";
import { useAccountContext } from "./AccountContext";
import { setLinkPreviews } from "../lib/notes";
import { getEventQuoteStats, getEventZaps, setLinkPreviews } from "../lib/notes";
import { parseBolt11 } from "../utils";
import { getUserProfiles } from "../lib/profile";
export type TopZap = {
id: string,
amount: number,
pubkey: string,
message: string,
eventId: string,
}
export type ThreadContextStore = {
primaryNote: PrimalNote | undefined,
@ -46,9 +56,12 @@ export type ThreadContextStore = {
notes: PrimalNote[],
users: PrimalUser[],
isFetching: boolean,
isFetchingTopZaps: boolean,
page: FeedPage,
reposts: Record<string, string> | undefined,
lastNote: PrimalNote | undefined,
topZaps: Record<string, TopZap[]>,
quoteCount: number,
actions: {
saveNotes: (newNotes: PrimalNote[]) => void,
clearNotes: () => void,
@ -58,6 +71,8 @@ export type ThreadContextStore = {
updatePage: (content: NostrEventContent) => void,
savePage: (page: FeedPage) => void,
setPrimaryNote: (context: PrimalNote | undefined) => void,
fetchTopZaps: (noteId: string) => void,
fetchUsers: (pubkeys: string[]) => void,
}
}
@ -69,6 +84,7 @@ export const initialData = {
users: [],
replyNotes: [],
isFetching: false,
isFetchingTopZaps: false,
page: {
messages: [],
users: {},
@ -78,6 +94,8 @@ export const initialData = {
},
reposts: {},
lastNote: undefined,
topZaps: {},
quoteCount: 0,
};
@ -101,6 +119,8 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
clearNotes();
updateStore('noteId', noteId)
getThread(account?.publicKey, noteId, `thread_${APP_ID}`);
fetchTopZaps(noteId);
fetchNoteQuoteStats(noteId);
updateStore('isFetching', () => true);
}
@ -110,7 +130,7 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
}
const clearNotes = () => {
updateStore('page', () => ({ messages: [], users: {}, postStats: {}, noteActions: {} }));
updateStore('page', () => ({ messages: [], users: {}, postStats: {}, noteActions: {}, mentions: {} }));
updateStore('notes', () => []);
updateStore('reposts', () => undefined);
updateStore('lastNote', () => undefined);
@ -206,6 +226,67 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
updateStore('page', 'relayHints', (rh) => ({ ...rh, ...hints }));
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
const oldZaps = store.topZaps[eventId];
if (oldZaps === undefined) {
updateStore('topZaps', () => ({ [eventId]: [{ ...zap }]}));
return;
}
if (oldZaps.find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
updateStore('topZaps', eventId, () => [ ...newZaps ]);
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
updateStore('quoteCount', () => quoteStats.count || 0);
}
};
const savePage = (page: FeedPage) => {
@ -220,6 +301,19 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
updateStore('primaryNote', () => ({ ...context }));
};
const fetchTopZaps = (noteId: string) => {
updateStore('isFetchingTopZaps', () => true);
getEventZaps(noteId, account?.publicKey, `thread_zapps_${APP_ID}`, 10, 0);
};
const fetchUsers = (pubkeys: string[]) => {
getUserProfiles(pubkeys, `thread_pk_${APP_ID}`);
};
const fetchNoteQuoteStats = (noteId: string) => {
getEventQuoteStats(noteId, `thread_quote_stats_${APP_ID}`)
}
// SOCKET HANDLERS ------------------------------
const onMessage = (event: MessageEvent) => {
@ -268,6 +362,40 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
return;
}
}
if (subId === `thread_zapps_${APP_ID}`) {
if (type === 'EOSE') {
savePage(store.page);
updateStore('isFetchingTopZaps', () => false);
}
if (type === 'EVENT') {
updatePage(content);
return;
}
}
if (subId === `thread_pk_${APP_ID}`) {
if (type === 'EOSE') {
savePage(store.page);
}
if (type === 'EVENT') {
updatePage(content);
return;
}
}
if (subId === `thread_quote_stats_${APP_ID}`) {
if (type === 'EOSE') {
savePage(store.page);
}
if (type === 'EVENT') {
updatePage(content);
return;
}
}
};
const onSocketClose = (closeEvent: CloseEvent) => {
@ -336,6 +464,8 @@ export const ThreadProvider = (props: { children: ContextChildren }) => {
updatePage,
savePage,
setPrimaryNote,
fetchTopZaps,
fetchUsers,
},
});

View File

@ -60,8 +60,8 @@
--warning-color: #FA3C3C;
--success-color: #66E205;
--left-col-w: 187px;
--center-col-w: 602px;
--left-col-w: 188px;
--center-col-w: 600px;
--right-col-w: 348px;
--full-site-w: 1137px;
--header-height: 84px;

View File

@ -18,6 +18,16 @@ export const longDate = (timestamp: number | undefined) => {
return dtf.format(date);
};
export const veryLongDate = (timestamp: number | undefined) => {
if (!timestamp || timestamp < 0) {
return '';
}
const date = new Date(timestamp * 1000);
const dtf = new Intl.DateTimeFormat('en-US', { dateStyle: 'full', timeStyle: 'short'});
return dtf.format(date);
};
export const date = (postTimestamp: number, style: Intl.RelativeTimeFormatStyle = 'short', since?: number) => {
const today = since ?? Math.floor((new Date()).getTime() / 1000);
const date = new Date(postTimestamp * 1000);
@ -65,3 +75,51 @@ export const date = (postTimestamp: number, style: Intl.RelativeTimeFormatStyle
return { date, label: `${diff}s` };
};
export const dateFuture = (postTimestamp: number, style: Intl.RelativeTimeFormatStyle = 'short', since?: number) => {
const today = since ?? Math.floor((new Date()).getTime() / 1000);
const date = new Date(postTimestamp * 1000);
const minute = 60;
const hour = minute * 60;
const day = hour * 24;
const week = day * 7;
const month = day * 30;
const year = month * 12;
const rtf = new Intl.RelativeTimeFormat('en', { style });
const diff = postTimestamp - today;
if ( diff > year) {
const years = Math.floor(diff / year);
return { date, label: rtf.format(-years, 'years').replace(' ago', '') };
}
if (diff > month) {
const months = Math.floor(diff / month);
return { date, label: rtf.format(-months, 'months').replace(' ago', '') };
}
if (diff > week) {
const weeks = Math.floor(diff / week);
return { date, label: rtf.format(-weeks, 'weeks').replace(' ago', '') };
}
if (diff > day) {
const days = Math.floor(diff / day);
return { date, label: rtf.format(-days, 'days').replace(' ago', '') };
}
if (diff > hour) {
const hours = Math.floor(diff / hour);
return { date, label: rtf.format(-hours, 'hours').replace(' ago', '') };
}
if (diff > minute) {
const minutes = Math.floor(diff / minute);
return { date, label: rtf.format(-minutes, 'minutes').replace(' ago', '') };
}
return { date, label: `${diff}s` };
};

View File

@ -67,19 +67,28 @@ export const getEvents = (user_pubkey: string | undefined, eventIds: string[], s
};
export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies', until = 0, limit = 20) => {
export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies' | 'bookmarks', until = 0, limit = 20, offset = 0) => {
if (!pubkey) {
return;
}
const start = until === 0 ? 'since' : 'until';
let payload = { pubkey, limit, notes, [start]: until } ;
let payload: {
pubkey: string,
limit: number,
notes: 'authored' | 'replies' | 'bookmarks',
user_pubkey?: string,
until?: number,
offset?: number,
} = { pubkey, limit, notes } ;
if (user_pubkey) {
payload.user_pubkey = user_pubkey;
}
if (until > 0) payload.until = until;
if (offset > 0) payload.offset = offset;
sendMessage(JSON.stringify([
"REQ",
subid,

View File

@ -13,6 +13,7 @@ export type LocalStore = {
theme: string,
homeSidebarSelection: SelectionOption | undefined,
userProfile: PrimalUser | undefined,
bookmarks: string[],
recomended: {
profiles: PrimalUser[],
stats: Record<string, UserStats>,
@ -21,6 +22,10 @@ export type LocalStore = {
profiles: Record<UserRelation, Record<string, PrimalUser>>,
counts: Record<string, SenderMessageCount>,
},
dmConversations: {
profiles: Record<string, PrimalUser>,
counts: Record<string, SenderMessageCount>,
},
emojiHistory: EmojiOption[],
noteDraft: Record<string, string>,
noteDraftUserRefs: Record<string, Record<string, PrimalUser>>,
@ -54,7 +59,8 @@ export const emptyStorage: LocalStore = {
likes: [],
feeds: [],
msgContacts: { profiles: { other: {}, follows: {}, any: {} }, counts: {} },
theme: 'sunset',
dmConversations: { profiles: {}, counts: {} },
theme: 'sunrise',
homeSidebarSelection: undefined,
userProfile: undefined,
recomended: { profiles: [], stats: {} },
@ -63,6 +69,7 @@ export const emptyStorage: LocalStore = {
noteDraftUserRefs: {},
uploadTime: defaultUploadTime,
selectedFeed: undefined,
bookmarks: [],
}
export const storageName = (pubkey?: string) => {
@ -398,7 +405,6 @@ export const saveMsgContacts = (pubkey: string | undefined, contacts: Record<str
setStorage(pubkey, store);
}
export const loadMsgContacts = (pubkey: string) => {
const store = getStorage(pubkey)
@ -406,6 +412,30 @@ export const loadMsgContacts = (pubkey: string) => {
};
export const saveDmConversations = (pubkey: string | undefined, contacts: Record<string, PrimalUser>, counts: Record<string, SenderMessageCount>) => {
if (!pubkey) {
return;
}
const store = getStorage(pubkey);
if (!store.dmConversations) {
store.dmConversations = { profiles: {}, counts: {} };
}
store.dmConversations.profiles = { ...store.dmConversations.profiles, ...contacts };
store.dmConversations.counts = { ...store.dmConversations.counts, ...counts };
setStorage(pubkey, store);
}
export const loadDmCoversations = (pubkey: string) => {
const store = getStorage(pubkey)
return store.dmConversations || { profiles: {}, counts: {} };
};
export const fetchStoredFeed = (pubkey: string | undefined) => {
if (!pubkey) return undefined;
@ -423,3 +453,21 @@ export const saveStoredFeed = (pubkey: string | undefined, feed: PrimalFeed) =>
setStorage(pubkey, store);
};
export const saveBookmarks = (pubkey: string | undefined, bookmarks: string[]) => {
if (!pubkey) return;
const store = getStorage(pubkey);
store.bookmarks = [ ...bookmarks ];
setStorage(pubkey, store);
};
export const readBookmarks = (pubkey: string | undefined) => {
if (!pubkey) return [];
const store = getStorage(pubkey)
return store.bookmarks || [];
};

View File

@ -1,10 +1,12 @@
import { A } from "@solidjs/router";
// @ts-ignore Bad types in nostr-tools
import { Relay } from "nostr-tools";
import { Relay, relayInit } from "nostr-tools";
import { createStore } from "solid-js/store";
import LinkPreview from "../components/LinkPreview/LinkPreview";
import { appleMusicRegex, emojiRegex, hashtagRegex, interpunctionRegex, Kind, linebreakRegex, mixCloudRegex, nostrNestsRegex, noteRegex, noteRegexLocal, profileRegex, soundCloudRegex, spotifyRegex, tagMentionRegex, twitchRegex, urlRegex, urlRegexG, wavlakeRegex, youtubeRegex } from "../constants";
import { addrRegex, appleMusicRegex, emojiRegex, hashtagRegex, interpunctionRegex, Kind, linebreakRegex, lnRegex, lnUnifiedRegex, mixCloudRegex, nostrNestsRegex, noteRegex, noteRegexLocal, profileRegex, profileRegexG, soundCloudRegex, spotifyRegex, tagMentionRegex, twitchRegex, urlRegex, urlRegexG, wavlakeRegex, youtubeRegex } from "../constants";
import { sendMessage, subscribeTo } from "../sockets";
import { MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalNote, SendNoteResult } from "../types/primal";
import { npubToHex } from "./keys";
import { logError, logInfo, logWarning } from "./logger";
import { getMediaUrl as getMediaUrlDefault } from "./media";
import { signEvent } from "./nostrAPI";
@ -57,8 +59,11 @@ export const isLinebreak = (url: string) => linebreakRegex.test(url);
export const isTagMention = (url: string) => tagMentionRegex.test(url);
export const isNoteMention = (url: string) => noteRegexLocal.test(url);
export const isUserMention = (url: string) => profileRegex.test(url);
export const isAddrMention = (url: string) => addrRegex.test(url);
export const isInterpunction = (url: string) => interpunctionRegex.test(url);
export const isCustomEmoji = (url: string) => emojiRegex.test(url);
export const isLnbc = (url: string) => lnRegex.test(url);
export const isUnitifedLnAddress = (url: string) => lnUnifiedRegex.test(url);
export const isImage = (url: string) => ['.jpg', '.jpeg', '.webp', '.png', '.gif', '.format=png'].some(x => url.includes(x));
export const isMp4Video = (url: string) => ['.mp4', '.mov'].some(x => url.includes(x));
@ -75,6 +80,33 @@ export const isNostrNests = (url: string) => nostrNestsRegex.test(url);
export const isWavelake = (url: string) => wavlakeRegex.test(url);
export const linkifyNostrProfileLink = (text: string) => {
return text.replace(profileRegexG, (url) => {
if (isUserMention(url)) {
const npub = url.split('nostr:')[1];
// @ts-ignore
return (<span><A href={`/p/${npub}`}>{npub}</A></span>)?.innerHTML || url;
}
return url;
});
}
export const linkifyNostrNoteLink = (text: string) => {
return text.replace(noteRegex, (url) => {
if (isNoteMention(url)) {
const noteId = url.split('nostr:')[1];
// @ts-ignore
return (<span><A href={`/e/${noteId}`}>{noteId}</A></span>)?.innerHTML || url;
}
return url;
});
}
export const urlify = (
text: string,
getMediaUrl: ((url: string | undefined, size?: MediaSize, animated?: boolean) => string | undefined) | undefined,
@ -411,7 +443,22 @@ export const sendEvent = async (event: NostrEvent, relays: Relay[], relaySetting
let responses = [];
let reasons: string[] = [];
// Relay hints fromm `e` tags
const hintRelayUrls = event.tags.reduce((acc, t) => {
if (
t[0] === 'e' &&
t[2] &&
t[2].length > 0 &&
!relays.find(r => r.url === t[2])
) {
return [ ...acc, t[2] ];
}
return [...acc];
}, []);
for (let i = 0;i < relays.length;i++) {
const relay = relays[i];
const settings = (relaySettings && relaySettings[relay.url]) || { read: true, write: true };
@ -445,6 +492,30 @@ export const sendEvent = async (event: NostrEvent, relays: Relay[], relaySetting
}));
}
for (let i = 0;i < hintRelayUrls.length;i++) {
const url = hintRelayUrls[i];
new Promise<string>(async (resolve, reject) => {
const relay = relayInit(url);
await relay.connect();
try {
logInfo('publishing to relay: ', relay)
await relay.publish(signedNote);
logInfo(`${relay.url} has accepted our event`);
resolve('success');
} catch (e) {
logError(`Failed publishing note to ${relay.url}: `, e);
reject('success');
}
relay.close();
});
}
try {
await Promise.any(responses);
@ -471,9 +542,53 @@ export const triggerImportEvents = (events: NostrRelaySignedEvent[], subId: stri
export const getEventReactions = (eventId: string, kind: number, subid: string, offset = 0) => {
const event_id = eventId.startsWith('note1') ? npubToHex(eventId) : eventId;
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["event_actions", { event_id: eventId, kind, limit: 20, offset }]},
{cache: ["event_actions", { event_id, kind, limit: 20, offset }]},
]));
};
export const getEventQuotes = (eventId: string, subid: string, offset = 0) => {
const event_id = eventId.startsWith('note1') ? npubToHex(eventId) : eventId;
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["note_mentions", { event_id, limit: 20, offset }]},
]));
};
export const getEventZaps = (eventId: string, user_pubkey: string | undefined, subid: string, limit: number, offset = 0) => {
const event_id = eventId.startsWith('note1') ? npubToHex(eventId) : eventId;
let payload = {
event_id,
limit,
offset
};
if (user_pubkey) {
// @ts-ignore
payload.user_pubkey = user_pubkey;
}
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["event_zaps_by_satszapped", { ...payload }]},
]));
};
export const getEventQuoteStats = (eventId: string, subid: string) => {
const event_id = eventId.startsWith('note1') ? npubToHex(eventId) : eventId;
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["note_mentions_count", { event_id }]},
]));
};

View File

@ -376,6 +376,27 @@ export const sendRelays = async (relays: Relay[], relaySettings: NostrRelays) =>
return await sendEvent(event, relays, relaySettings);
};
export const sendBookmarks = async (tags: string[][], date: number, content: string, relays: Relay[], relaySettings?: NostrRelays) => {
const event = {
content,
kind: Kind.Bookmarks,
tags: [...tags],
created_at: date,
};
return await sendEvent(event, relays, relaySettings);
};
export const getBookmarks = async (pubkey: string | undefined, subid: string) => {
if (!pubkey) return;
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["get_bookmarks", { pubkey }]},
]));
};
export const extractRelayConfigFromTags = (tags: string[][]) => {
return tags.reduce((acc, tag) => {
if (tag[0] !== 'r') return acc;

23
src/lib/sockets.ts Normal file
View File

@ -0,0 +1,23 @@
import { NostrEventType, NostrEventContent, NostrEvent, NostrEOSE } from "../types/primal";
export const subTo = (socket: WebSocket, subId: string, cb: (type: NostrEventType, subId: string, content?: NostrEventContent) => void ) => {
const listener = (event: MessageEvent) => {
const message: NostrEvent | NostrEOSE = JSON.parse(event.data);
const [type, subscriptionId, content] = message;
if (subId === subscriptionId) {
cb(type, subscriptionId, content);
}
};
socket.addEventListener('message', listener);
return () => {
socket.removeEventListener('message', listener);
};
};
export const sendMessage = (socket: WebSocket, message: string) => {
socket.readyState === WebSocket.OPEN && socket.send(message);
}

View File

@ -18,13 +18,19 @@ export const zapNote = async (note: PrimalNote, sender: string | undefined, amou
const sats = Math.round(amount * 1000);
const zapReq = nip57.makeZapRequest({
let payload = {
profile: note.post.pubkey,
event: note.msg.id,
amount: sats,
comment,
relays: relays.map(r => r.url)
});
};
if (comment.length > 0) {
// @ts-ignore
payload.comment = comment;
}
const zapReq = nip57.makeZapRequest(payload);
try {
const signedEvent = await signEvent(zapReq);
@ -57,12 +63,17 @@ export const zapProfile = async (profile: PrimalUser, sender: string | undefined
const sats = Math.round(amount * 1000);
const zapReq = nip57.makeZapRequest({
let payload = {
profile: profile.pubkey,
amount: sats,
comment,
relays: relays.map(r => r.url)
});
};
if (comment.length > 0) {
// @ts-ignore
payload.comment = comment;
}
const zapReq = nip57.makeZapRequest(payload);
try {
const signedEvent = await signEvent(zapReq);

View File

@ -0,0 +1,26 @@
.bookmarkFeed {
border-top: 1px solid var(--devider);
padding-top: 20px;
margin-bottom: 48px;
}
.loader {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 64px;
margin-top: 20px;
}
.noBookmarks {
font-weight: 400;
font-size: 20px;
line-height: 20px;
color: var(--text-secondary);
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}

278
src/pages/Bookmarks.tsx Normal file
View File

@ -0,0 +1,278 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, For, on, onCleanup, onMount, Show, untrack } from 'solid-js';
import { createStore } from 'solid-js/store';
import { APP_ID } from '../App';
import Loader from '../components/Loader/Loader';
import Note from '../components/Note/Note';
import PageCaption from '../components/PageCaption/PageCaption';
import PageTitle from '../components/PageTitle/PageTitle';
import Paginator from '../components/Paginator/Paginator';
import { Kind } from '../constants';
import { useAccountContext } from '../contexts/AccountContext';
import { getEvents, getUserFeed } from '../lib/feed';
import { setLinkPreviews } from '../lib/notes';
import { subscribeTo } from '../sockets';
import { convertToNotes, parseEmptyReposts } from '../stores/note';
import { bookmarks as tBookmarks } from '../translations';
import { NostrEventContent, NostrUserContent, NostrNoteContent, NostrStatsContent, NostrMentionContent, NostrNoteActionsContent, NoteActions, FeedPage, PrimalNote, NostrFeedRange, PageRange } from '../types/primal';
import styles from './Bookmarks.module.scss';
export type BookmarkStore = {
fetchingInProgress: boolean,
page: FeedPage,
notes: PrimalNote[],
noteIds: string[],
offset: number,
pageRange: PageRange,
reposts: Record<string, string> | undefined,
firstLoad: boolean,
}
const emptyStore: BookmarkStore = {
fetchingInProgress: false,
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
},
notes: [],
noteIds: [],
pageRange: {
since: 0,
until: 0,
order_by: 'created_at',
},
reposts: {},
offset: 0,
firstLoad: true,
};
let since: number = 0;
const Bookmarks: Component = () => {
const account = useAccountContext();
const intl = useIntl();
const pageSize = 20;
const [store, updateStore] = createStore<BookmarkStore>({ ...emptyStore });
createEffect(on(() => account?.isKeyLookupDone, (v) => {
if (v && account?.publicKey) {
updateStore(() => ({ ...emptyStore }));
fetchBookmarks(account.publicKey);
}
}));
onCleanup(() => {
updateStore(() => ({ ...emptyStore }));
});
const fetchBookmarks = (pubkey: string | undefined, until = 0) => {
if (store.fetchingInProgress || !pubkey) return;
const subId = `bookmark_feed_${until}_${APP_ID}`;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
const reposts = parseEmptyReposts(store.page);
const ids = Object.keys(reposts);
if (ids.length === 0) {
savePage(store.page);
unsub();
return;
}
updateStore('reposts', () => reposts);
fetchReposts(ids);
unsub();
return;
}
if (type === 'EVENT') {
content && updatePage(content);
}
});
updateStore('fetchingInProgress', () => true);
getUserFeed(pubkey, pubkey, subId, 'bookmarks', until, pageSize, store.offset);
}
const fetchNextPage = () => since > 0 && fetchBookmarks(account?.publicKey, since);
const fetchReposts = (ids: string[]) => {
const subId = `bookmark_reposts_${APP_ID}`;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
savePage(store.page);
unsub();
return;
}
if (type === 'EVENT') {
const repostId = (content as NostrNoteContent).id;
const reposts = store.reposts || {};
const parent = store.page.messages.find(m => m.id === reposts[repostId]);
if (parent) {
updateStore('page', 'messages', (msg) => msg.id === parent.id, 'content', () => JSON.stringify(content));
}
return;
}
});
getEvents(account?.publicKey, ids, subId);
};
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
updateStore('page', 'users',
(usrs) => ({ ...usrs, [user.pubkey]: { ...user } })
);
return;
}
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
updateStore('page', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
updateStore('page', 'postStats',
(stats) => ({ ...stats, [stat.event_id]: { ...stat } })
);
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
updateStore('page', 'mentions',
(mentions) => ({ ...mentions, [mention.id]: { ...mention } })
);
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
updateStore('page', 'noteActions',
(actions) => ({ ...actions, [noteActions.event_id]: { ...noteActions } })
);
return;
}
if (content.kind === Kind.FeedRange) {
const noteActionContent = content as NostrFeedRange;
const range = JSON.parse(noteActionContent.content) as PageRange;
updateStore('pageRange', () => ({ ...range }));
since = range.until;
return;
}
if (content.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
};
const savePage = (page: FeedPage) => {
const newPosts = convertToNotes(page);
saveNotes(newPosts);
};
const saveNotes = (newNotes: PrimalNote[]) => {
const notesToAdd = newNotes.filter(n => !store.noteIds.includes(n.post.id));
const lastTimestamp = store.pageRange.since;
const offset = notesToAdd.reduce<number>((acc, n) => n.post.created_at === lastTimestamp ? acc+1 : acc, 0);
const ids = notesToAdd.map(m => m.post.id)
ids.length > 0 && updateStore('noteIds', () => [...ids]);
updateStore('offset', () => offset);
updateStore('notes', (notes) => [ ...notes, ...notesToAdd ]);
updateStore('page', () => ({
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
}));
updateStore('fetchingInProgress', () => false);
};
return (
<>
<PageTitle title={intl.formatMessage(tBookmarks.pageTitle)} />
<PageCaption title={intl.formatMessage(tBookmarks.pageTitle)} />
<div class={styles.bookmarkFeed}>
<Show when={!store.fetchingInProgress && store.notes.length === 0}>
<div class={styles.noBookmarks}>
{intl.formatMessage(tBookmarks.noBookmarks)}
</div>
</Show>
<For each={store.notes}>
{(note) =>
<Note note={note} />
}
</For>
<Paginator loadNextPage={fetchNextPage} />
<Show
when={store.fetchingInProgress}
>
<div class={styles.loader}>
{<Loader/>}
</div>
</Show>
</div>
</>
);
}
export default Bookmarks;

View File

@ -210,14 +210,19 @@ const CreateAccount: Component = () => { const intl = useIntl();
toast?.sendSuccess(intl.formatMessage(tToast.updateProfileSuccess));
pubkey && getUserProfiles([pubkey], `user_profile_${APP_ID}`);
const tags = followed.map(pk => ['p', pk]);
let tags = followed.map(pk => ['p', pk]);
const date = Math.floor((new Date()).getTime() / 1000);
if (pubkey) {
// Follow himself
tags.push(['p', pubkey]);
}
const sendResult = await sendContacts(tags, date, '', account.relays, relaySettings);
if (sendResult.success && sendResult.note) {
triggerImportEvents([sendResult.note], `import_contacts_${APP_ID}`, () => {
getProfileContactList(account?.publicKey, `user_contacts_${APP_ID}`);
getProfileContactList(pubkey, `user_contacts_${APP_ID}`);
});
}
@ -225,7 +230,7 @@ const CreateAccount: Component = () => { const intl = useIntl();
if (relayResult.success && relayResult.note) {
triggerImportEvents([relayResult.note], `import_relays_${APP_ID}`, () => {
getRelays(account?.publicKey, `user_relays_${APP_ID}`);
getRelays(pubkey, `user_relays_${APP_ID}`);
});
}

View File

@ -26,7 +26,7 @@
align-items: flex-start;
max-width: calc(100% - 48px);
.message {
.message, .messageLn {
@include messageContent();
@if $align-end {
@ -46,12 +46,18 @@
background-color: var(--subtile-devider);
}
}
.messageLn {
display: flex;
width: calc(500px + 12px - 40px) !important;
}
.threadTime {
color: var(--text-tertiary-2);
font-weight: 400;
font-size: 12px;
line-height: 16px;
}
}
.messagesContent {
@ -258,7 +264,7 @@
.myThread {
@include thread(true);
.threadMessages {
.message {
.message, .messageLn {
color: var(--text-primary-button);
background-color: var(--accent);
border-radius: 12px 0px 0px 12px;
@ -270,8 +276,13 @@
text-decoration: underline !important;
}
}
.messageLn {
padding: 0 12px !important;
}
}
+ .myThread {
padding: 0 12px !important;
margin-bottom: 20px;
}
}
@ -279,13 +290,17 @@
.theirThread {
@include thread(false);
.threadMessages {
.message {
.message, .messageLn {
background-color: var(--background-input);
border-radius: 0px 12px 12px 0px;
&:last-child {
border-radius: 12px 12px 12px 0px;
}
}
.messageLn {
padding: 0 !important;
}
}
+ .theirThread {
margin-bottom: 20px;

View File

@ -1,11 +1,11 @@
import { useIntl } from '@cookbook/solid-intl';
import { nip19 } from 'nostr-tools';
import { Component, createEffect, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
import { Component, createEffect, createSignal, For, JSXElement, Match, onCleanup, onMount, Show, Switch } from 'solid-js';
import Avatar from '../components/Avatar/Avatar';
import { useAccountContext } from '../contexts/AccountContext';
import { useMessagesContext } from '../contexts/MessagesContext';
import { nip05Verification, truncateNpub, userName } from '../stores/profile';
import { PrimalNote, PrimalUser } from '../types/primal';
import { DirectMessage, DirectMessageThread, PrimalNote, PrimalUser } from '../types/primal';
import { date } from '../lib/dates';
import styles from './Messages.module.scss';
@ -20,7 +20,7 @@ import SearchOption from '../components/Search/SearchOption';
import { debounce, isVisibleInContainer, uuidv4 } from '../utils';
import { useSearchContext } from '../contexts/SearchContext';
import { createStore } from 'solid-js/store';
import { editMentionRegex, emojiSearchLimit } from '../constants';
import { editMentionRegex, emojiSearchLimit, linebreakRegex } from '../constants';
import Search from '../components/Search/Search';
import { useProfileContext } from '../contexts/ProfileContext';
import Paginator from '../components/Paginator/Paginator';
@ -35,6 +35,8 @@ import {
import PageCaption from '../components/PageCaption/PageCaption';
import { useMediaContext } from '../contexts/MediaContext';
import PageTitle from '../components/PageTitle/PageTitle';
import Lnbc from '../components/Lnbc/Lnbc';
import Cashu from '../components/Cashu/Cashu';
type AutoSizedTextArea = HTMLTextAreaElement & { _baseScrollHeight: number };
@ -294,6 +296,7 @@ const Messages: Component = () => {
if (!messages) {
return message;
}
return parseNoteLinks(
parseNpubLinks(
highlightHashtags(
@ -831,6 +834,20 @@ const Messages: Component = () => {
newMessageInput.dispatchEvent(e);
};
const msgHasInvoice = (msg: DirectMessage) => {
const r =/(\s+|\r\n|\r|\n|^)lnbc[a-zA-Z0-9]+/;
const test = r.test(msg.content);
return test
};
const msgHasCashu = (msg: DirectMessage) => {
const r =/(\s+|\r\n|\r|\n|^)cashuA[a-zA-Z0-9]+/;
const test = r.test(msg.content);
return test
};
createEffect(() => {
if (account?.hasPublicKey()) {
profile?.actions.setProfileKey(account.publicKey)
@ -884,6 +901,84 @@ const Messages: Component = () => {
newMessageInput && setMessage(newMessageInput.value)
}
const renderMessage = (msg: DirectMessage, thread: DirectMessageThread) => {
if (!msgHasInvoice(msg) && !msgHasCashu(msg)) {
return (
<div
class={styles.message}
data-event-id={msg.id}
title={date(msg.created_at || 0).date.toLocaleString()}
innerHTML={parseMessage(msg.content)}
></div>
);
};
let sections: string[] = [];
let content = msg.content.replace(linebreakRegex, ' __LB__ ').replace(/\s+/g, ' __SP__ ');
let tokens: string[] = content.split(/[\s]+/);
let sectionIndex = 0;
tokens.forEach((t) => {
if (t.startsWith('lnbc') || t.startsWith('cashuA')) {
if (sections[sectionIndex]) sectionIndex++;
sections[sectionIndex] = t;
sectionIndex++;
}
else {
let c = t;
const prev = sections[sectionIndex] || '';
if (t === '__SP__') {
c = prev.length === 0 ? '' : ' ';
}
if (t === '__LB__') {
c = prev.length === 0 ? '' : '\r';
}
sections[sectionIndex] = prev + c;
}
});
return (
<For each={sections.reverse()}>
{section => (
<Switch fallback={
<div
class={styles.message}
data-event-id={msg.id}
title={date(msg.created_at || 0).date.toLocaleString()}
innerHTML={parseMessage(section)}
></div>
}>
<Match when={section.startsWith('lnbc')}>
<div
class={styles.messageLn}
data-event-id={msg.id}
title={date(msg.created_at || 0).date.toLocaleString()}
>
<Lnbc lnbc={section} noBack={true} alternative={!isSelectedSender(thread.author)} />
</div>
</Match>
<Match when={section.startsWith('cashuA')}>
<div
class={styles.messageLn}
data-event-id={msg.id}
title={date(msg.created_at || 0).date.toLocaleString()}
>
<Cashu token={section} noBack={true} alternative={!isSelectedSender(thread.author)} />
</div>
</Match>
</Switch>
)}
</For>
);
};
return (
<div>
<PageTitle title={intl.formatMessage(tMessages.title)} />
@ -1064,14 +1159,7 @@ const Messages: Component = () => {
</A>
<div class={styles.threadMessages}>
<For each={thread.messages}>
{(msg) => (
<div
class={styles.message}
data-event-id={msg.id}
title={date(msg.created_at || 0).date.toLocaleString()}
innerHTML={parseMessage(msg.content)}
></div>
)}
{msg => renderMessage(msg, thread)}
</For>
</div>
<Show when={thread.messages[0]}>
@ -1091,14 +1179,7 @@ const Messages: Component = () => {
</A>
<div class={styles.threadMessages}>
<For each={thread.messages}>
{(msg) => (
<div
class={styles.message}
data-event-id={msg.id}
title={date(msg.created_at || 0).date.toLocaleString()}
innerHTML={parseMessage(msg.content)}
></div>
)}
{msg => renderMessage(msg, thread)}
</For>
</div>
<Show when={thread.messages[0]}>

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