Compare commits

...

50 Commits

Author SHA1 Message Date
Dominik fb2e26cb23
Merge 6c73249ba7 into a69f7d5950 2024-06-13 00:05:32 +00:00
Bojan Mojsilovic a69f7d5950 Proper subs from profile and article 2024-06-10 15:44:26 +02:00
Bojan Mojsilovic 4440e0d499 Fix some styling 2024-06-10 15:06:43 +02:00
Bojan Mojsilovic 23d8bb980b Show all reads for guests 2024-06-10 14:51:03 +02:00
Bojan Mojsilovic f309b2bfc0 Handle USD subscriptions 2024-06-10 13:23:41 +02:00
Bojan Mojsilovic 40bc2ec8a7 fix 2024-06-07 17:52:59 +02:00
Bojan Mojsilovic 4524f7d4fc Update featured author layout 2024-06-07 16:33:06 +02:00
Bojan Mojsilovic de05c5d0a1 Filter-out non-sats prices 2024-06-07 16:24:16 +02:00
Bojan Mojsilovic bd09ed7668 More subs stuff 2024-06-06 17:15:00 +02:00
Bojan Mojsilovic fcb3926e67 Basic subscribe flow 2024-06-06 13:05:49 +02:00
Bojan Mojsilovic 388c2e689d Link article author to profile 2024-06-05 13:50:02 +02:00
Bojan Mojsilovic 829675481d Profile reads 2024-06-05 13:12:28 +02:00
Bojan Mojsilovic ebdda42433 Reply to article 2024-06-05 12:16:38 +02:00
Bojan Mojsilovic 5b5afea786 Fix displaying missing images 2024-06-04 17:08:17 +02:00
Bojan Mojsilovic e445b11019 Fix reads header 2024-06-04 16:48:48 +02:00
Bojan Mojsilovic cfa16f5964 Article sidebar 2024-06-04 16:00:09 +02:00
Bojan Mojsilovic 1ce4ecd7da fix bookmarks 2024-06-04 15:54:10 +02:00
Bojan Mojsilovic b7b99350a8 fix paging 2024-06-03 17:43:42 +02:00
Bojan Mojsilovic 635b504d5e Basic reeds select 2024-06-03 16:25:47 +02:00
Bojan Mojsilovic f22a02318c Reads preview changes 2024-06-03 15:39:40 +02:00
Bojan Mojsilovic 0081870198 Redo long-form note view 2024-06-03 15:29:57 +02:00
Bojan Mojsilovic 622dad8ecc Add read image zoom 2024-05-31 18:00:33 +02:00
Bojan Mojsilovic 26d8885b9c Style image previews 2024-05-31 17:46:49 +02:00
Bojan Mojsilovic d32d2af392 Table styles 2024-05-31 16:29:52 +02:00
Bojan Mojsilovic 70a935bc6f Remove Lora font 2024-05-31 16:11:23 +02:00
Bojan Mojsilovic 254499a4e4 Add reading estimate 2024-05-31 16:09:37 +02:00
Bojan Mojsilovic b4b51a242d Fix top reads 2024-05-31 14:59:19 +02:00
Bojan Mojsilovic b1ad4299eb Add reads sidebar 2024-05-31 12:42:56 +02:00
Bojan Mojsilovic 219f3ab084 Handle image zoom in reads 2024-05-31 12:42:13 +02:00
Bojan Mojsilovic b606e90532 Fix footer and context menu in article preview 2024-05-30 14:34:03 +02:00
Bojan Mojsilovic e28299e9f0 Improved Markdown styling 2024-05-29 19:38:18 +02:00
Bojan Mojsilovic a7eec46a27 Fix lottie animations 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 2fc28409cd Fix notification note size 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic f7e59e4f9b Fix new layout 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic d152ac47d7 New home header 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 77494df791 New feed note layout 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic f9b790ac58 Widen content 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic e1d565e939 New nav menu styling 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 2993a97e7f Fix test page 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 3914a3c0c9 Enable reactions 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic cca4d11df0 Reads page 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic e075c7741f WIP 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 129aca7a54 Refactor thread route 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 8f1a53d2ed Adjust styling for lf notes 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 786fda989a First markdown render try 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 52b4aba426 Markdown test 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 6a2ce12501 Basic longform render 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic 4c7de1e307 Fix home feed 2024-05-29 19:02:53 +02:00
Dominik Gronkiewicz 6c73249ba7 Extended gitignore 2023-09-14 15:21:19 +07:00
Dominik Gronkiewicz ba382c4165 Extended gitignore 2023-09-14 15:15:36 +07:00
103 changed files with 16956 additions and 594 deletions

27
.gitignore vendored
View File

@ -1,3 +1,26 @@
node_modules
dist
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
out
.DS_Store
.eslintcache
/.env.*
yarn-debug.log*
yarn-error.log*
#env
dist/
.idea/
.vscode/
# custom
api.http

8660
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
"license": "MIT",
"devDependencies": {
"@formatjs/cli": "^6.0.4",
"@types/markdown-it": "^14.1.1",
"typescript": "^5.0.0",
"vite": "^4.0.3",
"vite-plugin-solid": "^2.5.0"
@ -25,19 +26,48 @@
"@cookbook/solid-intl": "0.1.2",
"@jukben/emoji-search": "3.0.0",
"@kobalte/core": "0.11.0",
"@milkdown/core": "^7.3.6",
"@milkdown/ctx": "^7.3.6",
"@milkdown/plugin-emoji": "^7.3.6",
"@milkdown/plugin-history": "^7.3.6",
"@milkdown/plugin-listener": "^7.3.6",
"@milkdown/plugin-slash": "^7.3.6",
"@milkdown/preset-commonmark": "^7.3.6",
"@milkdown/preset-gfm": "^7.3.6",
"@milkdown/prose": "^7.3.6",
"@milkdown/theme-nord": "^7.3.6",
"@milkdown/transformer": "^7.3.6",
"@milkdown/utils": "^7.3.6",
"@picocss/pico": "1.5.10",
"@scure/base": "1.1.3",
"@solidjs/router": "0.8.3",
"@thisbeyond/solid-select": "^0.13.0",
"@types/dompurify": "3.0.2",
"@types/markdown-it-container": "^2.0.10",
"@types/markdown-it-emoji": "^3.0.1",
"@types/markdown-it-footnote": "^3.0.4",
"dompurify": "3.0.5",
"highlight.js": "^11.9.0",
"light-bolt11-decoder": "^3.1.1",
"markdown-it": "^14.1.0",
"markdown-it-abbr": "^2.0.0",
"markdown-it-container": "^4.0.0",
"markdown-it-deflist": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-ins": "^4.0.0",
"markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"medium-zoom": "1.0.8",
"nostr-tools": "1.15.0",
"photoswipe": "5.4.3",
"qr-code-styling": "^1.6.0-rc.1",
"remark-directive": "^3.0.0",
"sass": "1.67.0",
"solid-js": "1.7.11",
"solid-transition-group": "0.2.3"
"solid-markdown": "^2.0.1",
"solid-transition-group": "0.2.3",
"unist-util-visit": "^5.0.0"
}
}

View File

@ -14,6 +14,7 @@ import { SearchProvider } from './contexts/SearchContext';
import { MessagesProvider } from './contexts/MessagesContext';
import { MediaProvider } from './contexts/MediaContext';
import { AppProvider } from './contexts/AppContext';
import { ReadsProvider } from './contexts/ReadsContext';
export const APP_ID = `${Math.floor(Math.random()*10000000000)}`;
@ -39,13 +40,15 @@ const App: Component = () => {
<ProfileProvider>
<MessagesProvider>
<NotificationsProvider>
<HomeProvider>
<ExploreProvider>
<ThreadProvider>
<Router />
</ThreadProvider>
</ExploreProvider>
</HomeProvider>
<ReadsProvider>
<HomeProvider>
<ExploreProvider>
<ThreadProvider>
<Router />
</ThreadProvider>
</ExploreProvider>
</HomeProvider>
</ReadsProvider>
</NotificationsProvider>
</MessagesProvider>
</ProfileProvider>

View File

@ -16,6 +16,7 @@ import { useNotificationsContext } from './contexts/NotificationsContext';
import { useSearchContext } from './contexts/SearchContext';
const Home = lazy(() => import('./pages/Home'));
const Reads = lazy(() => import('./pages/Reads'));
const Layout = lazy(() => import('./components/Layout/Layout'));
const Explore = lazy(() => import('./pages/Explore'));
const Thread = lazy(() => import('./pages/Thread'));
@ -109,8 +110,9 @@ const Router: Component = () => {
<Route path="/" component={Layout} >
<Route path="/" component={Landing} />
<Route path="/home" component={Home} />
<Route path="/thread/:postId" component={Thread} />
<Route path="/e/:postId" component={Thread} />
<Route path="/reads" component={Reads} />
<Route path="/thread/:id" component={Thread} />
<Route path="/e/:id" component={Thread} />
<Route path="/explore/:scope?/:timeframe?" component={Explore} />
<Route path="/messages/:sender?" component={Messages} />
<Route path="/notifications" component={Notifications} />

3
src/assets/icons/dot.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="6" height="6" viewBox="0 0 6 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.2 5.384C2.76267 5.384 2.35733 5.28267 1.984 5.08C1.62133 4.86667 1.33333 4.57867 1.12 4.216C0.906667 3.85333 0.8 3.45333 0.8 3.016C0.8 2.568 0.906667 2.168 1.12 1.816C1.33333 1.45333 1.62133 1.17067 1.984 0.967999C2.35733 0.754666 2.76267 0.648 3.2 0.648C3.648 0.648 4.05333 0.754666 4.416 0.967999C4.77867 1.17067 5.06667 1.45333 5.28 1.816C5.504 2.168 5.616 2.568 5.616 3.016C5.616 3.45333 5.504 3.85333 5.28 4.216C5.06667 4.57867 4.77867 4.86667 4.416 5.08C4.05333 5.28267 3.648 5.384 3.2 5.384Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@ -0,0 +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="M3.75 3.12069C3.75 1.52587 4.99042 0.25 6.5 0.25H17.5C19.0096 0.25 20.25 1.52587 20.25 3.12069V22.958C20.25 23.6156 19.5534 23.9603 19.0713 23.6063L12.2921 18.6285C12.1177 18.5004 11.8823 18.5004 11.7079 18.6285L4.92872 23.6063C4.44657 23.9603 3.75 23.6156 3.75 22.958V3.12069ZM6.5 1.83046C5.80044 1.83046 5.25 2.4175 5.25 3.12069V21.4441L10.8504 17.3318C11.5403 16.8253 12.4597 16.8253 13.1496 17.3318L18.75 21.4441V3.12069C18.75 2.4175 18.1996 1.83046 17.5 1.83046H6.5Z" fill="#AAAAAA"/>
</svg>

After

Width:  |  Height:  |  Size: 642 B

View File

@ -0,0 +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="M17.5 2.08046H6.5C5.94772 2.08046 5.5 2.54619 5.5 3.12069V20.9504L10.7025 17.1303C11.4804 16.5591 12.5197 16.5591 13.2975 17.1303L18.5 20.9504V3.12069C18.5 2.54619 18.0523 2.08046 17.5 2.08046ZM6.5 0C4.84315 0 3.5 1.39718 3.5 3.12069V22.958C3.5 23.8014 4.41427 24.2942 5.07668 23.8078L11.8558 18.83C11.9423 18.7665 12.0577 18.7665 12.1442 18.83L18.9233 23.8078C19.5857 24.2942 20.5 23.8014 20.5 22.958V3.12069C20.5 1.39718 19.1569 0 17.5 0H6.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 613 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="M11.25 1.03944C11.25 0.594241 11.5948 0.25 12 0.25C12.4052 0.25 12.75 0.594241 12.75 1.03944V14.9286L18.5155 9.79183C18.8279 9.51355 19.2963 9.54937 19.5664 9.87696C19.8412 10.2102 19.8025 10.7136 19.4845 10.9969L12.4989 17.2207C12.2146 17.474 11.7855 17.474 11.5011 17.2207L4.51553 10.9969C4.1975 10.7136 4.15886 10.2102 4.43364 9.87696C4.70378 9.54937 5.17217 9.51355 5.4845 9.79183L11.25 14.9286V1.03944Z" fill="#AAAAAA"/>
<path d="M0.25 19C0.25 18.5858 0.585787 18.25 1 18.25C1.41421 18.25 1.75 18.5858 1.75 19V21C1.75 21.6904 2.30964 22.25 3 22.25H21C21.6904 22.25 22.25 21.6904 22.25 21V19C22.25 18.5858 22.5858 18.25 23 18.25C23.4142 18.25 23.75 18.5858 23.75 19V22C23.75 22.9665 22.9665 23.75 22 23.75H2C1.0335 23.75 0.25 22.9665 0.25 22V19Z" fill="#AAAAAA"/>
</svg>

After

Width:  |  Height:  |  Size: 880 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="M13 1.03944C13 0.465371 12.5523 0 12 0C11.4477 0 11 0.465371 11 1.03944V14.371L5.65081 9.60516C5.23148 9.23157 4.60018 9.28204 4.24076 9.71791C3.88134 10.1538 3.9299 10.81 4.34923 11.1836L11.3348 17.4073C11.714 17.7451 12.2861 17.7451 12.6652 17.4073L19.6508 11.1836C20.0701 10.81 20.1187 10.1538 19.7593 9.71791C19.3999 9.28204 18.7686 9.23157 18.3492 9.60516L13 14.371V1.03944Z" fill="white"/>
<path d="M1 18C0.447715 18 0 18.4477 0 19V22C0 23.1046 0.89543 24 2 24H22C23.1046 24 24 23.1046 24 22V19C24 18.4477 23.5523 18 23 18C22.4477 18 22 18.4477 22 19V21C22 21.5523 21.5523 22 21 22H3C2.44772 22 2 21.5523 2 21V19C2 18.4477 1.55228 18 1 18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 774 B

View File

@ -0,0 +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="M11.5487 1.40099C11.8159 1.19967 12.1842 1.19967 12.4514 1.40099L23.4377 9.67839C23.7747 9.93233 23.8529 10.4278 23.6041 10.7822C23.3589 11.1314 22.8938 11.2045 22.5622 10.9547L20.7929 9.62164L19.75 20.7362V20.7479C19.75 21.7144 18.9665 22.4979 18 22.4979H6.00014C5.03365 22.4979 4.25014 21.7144 4.25014 20.7479V20.7362L3.20723 9.62157L1.43783 10.9547C1.10624 11.2045 0.641152 11.1314 0.395985 10.7822C0.147197 10.4278 0.22533 9.93233 0.562373 9.67839L11.5487 1.40099ZM12.7522 3.56355C12.3069 3.22803 11.6932 3.22803 11.2479 3.56355L4.73866 8.46775L5.75019 19.6879C5.75655 20.3728 6.31373 20.926 7.00013 20.926H17C17.6864 20.926 18.2436 20.3728 18.25 19.6879L19.2615 8.46783L12.7522 3.56355Z" fill="#AAAAAA"/>
</svg>

After

Width:  |  Height:  |  Size: 862 B

View File

@ -0,0 +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.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.47872ZM19 8.58384L12.6017 3.76323C12.2455 3.4948 11.7545 3.49481 11.3983 3.76323L5.00009 8.58376L6.00009 19.676C6.00009 20.2283 6.44781 20.676 7.00009 20.676H17C17.5523 20.676 18 20.2283 18 19.676L19 8.58384Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 814 B

View File

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.75 3C0.75 2.58579 1.08579 2.25 1.5 2.25H22.5C22.9142 2.25 23.25 2.58579 23.25 3C23.25 3.41421 22.9142 3.75 22.5 3.75H1.5C1.08579 3.75 0.75 3.41421 0.75 3Z" fill="#AAAAAA"/>
<path d="M0.75 9C0.75 8.58579 1.08579 8.25 1.5 8.25H22.5C22.9142 8.25 23.25 8.58579 23.25 9C23.25 9.41421 22.9142 9.75 22.5 9.75H1.5C1.08579 9.75 0.75 9.41421 0.75 9Z" fill="#AAAAAA"/>
<path d="M0.75 15C0.75 14.5858 1.08579 14.25 1.5 14.25H22.5C22.9142 14.25 23.25 14.5858 23.25 15C23.25 15.4142 22.9142 15.75 22.5 15.75H1.5C1.08579 15.75 0.75 15.4142 0.75 15Z" fill="#AAAAAA"/>
<path d="M0.75 21C0.75 20.5858 1.08579 20.25 1.5 20.25H14.5C14.9142 20.25 15.25 20.5858 15.25 21C15.25 21.4142 14.9142 21.75 14.5 21.75H1.5C1.08579 21.75 0.75 21.4142 0.75 21Z" fill="#AAAAAA"/>
</svg>

After

Width:  |  Height:  |  Size: 861 B

View File

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 2C0.947715 2 0.5 2.44772 0.5 3C0.5 3.55228 0.947716 4 1.5 4H22.5C23.0523 4 23.5 3.55228 23.5 3C23.5 2.44772 23.0523 2 22.5 2H1.5Z" fill="white"/>
<path d="M1.5 8C0.947715 8 0.5 8.44772 0.5 9C0.5 9.55228 0.947716 10 1.5 10H22.5C23.0523 10 23.5 9.55228 23.5 9C23.5 8.44772 23.0523 8 22.5 8H1.5Z" fill="white"/>
<path d="M0.5 15C0.5 14.4477 0.947715 14 1.5 14H22.5C23.0523 14 23.5 14.4477 23.5 15C23.5 15.5523 23.0523 16 22.5 16H1.5C0.947716 16 0.5 15.5523 0.5 15Z" fill="white"/>
<path d="M1.5 20C0.947715 20 0.5 20.4477 0.5 21C0.5 21.5523 0.947715 22 1.5 22H14.5C15.0523 22 15.5 21.5523 15.5 21C15.5 20.4477 15.0523 20 14.5 20H1.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@ -0,0 +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="M0.25 6C0.25 3.92893 1.92893 2.25 4 2.25H20C22.0711 2.25 23.75 3.92893 23.75 6V18C23.75 20.0711 22.0711 21.75 20 21.75H4C1.92893 21.75 0.25 20.0711 0.25 18V6ZM4 3.75C3.47378 3.75 2.98914 3.93095 2.60588 4.23385C2.42273 4.37859 2.35191 4.59023 2.3731 4.7907C2.39379 4.98647 2.50074 5.17163 2.66852 5.29444L11.2617 11.5841C11.7013 11.9059 12.2987 11.9059 12.7383 11.5841L21.3315 5.29444C21.4993 5.17164 21.6062 4.98648 21.6269 4.79071C21.6481 4.59024 21.5773 4.37861 21.3941 4.23386C21.0109 3.93096 20.5262 3.75 20 3.75H4ZM2.54296 7.03896C2.21233 6.7999 1.75 7.03614 1.75 7.44415V18C1.75 19.2426 2.75736 20.25 4 20.25H20C21.2426 20.25 22.25 19.2426 22.25 18V7.44416C22.25 7.03615 21.7877 6.79992 21.457 7.03898L13.0254 13.1354C12.4134 13.5779 11.5866 13.5779 10.9746 13.1354L2.54296 7.03896Z" fill="#AAAAAA"/>
</svg>

After

Width:  |  Height:  |  Size: 960 B

View File

@ -0,0 +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="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="white"/>
</svg>

After

Width:  |  Height:  |  Size: 778 B

View File

@ -0,0 +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="M2.2532 10C2.2532 4.61522 6.61842 0.25 12.0032 0.25C17.388 0.25 21.7532 4.61522 21.7532 10V12.8445C21.7532 13.6396 21.9049 14.4274 22.2002 15.1657L23.2225 17.7215C23.4196 18.2141 23.0568 18.75 22.5262 18.75H16.7532V19C16.7532 21.6234 14.6266 23.75 12.0032 23.75C9.37985 23.75 7.2532 21.6234 7.2532 19V18.75H1.48023C0.949634 18.75 0.586815 18.2141 0.783874 17.7215L1.80618 15.1657C2.10148 14.4274 2.2532 13.6396 2.2532 12.8445V10ZM12.0032 1.75C7.44685 1.75 3.7532 5.44365 3.7532 10V12.8445C3.7532 13.8305 3.56507 14.8074 3.19889 15.7228L2.58801 17.25H21.4184L20.8075 15.7228C20.4413 14.8074 20.2532 13.8305 20.2532 12.8445V10C20.2532 5.44365 16.5595 1.75 12.0032 1.75ZM15.2532 19V18.75H8.7532V19C8.7532 20.7949 10.2083 22.25 12.0032 22.25C13.7981 22.25 15.2532 20.7949 15.2532 19Z" fill="#AAAAAA"/>
</svg>

After

Width:  |  Height:  |  Size: 950 B

View File

@ -0,0 +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="M7.00296 19H1.47999C0.772526 19 0.288768 18.2855 0.551513 17.6286L1.57382 15.0729C1.85731 14.3641 2.00296 13.6078 2.00296 12.8445V10C2.00296 4.47715 6.48011 0 12.003 0C17.5258 0 22.003 4.47715 22.003 10V12.8445C22.003 13.6078 22.1486 14.3641 22.4321 15.0728L23.4544 17.6286C23.7171 18.2855 23.2334 19 22.5259 19H17.003C17.003 21.7614 14.7644 24 12.003 24C9.24153 24 7.00296 21.7614 7.00296 19ZM20.5751 15.8156L21.0489 17H2.95702L3.43077 15.8156C3.80876 14.8707 4.00296 13.8623 4.00296 12.8445V10C4.00296 5.58172 7.58468 2 12.003 2C16.4212 2 20.003 5.58172 20.003 10V12.8445C20.003 13.8623 20.1972 14.8707 20.5751 15.8156ZM15.003 19H9.00296C9.00296 20.6569 10.3461 22 12.003 22C13.6598 22 15.003 20.6569 15.003 19Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 882 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.25 1C8.25 0.585787 8.58579 0.25 9 0.25C9.41421 0.25 9.75 0.585786 9.75 1V8.25H17C17.4142 8.25 17.75 8.58579 17.75 9C17.75 9.41421 17.4142 9.75 17 9.75H9.75V17C9.75 17.4142 9.41421 17.75 9 17.75C8.58579 17.75 8.25 17.4142 8.25 17V9.75H1C0.585787 9.75 0.25 9.41421 0.25 9C0.25 8.58579 0.585786 8.25 1 8.25H8.25V1Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -0,0 +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="M0.25 9.5C0.25 4.39137 4.39137 0.25 9.5 0.25C14.6086 0.25 18.75 4.39137 18.75 9.5C18.75 11.3929 18.1817 13.1523 17.2064 14.6178L17.093 14.7883L22.4941 20.1894C23.1305 20.8258 23.1305 21.8577 22.4941 22.4941C21.8577 23.1305 20.8258 23.1305 20.1894 22.4941L14.7883 17.093L14.6178 17.2064C13.1523 18.1817 11.3929 18.75 9.5 18.75C4.39137 18.75 0.25 14.6086 0.25 9.5ZM9.5 1.75C5.21979 1.75 1.75 5.21979 1.75 9.5C1.75 13.7802 5.21979 17.25 9.5 17.25C13.7802 17.25 17.25 13.7802 17.25 9.5C17.25 5.21979 13.7802 1.75 9.5 1.75Z" fill="#AAAAAA"/>
</svg>

After

Width:  |  Height:  |  Size: 689 B

View File

@ -0,0 +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="M14.7563 17.4146C13.251 18.4163 11.4437 19 9.5 19C4.25329 19 0 14.7467 0 9.5C0 4.25329 4.25329 0 9.5 0C14.7467 0 19 4.25329 19 9.5C19 11.4437 18.4163 13.251 17.4146 14.7563L22.6709 20.0126C23.4049 20.7467 23.4049 21.9368 22.6709 22.6709C21.9368 23.4049 20.7467 23.4049 20.0126 22.6709L14.7563 17.4146ZM17 9.5C17 13.6421 13.6421 17 9.5 17C5.35786 17 2 13.6421 2 9.5C2 5.35786 5.35786 2 9.5 2C13.6421 2 17 5.35786 17 9.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 588 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 fill-rule="evenodd" clip-rule="evenodd" d="M7.66237 12C7.66237 9.85005 9.4054 8.10714 11.5552 8.10714C13.7051 8.10714 15.4481 9.85005 15.4481 12C15.4481 14.15 13.7051 15.8929 11.5552 15.8929C9.4054 15.8929 7.66237 14.15 7.66237 12ZM11.5552 9.60714C10.2338 9.60714 9.16237 10.6785 9.16237 12C9.16237 13.3215 10.2338 14.3929 11.5552 14.3929C12.8767 14.3929 13.9481 13.3215 13.9481 12C13.9481 10.6785 12.8767 9.60714 11.5552 9.60714Z" fill="#AAAAAA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.39429 1.32318C8.48228 0.707389 9.00967 0.25 9.63172 0.25H13.4787C14.1008 0.25 14.6282 0.707391 14.7162 1.32318L15.122 4.16335L15.2433 4.22093C15.6644 4.42093 16.0666 4.65405 16.4465 4.91687L16.5571 4.99337L19.2208 3.92412C19.7981 3.69239 20.458 3.92043 20.769 4.45916L22.6925 7.79074C23.0035 8.32948 22.8711 9.01494 22.3818 9.39903L20.1238 11.1713L20.1345 11.3051C20.1529 11.5343 20.1624 11.766 20.1624 12C20.1624 12.2339 20.1529 12.4657 20.1345 12.6948L20.1238 12.8286L22.3818 14.6009C22.8711 14.985 23.0035 15.6704 22.6925 16.2092L20.769 19.5408C20.458 20.0795 19.7982 20.3075 19.2209 20.0758L16.5571 19.0066L16.4465 19.0831C16.0666 19.346 15.6644 19.5791 15.2433 19.7791L15.122 19.8367L14.7162 22.6768C14.6282 23.2926 14.1008 23.75 13.4787 23.75H9.63172C9.00967 23.75 8.48228 23.2926 8.39429 22.6768L7.98845 19.8367L7.86719 19.7791C7.44599 19.5791 7.04359 19.3459 6.66356 19.083L6.55298 19.0065L3.88922 20.0758C3.31193 20.3075 2.65208 20.0795 2.34105 19.5407L0.417555 16.2091C0.106527 15.6704 0.238952 14.985 0.728265 14.6009L2.98635 12.8284L2.97568 12.6947C2.95739 12.4654 2.94808 12.2337 2.94808 12C2.94808 11.7663 2.95739 11.5346 2.97569 11.3053L2.98636 11.1716L0.728241 9.399C0.238943 9.01491 0.106527 8.32948 0.41755 7.79077L2.34106 4.45914C2.65208 3.92043 3.3119 3.69239 3.88919 3.92409L6.55333 4.99337L6.66391 4.91686C7.04383 4.65401 7.44607 4.4209 7.86719 4.22093L7.98845 4.16334L8.39429 1.32318ZM13.2619 1.75H9.84853L9.37231 5.08279C9.3596 5.17171 9.30018 5.24697 9.21664 5.27996L8.9464 5.38667C8.28924 5.64615 7.67959 6.00096 7.13491 6.43438L6.90729 6.6155C6.83694 6.67148 6.74195 6.68537 6.65851 6.65189L3.53169 5.39691L1.825 8.35299L4.47381 10.4323C4.54437 10.4877 4.57985 10.5767 4.56677 10.6654L4.52448 10.9524C4.47417 11.2938 4.44808 11.6436 4.44808 12C4.44808 12.3564 4.47417 12.7062 4.52448 13.0476L4.56677 13.3346C4.57985 13.4234 4.54437 13.5123 4.47381 13.5677L1.825 15.6469L3.53168 18.603L6.65811 17.348C6.74154 17.3145 6.83654 17.3284 6.9069 17.3844L7.13452 17.5655C7.67914 17.9989 8.28918 18.3538 8.9464 18.6133L9.21664 18.72C9.30018 18.753 9.3596 18.8283 9.37231 18.9172L9.84853 22.25H13.2619L13.7381 18.9172C13.7508 18.8283 13.8103 18.753 13.8938 18.72L14.1641 18.6133C14.8212 18.3539 15.4309 17.999 15.9755 17.5656L16.2032 17.3845C16.2735 17.3285 16.3685 17.3146 16.452 17.3481L19.5784 18.603L21.2851 15.6469L18.6363 13.5679C18.5657 13.5125 18.5302 13.4236 18.5433 13.3348L18.5856 13.0478C18.636 12.7058 18.6624 12.356 18.6624 12C18.6624 11.644 18.636 11.2942 18.5856 10.9521L18.5433 10.6651C18.5302 10.5763 18.5657 10.4874 18.6363 10.432L21.2851 8.35296L19.5784 5.39693L16.452 6.65188C16.3686 6.68537 16.2736 6.67148 16.2032 6.61551L15.9756 6.43443C15.4309 6.00104 14.8212 5.64613 14.1641 5.38667L13.8938 5.27996C13.8103 5.24697 13.7508 5.17171 13.7381 5.08279L13.2619 1.75Z" fill="#AAAAAA"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +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="M8.14682 1.28782C8.25241 0.548867 8.88528 0 9.63173 0H13.4788C14.2252 0 14.8581 0.548869 14.9637 1.28782L15.3505 3.99511C15.7839 4.20093 16.1978 4.44084 16.5887 4.71127L19.1277 3.69212C19.8205 3.41404 20.6123 3.68768 20.9855 4.33416L22.909 7.66574C23.2823 8.31223 23.1233 9.13478 22.5361 9.59569L20.3838 11.2851C20.4027 11.5209 20.4124 11.7593 20.4124 12C20.4124 12.2406 20.4027 12.479 20.3838 12.7148L22.5361 14.4042C23.1233 14.8651 23.2823 15.6877 22.909 16.3342L20.9855 19.6658C20.6123 20.3122 19.8205 20.5859 19.1278 20.3078L16.5888 19.2887C16.1978 19.5592 15.7839 19.7991 15.3505 20.0049L14.9637 22.7122C14.8581 23.4511 14.2252 24 13.4788 24H9.63173C8.88528 24 8.25241 23.4511 8.14682 22.7122L7.75997 20.0049C7.3265 19.7991 6.9124 19.5592 6.52134 19.2886L3.98237 20.3078C3.28961 20.5859 2.4978 20.3122 2.12456 19.6657L0.201065 16.3341C-0.172169 15.6877 -0.0132584 14.8652 0.573917 14.4042L2.72649 12.7145C2.70767 12.4787 2.6981 12.2403 2.6981 12C2.6981 11.7597 2.70767 11.5213 2.72649 11.2854L0.573889 9.59565C-0.0132678 9.13473 -0.172168 8.31222 0.20106 7.66577L2.12457 4.33414C2.4978 3.68769 3.28957 3.41404 3.98232 3.69208L6.52169 4.71127C6.91263 4.44079 7.32657 4.2009 7.75997 3.99509L8.14682 1.28782ZM10.0654 2L9.61981 5.11816C9.5944 5.29599 9.47555 5.44652 9.30847 5.51249L9.03823 5.6192C8.40436 5.86948 7.81618 6.21178 7.29059 6.63L7.06297 6.81112C6.92227 6.92309 6.73228 6.95087 6.56541 6.8839L3.6398 5.70968L2.14993 8.29023L4.6282 10.2357C4.76932 10.3464 4.84027 10.5244 4.81412 10.7019L4.77182 10.9889C4.72329 11.3182 4.6981 11.6558 4.6981 12C4.6981 12.3442 4.72329 12.6818 4.77182 13.0111L4.81412 13.2982C4.84028 13.4757 4.76932 13.6536 4.62819 13.7643L2.14994 15.7097L3.63979 18.2902L6.56499 17.116C6.73187 17.049 6.92187 17.0768 7.06258 17.1888L7.2902 17.3699C7.81572 17.7881 8.40427 18.1305 9.03823 18.3808L9.30847 18.4875C9.47555 18.5535 9.5944 18.704 9.61981 18.8818L10.0654 22H13.0451L13.4907 18.8818C13.5161 18.704 13.6349 18.5535 13.802 18.4875L14.0723 18.3808C14.7061 18.1305 15.2943 17.7882 15.8199 17.37L16.0475 17.1889C16.1882 17.0769 16.3782 17.0491 16.5451 17.1161L19.4703 18.2902L20.9601 15.7097L18.4819 13.7646C18.3408 13.6538 18.2698 13.4759 18.296 13.2984L18.3383 13.0113C18.3869 12.6813 18.4124 12.3436 18.4124 12C18.4124 11.6564 18.3869 11.3187 18.3383 10.9885L18.296 10.7015C18.2699 10.524 18.3408 10.3461 18.4819 10.2353L20.9601 8.29018L19.4703 5.70971L16.5451 6.88388C16.3783 6.95086 16.1883 6.92309 16.0476 6.81115L15.82 6.63006C15.2943 6.21184 14.7061 5.86946 14.0723 5.6192L13.802 5.51249C13.6349 5.44652 13.5161 5.29599 13.4907 5.11816L13.0451 2H10.0654ZM11.5552 9.85714C10.3719 9.85714 9.41239 10.8166 9.41239 12C9.41239 13.1834 10.3719 14.1429 11.5552 14.1429C12.7386 14.1429 13.6981 13.1834 13.6981 12C13.6981 10.8166 12.7386 9.85714 11.5552 9.85714ZM7.41239 12C7.41239 9.71197 9.26735 7.85714 11.5552 7.85714C13.8431 7.85714 15.6981 9.71197 15.6981 12C15.6981 14.288 13.8431 16.1429 11.5552 16.1429C9.26735 16.1429 7.41239 14.288 7.41239 12Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,216 @@
.article, .articleShort {
position: relative;
display: flex;
flex-direction: column;
text-decoration: none;
color: unset;
width: 100%;
margin-top: 20px;
padding-inline: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--devider);
.header {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
margin-bottom: 8px;
.userInfo {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
.userName {
color: var(--text-secondary);
font-family: Nacelle;
font-size: 14px;
font-weight: 700;
line-height: 14px;
}
.nip05 {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
}
.time {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 400;
line-height: 14px;
&::before {
content: '';
}
}
}
.body {
display: flex;
gap: 8px;
margin-bottom: 12px;
.text {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
flex-grow: 1;
.content {
.title {
color: var(--text-primary);
font-size: 24px;
font-weight: 800;
line-height: 32px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
}
.summary {
color: var(--brand-text);
font-size: 15px;
font-weight: 400;
line-height: 22px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 5;
line-clamp: 5;
-webkit-box-orient: vertical;
}
}
.tags {
width: 100%;
.tag {
display: inline-block;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
background-color: var(--background-input);
padding: 6px 10px;
border-radius: 12px;
width: fit-content;
margin-block: 4px;
margin-inline: 3px;
}
.estimate {
display: inline-block;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 12px;
padding: 6px 10px;
border: 1px solid var(--subtile-devider );
border-radius: 12px;
margin-right: 3px;
}
}
}
.image {
min-width: 164px;
min-height: 70px;
max-height: 240px;
height: fit-content;
border: 1px solid var(--devider);
border-radius: 8px;
overflow: hidden;
text-align: center;
img {
max-width: 162px;
max-height: 238px;
object-fit: scale-down;
}
.placeholderImage {
width: 164px;
height: 118px;
background-image: var(--reads-placeholder-image);
background-size: contain;
}
}
}
.zaps {
margin-bottom: 16px;
}
}
.upRightFloater {
position: absolute;
top: -6px;
right: 8px;
}
.articleShort {
padding: 0;
.header {
.userInfo {
.userName {
font-size: 15px;
font-weight: 700;
line-height: normal;
}
}
.time {
font-size: 15px;
font-weight: 400;
line-height: normal;
}
}
.body {
.text {
.content {
.title {
font-size: 16px;
font-weight: 800;
line-height: 24px;
}
.estimate {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 24px;
}
}
}
.image {
min-width: 100px;
height: fit-content;
border: 1px solid var(--devider);
border-radius: 8px;
overflow: hidden;
img {
width: 100px;
object-fit: scale-down;
}
.placeholderImage {
width: 100px;
height: 70px;
background-image: var(--reads-placeholder-image);
background-size: contain;
}
}
}
}

View File

@ -0,0 +1,291 @@
import { A } from '@solidjs/router';
import { batch, Component, createEffect, For, JSXElement, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Portal } from 'solid-js/web';
import { useAccountContext } from '../../contexts/AccountContext';
import { CustomZapInfo, useAppContext } from '../../contexts/AppContext';
import { useThreadContext } from '../../contexts/ThreadContext';
import { shortDate } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { userName } from '../../stores/profile';
import { PrimalArticle, ZapOption } from '../../types/primal';
import { uuidv4 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import { NoteReactionsState } from '../Note/Note';
import NoteContextTrigger from '../Note/NoteContextTrigger';
import ArticleFooter from '../Note/NoteFooter/ArticleFooter';
import NoteFooter from '../Note/NoteFooter/NoteFooter';
import NoteTopZaps from '../Note/NoteTopZaps';
import NoteTopZapsCompact from '../Note/NoteTopZapsCompact';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './ArticlePreview.module.scss';
const ArticlePreview: Component<{
id?: string,
article: PrimalArticle,
}> = (props) => {
const app = useAppContext();
const account = useAccountContext();
const thread = useThreadContext();
const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({
likes: props.article.likes,
liked: props.article.noteActions.liked,
reposts: props.article.reposts,
reposted: props.article.noteActions.reposted,
replies: props.article.replies,
replied: props.article.noteActions.replied,
zapCount: props.article.zaps,
satsZapped: props.article.satszapped,
zapped: props.article.noteActions.zapped,
zappedAmount: 0,
zappedNow: false,
isZapping: false,
showZapAnim: false,
hideZapIcon: false,
moreZapsAvailable: false,
isRepostMenuVisible: false,
topZaps: [],
topZapsFeed: [],
quoteCount: 0,
});
let latestTopZap: string = '';
let latestTopZapFeed: string = '';
let articleContextMenu: HTMLDivElement | undefined;
const onConfirmZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
batch(() => {
updateReactionsState('zappedAmount', () => zapOption.amount || 0);
updateReactionsState('satsZapped', (z) => z + (zapOption.amount || 0));
updateReactionsState('zapped', () => true);
updateReactionsState('showZapAnim', () => true)
});
addTopZap(zapOption);
addTopZapFeed(zapOption)
};
const onSuccessZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
app?.actions.resetCustomZap();
const pubkey = account?.publicKey;
if (!pubkey) return;
batch(() => {
updateReactionsState('zapCount', (z) => z + 1);
updateReactionsState('isZapping', () => false);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => true);
});
};
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);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => props.article.noteActions.zapped);
});
removeTopZap(zapOption);
removeTopZapFeed(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);
updateReactionsState('showZapAnim', () => false);
updateReactionsState('hideZapIcon', () => false);
updateReactionsState('zapped', () => props.article.noteActions.zapped);
});
removeTopZap(zapOption);
removeTopZapFeed(zapOption);
};
const addTopZap = (zapOption: ZapOption) => {
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.article.id,
id: latestTopZap,
};
if (!thread?.users.find((u) => u.pubkey === pubkey)) {
thread?.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 addTopZapFeed = (zapOption: ZapOption) => {
const pubkey = account?.publicKey;
if (!pubkey) return;
const oldZaps = [ ...reactionsState.topZapsFeed ];
latestTopZapFeed = uuidv4() as string;
const newZap = {
amount: zapOption.amount || 0,
message: zapOption.message || '',
pubkey,
eventId: props.article.id,
id: latestTopZapFeed,
};
const zaps = [ ...oldZaps, { ...newZap }].sort((a, b) => b.amount - a.amount).slice(0, 4);
updateReactionsState('topZapsFeed', () => [...zaps]);
}
const removeTopZapFeed = (zapOption: ZapOption) => {
const zaps = reactionsState.topZapsFeed.filter(z => z.id !== latestTopZapFeed);
updateReactionsState('topZapsFeed', () => [...zaps]);
};
const customZapInfo: () => CustomZapInfo = () => ({
note: props.article,
onConfirm: onConfirmZap,
onSuccess: onSuccessZap,
onFail: onFailZap,
onCancel: onCancelZap,
});
const openReactionModal = (openOn = 'likes') => {
app?.actions.openReactionModal(props.article.id, {
likes: reactionsState.likes,
zaps: reactionsState.zapCount,
reposts: reactionsState.reposts,
quotes: reactionsState.quoteCount,
openOn,
});
};
const onContextMenuTrigger = () => {
app?.actions.openContextMenu(
props.article,
articleContextMenu?.getBoundingClientRect(),
() => {
app?.actions.openCustomZapModal(customZapInfo());
},
openReactionModal,
);
}
return (
<A class={styles.article} href={`/e/${props.article.naddr}`}>
<div class={styles.upRightFloater}>
<NoteContextTrigger
ref={articleContextMenu}
onClick={onContextMenuTrigger}
/>
</div>
<div class={styles.header}>
<div class={styles.userInfo}>
<Avatar user={props.article.user} size="micro"/>
<div class={styles.userName}>{userName(props.article.user)}</div>
<VerificationCheck user={props.article.user} />
<div class={styles.nip05}>{props.article.user.nip05 || ''}</div>
</div>
<div class={styles.time}>
{shortDate(props.article.published)}
</div>
</div>
<div class={styles.body}>
<div class={styles.text}>
<div class={styles.content}>
<div class={styles.title}>
{props.article.title}
</div>
<div class={styles.summary}>
{props.article.summary}
</div>
</div>
<div class={styles.tags}>
<div class={styles.estimate}>
{Math.ceil(props.article.wordCount / 238)} minute read
</div>
<For each={props.article.tags.slice(0, 3)}>
{tag => (
<div class={styles.tag}>
{tag}
</div>
)}
</For>
<Show when={props.article.tags.length > 3}>
<div class={styles.tag}>
+ {props.article.tags.length - 3}
</div>
</Show>
</div>
</div>
<div class={styles.image}>
<Show
when={props.article.image}
fallback={<div class={styles.placeholderImage}></div>}
>
<img src={props.article.image} />
</Show>
</div>
</div>
<Show when={props.article.topZaps.length > 0}>
<div class={styles.zaps}>
<NoteTopZapsCompact
note={props.article}
action={() => {}}
topZaps={props.article.topZaps}
topZapLimit={4}
/>
</div>
</Show>
<div class={styles.footer}>
<ArticleFooter
note={props.article}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
onZapAnim={addTopZapFeed}
/>
</div>
</A>
);
}
export default hookForDev(ArticlePreview);

View File

@ -0,0 +1,65 @@
import { A } from '@solidjs/router';
import { batch, Component, createEffect, For, JSXElement, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Portal } from 'solid-js/web';
import { useAccountContext } from '../../contexts/AccountContext';
import { CustomZapInfo, useAppContext } from '../../contexts/AppContext';
import { useThreadContext } from '../../contexts/ThreadContext';
import { date, shortDate } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { userName } from '../../stores/profile';
import { PrimalArticle, ZapOption } from '../../types/primal';
import { uuidv4 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import { NoteReactionsState } from '../Note/Note';
import NoteContextTrigger from '../Note/NoteContextTrigger';
import ArticleFooter from '../Note/NoteFooter/ArticleFooter';
import NoteFooter from '../Note/NoteFooter/NoteFooter';
import NoteTopZaps from '../Note/NoteTopZaps';
import NoteTopZapsCompact from '../Note/NoteTopZapsCompact';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './ArticlePreview.module.scss';
const ArticlePreview: Component<{
id?: string,
article: PrimalArticle,
}> = (props) => {
return (
<A class={styles.articleShort} href={`/e/${props.article.noteId}`}>
<div class={styles.header}>
<div class={styles.userInfo}>
<Avatar user={props.article.user} size="micro"/>
<div class={styles.userName}>{userName(props.article.user)}</div>
</div>
<div class={styles.time}>
{date(props.article.published).label}
</div>
</div>
<div class={styles.body}>
<div class={styles.text}>
<div class={styles.content}>
<div class={styles.title}>
{props.article.title}
</div>
<div class={styles.estimate}>
{Math.ceil(props.article.wordCount / 238)} minutes
</div>
</div>
</div>
<div class={styles.image}>
<Show
when={props.article.image}
fallback={<div class={styles.placeholderImage}></div>}
>
<img src={props.article.image} />
</Show>
</div>
</div>
</A>
);
}
export default hookForDev(ArticlePreview);

View File

@ -0,0 +1,66 @@
.authorSubscribeCard {
display: flex;
flex-direction: column;
gap: 16px;
border-radius: 8px;
background: var(--background-header-input);
width: 300px;
padding: 16px;
.userInfo {
display: flex;
gap: 8px;
.userData {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
overflow: hidden;
.userName {
display: flex;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.nip05 {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.userPitch {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 22px;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
>button {
text-align: center;
font-size: 14px;
font-weight: 600;
line-height: 16px;
padding-inline: 16px;
width: 100%;
}
}
}

View File

@ -0,0 +1,153 @@
import { A, useNavigate } from '@solidjs/router';
import { batch, Component, createEffect, createSignal, For, JSXElement, onMount, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Portal } from 'solid-js/web';
import { APP_ID } from '../../App';
import { Kind } from '../../constants';
import { useAccountContext } from '../../contexts/AccountContext';
import { CustomZapInfo, useAppContext } from '../../contexts/AppContext';
import { useThreadContext } from '../../contexts/ThreadContext';
import { fetchUserProfile } from '../../handleNotes';
import { date, shortDate } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { sendEvent } from '../../lib/notes';
import { zapSubscription } from '../../lib/zap';
import { userName } from '../../stores/profile';
import { PrimalArticle, PrimalUser, ZapOption } from '../../types/primal';
import { uuidv4 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonSecondary from '../Buttons/ButtonSecondary';
import Loader from '../Loader/Loader';
import { Tier, TierCost } from '../SubscribeToAuthorModal/SubscribeToAuthorModal';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './AuthorSubscribe.module.scss';
const AuthoreSubscribe: Component<{
id?: string,
pubkey: string,
}> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const navigate = useNavigate();
const [isFetching, setIsFetching] = createSignal(false);
const [author, setAuthor] = createSignal<PrimalUser>();
const getAuthorData = async () => {
if (!account?.publicKey) return;
const subId = `reads_fpi_${APP_ID}`;
setIsFetching(() => true);
const profile = await fetchUserProfile(account.publicKey, props.pubkey, subId);
setIsFetching(() => false);
setAuthor(() => ({ ...profile }));
};
onMount(() => {
getAuthorData();
});
const doSubscription = async (tier: Tier, cost: TierCost, exchangeRate?: Record<string, Record<string, number>>) => {
const a = author();
if (!a || !account || !cost) return;
if (cost.unit === 'USD' && (!exchangeRate || !exchangeRate['USD'])) return;
const subEvent = {
kind: Kind.Subscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', tier.id],
['amount', cost.amount, cost.unit, cost.cadence],
['event', JSON.stringify(tier.event)],
// Copy any zap splits
...(tier.event.tags?.filter(t => t[0] === 'zap') || []),
],
}
const { success, note } = await sendEvent(subEvent, account.relays, account.relaySettings);
if (success && note) {
const isZapped = await zapSubscription(note, a, account.publicKey, account.relays, exchangeRate);
if (!isZapped) {
unsubscribe(note.id);
}
}
}
const unsubscribe = async (eventId: string) => {
const a = author();
if (!a || !account) return;
const unsubEvent = {
kind: Kind.Unsubscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', eventId],
],
};
await sendEvent(unsubEvent, account.relays, account.relaySettings);
}
const openSubscribe = () => {
app?.actions.openAuthorSubscribeModal(author(), doSubscription);
};
return (
<div class={styles.featuredAuthor}>
<Show
when={!isFetching()}
fallback={<Loader />}
>
<div class={styles.authorSubscribeCard}>
<div class={styles.userInfo}>
<Avatar user={author()} size="ml" />
<div class={styles.userData}>
<div class={styles.userName}>
{userName(author())}
<VerificationCheck user={author()} />
</div>
<div class={styles.nip05}>
{author()?.nip05}
</div>
</div>
</div>
<div class={styles.userPitch}>
{author()?.about || ''}
</div>
<div class={styles.actions}>
<ButtonSecondary
light={true}
onClick={() => navigate(`/p/${author()?.npub}`)}
>
view profile
</ButtonSecondary>
<ButtonPrimary onClick={openSubscribe}>
subscribe
</ButtonPrimary>
</div>
</div>
</Show>
</div>
);
}
export default hookForDev(AuthoreSubscribe);

View File

@ -164,6 +164,21 @@
}
}
.mlAvatar {
@include avatar;
width: 60px;
height: 60px;
.missingBack {
width: 60px;
height: 60px;
}
.iconBackground {
@include iconBackground;
}
}
.largeAvatar {
@include avatar;
width: 72px;
@ -290,6 +305,13 @@
font-size: 16px;
}
.mlMissing {
@include missing;
width: 60px;
height: 60px;
font-size: 16px;
}
.largeMissing {
@include missing;
width: 68px;

View File

@ -11,7 +11,7 @@ import styles from './Avatar.module.scss';
const Avatar: Component<{
src?: string | undefined,
size?: "micro" | "xxs" | "xss" | "xs" | "vvs" | "vs" | "sm" | "md" | "lg" | "xl" | "xxl",
size?: "micro" | "xxs" | "xss" | "xs" | "vvs" | "vs" | "sm" | "md" | "ml" | "lg" | "xl" | "xxl",
user?: PrimalUser,
highlightBorder?: boolean,
id?: string,
@ -34,6 +34,7 @@ const Avatar: Component<{
vs: styles.vsAvatar,
sm: styles.smallAvatar,
md: styles.midAvatar,
ml: styles.mlAvatar,
lg: styles.largeAvatar,
xl: styles.extraLargeAvatar,
xxl: styles.xxlAvatar,
@ -48,6 +49,7 @@ const Avatar: Component<{
vs: styles.vsMissing,
sm: styles.smallMissing,
md: styles.midMissing,
ml: styles.mlMissing,
lg: styles.largeMissing,
xl: styles.extraLargeMissing,
xxl: styles.xxlMissing,

View File

@ -0,0 +1,165 @@
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 { PrimalArticle, 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 BookmarkArticle: Component<{ note: PrimalArticle | undefined, large?: boolean }> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
const [isBookmarked, setIsBookmarked] = createSignal(false);
const [bookmarkInProgress, setBookmarkInProgress] = createSignal(false);
createEffect(() => {
const note = props.note;
if (note) {
setIsBookmarked(() => account?.bookmarks.includes(note.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 && props.note && !bookmarkTags.find(b => b[0] === 'e' && b[1] === props.note?.id)) {
const bookmarksToAdd = [...bookmarkTags, ['e', props.note.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?.id)) {
const bookmarksToAdd = bookmarks.filter(b => b[0] !== 'e' || b[1] !== props.note?.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 BookmarkArticle;

View File

@ -37,3 +37,14 @@
}
}
}
.right {
display: flex;
justify-content: flex-end;
padding-left: 6px !important;
padding-right: 0px !important;
&.rightL {
width: 22px !important;
}
}

View File

@ -16,7 +16,7 @@ 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 BookmarkNote: Component<{ note: PrimalNote, large?: boolean, right?: boolean }> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
@ -136,6 +136,7 @@ const BookmarkNote: Component<{ note: PrimalNote, large?: boolean }> = (props) =
return (
<div class={styles.bookmark}>
<ButtonGhost
class={`${props.right ? styles.right : ''} ${props.large ? styles.rightL : ''}`}
onClick={(e: MouseEvent) => {
e.preventDefault();

View File

@ -118,6 +118,7 @@ const EmbeddedNote: Component<{
shorten={true}
isEmbeded={true}
width={noteContent?.getBoundingClientRect().width}
margins={2}
/>
</div>
</>

View File

@ -8,7 +8,7 @@ import { FeedOption, PrimalFeed, SelectionOption } from '../../types/primal';
import SelectBox from '../SelectBox/SelectBox';
import SelectionBox from '../SelectionBox/SelectionBox';
const FeedSelect: Component<{ isPhone?: boolean, id?: string}> = (props) => {
const FeedSelect: Component<{ isPhone?: boolean, id?: string, big?: boolean}> = (props) => {
const account = useAccountContext();
const home = useHomeContext();
@ -123,6 +123,7 @@ const FeedSelect: Component<{ isPhone?: boolean, id?: string}> = (props) => {
value={selectedValue()}
isSelected={isSelected}
isPhone={props.isPhone}
big={props.big}
/>
);
}

View File

@ -0,0 +1,129 @@
import { Component } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { useHomeContext } from '../../contexts/HomeContext';
import { useReadsContext } from '../../contexts/ReadsContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { hookForDev } from '../../lib/devTools';
import { fetchStoredFeed } from '../../lib/localStore';
import { FeedOption, PrimalFeed, SelectionOption } from '../../types/primal';
import SelectBox from '../SelectBox/SelectBox';
import SelectionBox from '../SelectionBox/SelectionBox';
const ReedSelect: Component<{ isPhone?: boolean, id?: string, big?: boolean}> = (props) => {
const account = useAccountContext();
const reeds = useReadsContext();
const settings = useSettingsContext();
const findFeed = (hex: string, includeReplies: string) => {
const ir = includeReplies === 'undefined' ? undefined :
includeReplies === 'true';
return settings?.availableFeeds.find(f => {
const isHex = f.hex === hex;
const isOpt = typeof ir === typeof f.includeReplies ?
f.includeReplies === ir :
false;
return isHex && isOpt;
});
};
const selectFeed = (option: FeedOption) => {
const [hex, includeReplies] = option.value?.split('_') || [];
// const selector = document.getElementById('defocus');
// selector?.focus();
// selector?.blur();
const feed = {
hex: option.value,
name: option.label,
};
reeds?.actions.clearNotes();
reeds?.actions.selectFeed(feed);
};
const isSelected = (option: FeedOption) => {
const selected = reeds?.selectedFeed;
if (selected?.hex && option.value) {
const t = option.value.split('_');
const isHex = encodeURI(selected.hex) == t[0];
const isOpt = t[1] === 'undefined' ?
selected.includeReplies === undefined :
selected.includeReplies?.toString() === t[1];
return isHex && isOpt;
}
return false;
}
const options:() => SelectionOption[] = () => {
let opts = [];
if (account?.publicKey) {
opts.push(
{
label: 'My Reads',
value: account?.publicKey || '',
}
);
}
opts.push(
{
label: 'All Reads',
value: 'none',
}
);
return [ ...opts ];
};
const initialValue = () => {
const selected = reeds?.selectedFeed;
if (!selected) {
const feed = options()[0];
selectFeed(feed);
return feed;
}
return {
label: selected.name,
value: selected.hex || '',
}
}
const selectedValue = () => {
if (!reeds?.selectedFeed)
return initialValue();
const value = `${encodeURI(reeds.selectedFeed.hex || '')}`;
return {
label: reeds.selectedFeed.name,
value,
};
};
return (
<SelectionBox
options={options()}
onChange={selectFeed}
initialValue={initialValue()}
value={selectedValue()}
isSelected={isSelected}
isPhone={props.isPhone}
big={props.big}
/>
);
}
export default hookForDev(ReedSelect);

View File

@ -117,11 +117,19 @@
outline: none;
width: 100%;
padding-block: 10px;
padding-inline: 14px;
padding-top: 18px;
padding-bottom: 12px;
padding-inline: 20px;
border-bottom: 1px solid var(--devider);
z-index: var(--z-index-header);
&.readsFeed {
position: relative;
border-bottom: none;
padding-inline: 0;
z-index: var(--z-index-header);
}
.newContentItem {
color: var(--accent-links);
@ -140,7 +148,7 @@
}
.feedPlaceholder {
width: 600px;
width: var(--center-col-w);
height: 20px;
background-color: red;
}
@ -154,9 +162,8 @@
outline: none;
padding-block: 21px;
padding-inline: 12px;
padding-inline: 20px;
border: none;
border-bottom: 1px solid var(--devider);
border-radius: 0;
justify-content: flex-start;

View File

@ -119,7 +119,7 @@ const HomeHeader: Component< {
<Show
when={settings?.availableFeeds && settings?.availableFeeds.length > 0 && home?.selectedFeed}
>
<FeedSelect />
<FeedSelect big={true} />
</Show>
<Show

View File

@ -0,0 +1,37 @@
import { Component, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
import Avatar from '../Avatar/Avatar';
import styles from './HomeHeader.module.scss';
import FeedSelect from '../FeedSelect/FeedSelect';
import { useAccountContext } from '../../contexts/AccountContext';
import SmallCallToAction from '../SmallCallToAction/SmallCallToAction';
import { useHomeContext } from '../../contexts/HomeContext';
import { useIntl } from '@cookbook/solid-intl';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { placeholders as t, actions as tActions, feedNewPosts } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import CreateAccountModal from '../CreateAccountModal/CreateAccountModal';
import LoginModal from '../LoginModal/LoginModal';
import { userName } from '../../stores/profile';
import { PrimalUser } from '../../types/primal';
import ReedSelect from '../FeedSelect/ReedSelect';
const ReadsHeader: Component< {
id?: string,
hasNewPosts: () => boolean,
loadNewContent: () => void,
newPostCount: () => number,
newPostAuthors: PrimalUser[],
} > = (props) => {
return (
<div id={props.id}>
<div class={`${styles.bigFeedSelect} ${styles.readsFeed}`}>
<ReedSelect big={true} />
</div>
</div>
);
}
export default hookForDev(ReadsHeader);

View File

@ -0,0 +1,96 @@
import { Component, createEffect, createSignal, For, onMount, Show } from 'solid-js';
import {
EventCoordinate,
PrimalArticle,
PrimalUser,
SelectionOption
} from '../../types/primal';
import styles from './HomeSidebar.module.scss';
import SmallNote from '../SmallNote/SmallNote';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import SelectionBox from '../SelectionBox/SelectionBox';
import Loader from '../Loader/Loader';
import { readHomeSidebarSelection, saveHomeSidebarSelection } from '../../lib/localStore';
import { useHomeContext } from '../../contexts/HomeContext';
import { useReadsContext } from '../../contexts/ReadsContext';
import { createStore } from 'solid-js/store';
import { APP_ID } from '../../App';
import { subsTo } from '../../sockets';
import { getArticleThread, getReadsTopics, getUserArticleFeed } from '../../lib/feed';
import { fetchUserArticles } from '../../handleNotes';
import { getParametrizedEvent, getParametrizedEvents } from '../../lib/notes';
import { decodeIdentifier } from '../../lib/keys';
import ArticleShort from '../ArticlePreview/ArticleShort';
import { userName } from '../../stores/profile';
const ArticleSidebar: Component< { id?: string, user: PrimalUser, article: PrimalArticle } > = (props) => {
const account = useAccountContext();
const [recomended, setRecomended] = createStore<PrimalArticle[]>([]);
const [isFetchingArticles, setIsFetchingArticles] = createSignal(false);
const getArticles = async () => {
const subId = `article_recomended_${APP_ID}`;
setIsFetchingArticles(() => true);
const articles = await fetchUserArticles(account?.publicKey, props.user.pubkey, 'authored', subId);
setRecomended(() => [...articles.filter(a => a.id !== props.article.id)]);
setIsFetchingArticles(() => false);
}
createEffect(() => {
if (account?.isKeyLookupDone && props.user) {
getArticles();
}
});
return (
<div id={props.id} class={styles.articleSidebar}>
<Show when={account?.isKeyLookupDone && props.article}>
<div class={styles.headingPicks}>
Total zaps
</div>
<div class={styles.section}>
<div class={styles.totalZaps}>
<span class={styles.totalZapsIcon} />
<span class={styles.amount}>26,450</span>
<span class={styles.unit}>sats</span>
</div>
</div>
<Show
when={!isFetchingArticles()}
fallback={
<Loader />
}
>
<Show
when={recomended.length > 0}
>
<div class={styles.headingReads}>
More Reads from {userName(props.article.user)}
</div>
<div class={styles.section}>
<For each={recomended}>
{(note) => <ArticleShort article={note} />}
</For>
</div>
</Show>
</Show>
</Show>
</div>
);
}
export default hookForDev(ArticleSidebar);

View File

@ -56,3 +56,99 @@
margin-top: 34px;
z-index: 10px;
}
.headingPicks {
@include heading();
font-weight: 600;
text-transform: capitalize;
height: fit-content;
margin-bottom: 12px;
padding-bottom: 0px;
}
.headingReads {
@include heading();
font-weight: 600;
text-transform: none;
height: fit-content;
margin-bottom: 12px;
padding-bottom: 0px;
}
.readsSidebar {
margin-left: -8px;
.section {
margin-bottom: 28px;
>a:last-child {
border-bottom: none;
}
}
.topic {
display: inline-block;
background-color: var(--background-input);
padding: 6px 10px;
border-radius: 12px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
margin-right: 8px;
}
}
.articleSidebar {
.section {
margin-bottom: 28px;
max-height: 526px;
overflow-y: scroll;
>a:last-child {
border-bottom: none;
}
.totalZaps {
display: flex;
align-items: flex-end;
gap: 4px;
.totalZapsIcon {
display: inline-block;
width: 18px;
height: 32px;
background: var(--active-zap);
-webkit-mask: url(../../assets/icons/feed_zap_fill_2.svg) no-repeat 0px 0 / 19px 32px;
mask: url(../../assets/icons/feed_zap_fill_2.svg) no-repeat 0px 0 / 19px 32px;
}
.amount {
color: var(--text-primary);
font-size: 28px;
font-weight: 700;
line-height: 32px;
}
.unit {
color: var(--text-primary);
font-size: 22px;
font-weight: 400;
line-height: 32px;
}
}
}
.topic {
display: inline-block;
background-color: var(--background-input);
padding: 6px 10px;
border-radius: 12px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
margin-right: 8px;
}
}

View File

@ -0,0 +1,240 @@
import { Component, createEffect, createSignal, For, onMount, Show } from 'solid-js';
import {
EventCoordinate,
PrimalArticle,
SelectionOption
} from '../../types/primal';
import styles from './HomeSidebar.module.scss';
import SmallNote from '../SmallNote/SmallNote';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import SelectionBox from '../SelectionBox/SelectionBox';
import Loader from '../Loader/Loader';
import { readHomeSidebarSelection, saveHomeSidebarSelection } from '../../lib/localStore';
import { useHomeContext } from '../../contexts/HomeContext';
import { useReadsContext } from '../../contexts/ReadsContext';
import { createStore } from 'solid-js/store';
import { APP_ID } from '../../App';
import { subsTo } from '../../sockets';
import { getArticleThread, getFeaturedAuthors, getReadsTopics } from '../../lib/feed';
import { fetchArticles } from '../../handleNotes';
import { getParametrizedEvent, getParametrizedEvents } from '../../lib/notes';
import { decodeIdentifier } from '../../lib/keys';
import ArticleShort from '../ArticlePreview/ArticleShort';
import AuthorSubscribe from '../AuthorSubscribe/AuthorSubscribe';
const sidebarOptions = [
{
label: 'Trending 24h',
value: 'trending_24h',
},
{
label: 'Trending 12h',
value: 'trending_12h',
},
{
label: 'Trending 4h',
value: 'trending_4h',
},
{
label: 'Trending 1h',
value: 'trending_1h',
},
{
label: '',
value: '',
disabled: true,
separator: true,
},
{
label: 'Most-zapped 24h',
value: 'mostzapped_24h',
},
{
label: 'Most-zapped 12h',
value: 'mostzapped_12h',
},
{
label: 'Most-zapped 4h',
value: 'mostzapped_4h',
},
{
label: 'Most-zapped 1h',
value: 'mostzapped_1h',
},
];
const ReadsSidebar: Component< { id?: string } > = (props) => {
const account = useAccountContext();
const reads= useReadsContext();
const [topPicks, setTopPicks] = createStore<PrimalArticle[]>([]);
const [topics, setTopics] = createStore<string[]>([]);
const [featuredAuthor, setFeautredAuthor] = createSignal<string>();
const [isFetching, setIsFetching] = createSignal(false);
const [isFetchingTopics, setIsFetchingTopics] = createSignal(false);
const [isFetchingAuthors, setIsFetchingAuthors] = createSignal(false);
const [got, setGot] = createSignal(false);
const getTopics = () => {
const subId = `reads_topics_${APP_ID}`;
const unsub = subsTo(subId, {
onEvent: (_, content) => {
const topics = JSON.parse(content.content || '[]') as string[];
setTopics(() => [...topics]);
},
onEose: () => {
setIsFetchingTopics(() => false);
unsub();
}
})
setIsFetchingTopics(() => true);
getReadsTopics(subId);
}
const getFeaturedAuthor = () => {
const subId = `reads_fa_${APP_ID}`;
const unsub = subsTo(subId, {
onEvent: (_, content) => {
const authors = JSON.parse(content.content || '[]') as string[];
// const author = '1d22e00c32fcf2eb60c094f89f5cfa3ccd38a1b317dccda9b296fa6f50e00d0e';
// setFeautredAuthor(() => author);
// const author = 'a8eb6e07bf408713b0979f337a3cd978f622e0d41709f3b74b48fff43dbfcd2b';
// setFeautredAuthor(() => author);
// const author = '88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079';
// setFeautredAuthor(() => author);
// const author = 'fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52';
// setFeautredAuthor(() => author);
// const author = '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24';
// setFeautredAuthor(() => author);
setFeautredAuthor(() => authors[Math.floor(Math.random() * authors.length)]);
},
onEose: () => {
setIsFetchingAuthors(() => false);
unsub();
}
})
setIsFetchingAuthors(() => true);
getFeaturedAuthors(subId);
}
onMount(() => {
if (account?.isKeyLookupDone && reads?.recomendedReads.length === 0) {
reads.actions.doSidebarSearch('');
}
if (account?.isKeyLookupDone) {
getTopics();
getFeaturedAuthor();
}
});
createEffect(() => {
const rec = reads?.recomendedReads || [];
if (rec.length > 0 && !got()) {
setGot(() => true);
let randomIndices = new Set<number>();
while (randomIndices.size < 3) {
const randomIndex = Math.floor(Math.random() * rec.length);
randomIndices.add(randomIndex);
}
const reads = [ ...randomIndices ].map(i => rec[i]);
getRecomendedArticles(reads)
}
});
const getRecomendedArticles = async (ids: string[]) => {
// if (!account?.publicKey) return;
const subId = `reads_picks_${APP_ID}`;
setIsFetching(() => true);
const articles = await fetchArticles(ids,subId);
setIsFetching(() => false);
setTopPicks(() => [...articles]);
};
return (
<div id={props.id} class={styles.readsSidebar}>
<Show when={account?.isKeyLookupDone}>
<Show when={account?.publicKey}>
<div class={styles.headingPicks}>
Featured Author
</div>
<Show
when={!isFetchingAuthors()}
fallback={
<Loader />
}
>
<div class={styles.section}>
<AuthorSubscribe pubkey={featuredAuthor()} />
</div>
</Show>
</Show>
<div class={styles.headingPicks}>
Featured Reads
</div>
<Show
when={!isFetching()}
fallback={
<Loader />
}
>
<div class={styles.section}>
<For each={topPicks}>
{(note) => <ArticleShort article={note} />}
</For>
</div>
</Show>
<div class={styles.headingPicks}>
Topics
</div>
<Show
when={!isFetchingTopics()}
fallback={
<Loader />
}
>
<div class={styles.section}>
<For each={topics}>
{(topic) => <div class={styles.topic}>{topic}</div>}
</For>
</div>
</Show>
</Show>
</div>
);
}
export default hookForDev(ReadsSidebar);

View File

@ -37,7 +37,7 @@
position: relative;
display: grid;
height: 100%;
padding-top: 30px;
padding-top: 22px;
justify-content: left;
.overlay {
@ -67,16 +67,16 @@
}
.centerHeader {
width: 600px;
width: var(--center-col-w);
}
.centerContent {
width: 600px;
width: var(--center-col-w);
.headerFloater {
position: fixed;
opacity: 0;
pointer-events: none;
width: 600px;
width: var(--center-col-w);
z-index: var(--z-index-floater);
&.animatedShow {
@ -171,7 +171,7 @@
@media only screen and (max-width: 1300px) {
.container {
width: 1032px;
grid-template-columns: 48px 600px 332px;
grid-template-columns: 48px var(--center-col-w) 332px;
}
.leftColumn {
@ -199,7 +199,7 @@
@media only screen and (max-width: 1087px) {
.container {
width: 720px;
grid-template-columns: 48px 600px 1px;
grid-template-columns: 48px var(--center-col-w) 1px;
}
.rightColumn {

View File

@ -21,6 +21,7 @@ import NoteContextMenu from '../Note/NoteContextMenu';
import LnQrCodeModal from '../LnQrCodeModal/LnQrCodeModal';
import ConfirmModal from '../ConfirmModal/ConfirmModal';
import CashuQrCodeModal from '../CashuQrCodeModal/CashuQrCodeModal';
import SubscribeToAuthorModal from '../SubscribeToAuthorModal/SubscribeToAuthorModal';
export const [isHome, setIsHome] = createSignal(false);
@ -191,6 +192,12 @@ const Layout: Component = () => {
onConfirm={app?.confirmInfo?.onConfirm}
onAbort={app?.confirmInfo?.onAbort}
/>
<SubscribeToAuthorModal
author={app?.subscribeToAuthor}
onClose={app?.actions.closeAuthorSubscribeModal}
onSubscribe={app?.subscribeToTier}
/>
</div>
</Show>
</div>

View File

@ -1,7 +1,9 @@
@mixin iconNav {
@mixin iconNav($url, $color) {
width: 24px;
height: 24px;
background-color: var(--text-secondary);
background-color: $color;
-webkit-mask: $url no-repeat center;
mask: $url no-repeat center;
}
.navLink {
@ -13,14 +15,27 @@
border-radius: 0px;
display: flex;
justify-content: flex-start;
align-items: center;
width: 158px;
a {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 16px;
>div {
background-color: var(--text-secondary);
}
.label {
margin-left: 16px;
font-size: 18px;
font-weight: 600;
line-height: 20px;
display: flex;
color: var(--text-secondary);
background: none;
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
&:focus {
@ -28,85 +43,71 @@
}
}
p {
display: inline;
text-transform: uppercase;
font-size: 18px;
line-height: 20px;
font-weight: 800;
.homeIcon {
@include iconNav(url(../../assets/icons/nav/home.svg), var(--text-secondary));
}
}
.homeIcon {
@include iconNav;
-webkit-mask: url(../../assets/icons/home.svg) no-repeat center;
mask: url(../../assets/icons/home.svg) no-repeat center;
}
.exploreIcon {
@include iconNav;
-webkit-mask: url(../../assets/icons/search.svg) no-repeat center;
mask: url(../../assets/icons/search.svg) no-repeat center;
}
.messagesIcon {
@include iconNav;
-webkit-mask: url(../../assets/icons/messages.svg) no-repeat center;
mask: url(../../assets/icons/messages.svg) no-repeat center;
}
.notificationsIcon {
@include iconNav;
-webkit-mask: url(../../assets/icons/notifications.svg) no-repeat center;
mask: url(../../assets/icons/notifications.svg) no-repeat center;
}
.downloadIcon {
@include iconNav;
-webkit-mask: url(../../assets/icons/download.svg) no-repeat center;
mask: url(../../assets/icons/download.svg) no-repeat center;
}
.settingsIcon {
@include iconNav;
-webkit-mask: url(../../assets/icons/settings.svg) no-repeat center;
mask: url(../../assets/icons/settings.svg) no-repeat center;
}
.helpIcon {
@include iconNav;
-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;
justify-content: flex-start;
align-items: flex-end;
background: none;
>div {
background-color: var(--text-primary);
.readsIcon {
@include iconNav(url(../../assets/icons/nav/long.svg), var(--text-secondary));
}
.exploreIcon {
@include iconNav(url(../../assets/icons/nav/search.svg), var(--text-secondary));
}
.messagesIcon {
@include iconNav(url(../../assets/icons/nav/messages.svg), var(--text-secondary));
}
.notificationsIcon {
@include iconNav(url(../../assets/icons/nav/notifications.svg), var(--text-secondary));
}
.downloadIcon {
@include iconNav(url(../../assets/icons/nav/downloads.svg), var(--text-secondary));
}
.settingsIcon {
@include iconNav(url(../../assets/icons/nav/settings.svg), var(--text-secondary));
}
.bookmarkIcon {
@include iconNav(url(../../assets/icons/nav/bookmarks.svg), var(--text-secondary));
}
.label {
color: var(--text-primary);
background: none;
}
}
.inactive {
display: flex;
justify-content: flex-start;
align-items: flex-end;
&:hover, a.active {
.label {
color: var(--text-primary);
background: none;
// font-weight: 600;
}
>div {
background-color: var(--text-secondary);
.homeIcon {
@include iconNav(url(../../assets/icons/nav/home_selected.svg), var(--text-primary));
}
.readsIcon {
@include iconNav(url(../../assets/icons/nav/long_selected.svg), var(--text-primary));
}
.exploreIcon {
@include iconNav(url(../../assets/icons/nav/search_selected.svg), var(--text-primary));
}
.messagesIcon {
@include iconNav(url(../../assets/icons/nav/messages_selected.svg), var(--text-primary));
}
.notificationsIcon {
@include iconNav(url(../../assets/icons/nav/notifications_selected.svg), var(--text-primary));
}
.downloadIcon {
@include iconNav(url(../../assets/icons/nav/downloads_selected.svg), var(--text-primary));
}
.settingsIcon {
@include iconNav(url(../../assets/icons/nav/settings_selected.svg), var(--text-primary));
}
.bookmarkIcon {
@include iconNav(url(../../assets/icons/nav/bookmarks_selected.svg), var(--text-primary));
}
}
.label {
color: var(--text-secondary);
background: none;
a.active {
.label {
color: var(--text-primary);
background: none;
font-weight: 600;
}
}
}
@ -114,10 +115,11 @@
display: flex;
justify-content: center;
align-items: center;
width: 18px;
min-width: 18px;
height: 18px;
margin-left: 2px;
margin-top: -8px;
font-family: 'Roboto Condensed';
font-weight: 500;

View File

@ -27,6 +27,11 @@ const NavMenu: Component< { id?: string } > = (props) => {
label: intl.formatMessage(t.home),
icon: 'homeIcon',
},
{
to: '/reads',
label: intl.formatMessage(t.reads),
icon: 'readsIcon',
},
{
to: '/explore',
label: intl.formatMessage(t.explore),
@ -63,12 +68,6 @@ const NavMenu: Component< { id?: string } > = (props) => {
hiddenOnSmallScreens: true,
bubble: () => account?.sec ? 1 : 0,
},
{
to: '/help',
label: intl.formatMessage(t.help),
icon: 'helpIcon',
hiddenOnSmallScreens: true,
},
];
const isBigScreen = () => (media?.windowSize.w || 0) > 1300;

View File

@ -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, NostrStatsContent, NostrUserContent, PrimalArticle, PrimalNote, PrimalUser, SendNoteResult } from "../../../types/primal";
import { debounce, getScreenCordinates, isVisibleInContainer, uuidv4 } from "../../../utils";
import Avatar from "../../Avatar/Avatar";
import EmbeddedNote from "../../EmbeddedNote/EmbeddedNote";
@ -52,7 +52,7 @@ type AutoSizedTextArea = HTMLTextAreaElement & { _baseScrollHeight: number };
const EditBox: Component<{
id?: string,
replyToNote?: PrimalNote,
replyToNote?: PrimalNote | PrimalArticle,
onClose?: () => void,
onSuccess?: (note: SendNoteResult) => void,
open?: boolean,
@ -587,8 +587,8 @@ const EditBox: Component<{
createEffect(() => {
if (props.open) {
const draft = readNoteDraft(account?.publicKey, props.replyToNote?.post.noteId);
const draftUserRefs = readNoteDraftUserRefs(account?.publicKey, props.replyToNote?.post.noteId);
const draft = readNoteDraft(account?.publicKey, props.replyToNote?.noteId);
const draftUserRefs = readNoteDraftUserRefs(account?.publicKey, props.replyToNote?.noteId);
setUserRefs(reconcile(draftUserRefs));
@ -618,8 +618,8 @@ const EditBox: Component<{
if (message().length === 0) return;
// save draft just in case there is an unintended interuption
saveNoteDraft(account?.publicKey, message(), props.replyToNote?.post.noteId);
saveNoteDraftUserRefs(account?.publicKey, userRefs, props.replyToNote?.post.noteId);
saveNoteDraft(account?.publicKey, message(), props.replyToNote?.noteId);
saveNoteDraftUserRefs(account?.publicKey, userRefs, props.replyToNote?.noteId);
});
const onEscape = (e: KeyboardEvent) => {
@ -667,8 +667,8 @@ const EditBox: Component<{
return;
}
saveNoteDraft(account?.publicKey, '', props.replyToNote?.post.noteId);
saveNoteDraftUserRefs(account?.publicKey, {}, props.replyToNote?.post.noteId);
saveNoteDraft(account?.publicKey, '', props.replyToNote?.noteId);
saveNoteDraftUserRefs(account?.publicKey, {}, props.replyToNote?.noteId);
clearEditor();
};
@ -680,8 +680,8 @@ const EditBox: Component<{
};
const persistNote = (note: string) => {
saveNoteDraft(account?.publicKey, note, props.replyToNote?.post.noteId);
saveNoteDraftUserRefs(account?.publicKey, userRefs, props.replyToNote?.post.noteId);
saveNoteDraft(account?.publicKey, note, props.replyToNote?.noteId);
saveNoteDraftUserRefs(account?.publicKey, userRefs, props.replyToNote?.noteId);
clearEditor();
};
@ -721,10 +721,10 @@ const EditBox: Component<{
const rep = props.replyToNote;
if (rep) {
let rootTag = rep.post.tags.find(t => t[0] === 'e' && t[3] === 'root');
let rootTag = rep.msg.tags.find(t => t[0] === 'e' && t[3] === 'root');
const rHints = (rep.post.relayHints && rep.post.relayHints[rep.post.id]) ?
rep.post.relayHints[rep.post.id] :
const rHints = (rep.relayHints && rep.relayHints[rep.id]) ?
rep.relayHints[rep.id] :
'';
// If the note has a root tag, that meens it is not a root note itself
@ -735,26 +735,26 @@ const EditBox: Component<{
v,
);
tags.push([...tagWithHint]);
tags.push(['e', rep.post.id, rHints, 'reply']);
tags.push(['e', rep.id, rHints, 'reply']);
}
// Otherwise, add the note as the root tag for this reply
else {
tags.push([
'e',
rep.post.id,
rep.id,
rHints,
'root',
]);
}
// Copy all `p` tags from the note we are repling to
const repPeople = rep.post.tags.filter(t => t[0] === 'p');
const repPeople = rep.msg.tags.filter(t => t[0] === 'p');
tags = [...tags, ...(unwrap(repPeople))];
// If the author of the note is missing, add them
if (!tags.find(t => t[0] === 'p' && t[1] === rep.post.pubkey)) {
tags.push(['p', rep.post.pubkey]);
if (!tags.find(t => t[0] === 'p' && t[1] === rep.pubkey)) {
tags.push(['p', rep.pubkey]);
}
}
@ -772,7 +772,7 @@ const EditBox: Component<{
toast?.sendSuccess(intl.formatMessage(tToast.publishNoteSuccess));
props.onSuccess && props.onSuccess({ success, reasons, note });
setIsPostingInProgress(false);
saveNoteDraft(account.publicKey, '', rep?.post.noteId)
saveNoteDraft(account.publicKey, '', rep?.noteId)
clearEditor();
}
unsub();
@ -1046,7 +1046,7 @@ const EditBox: Component<{
// // setNoteRefs((refs) => ({
// // ...refs,
// // [newNote.post.noteId]: newNote
// // [newNote.noteId]: newNote
// // }));
subUserRef(hex);
@ -1107,10 +1107,10 @@ const EditBox: Component<{
setNoteRefs((refs) => ({
...refs,
[newNote.post.noteId]: newNote
[newNote.noteId]: newNote
}));
subNoteRef(newNote.post.noteId);
subNoteRef(newNote.noteId);
unsub();
return;

View File

@ -1,4 +1,115 @@
.note {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 20px;
margin: 0px;
text-decoration: none;
color: unset;
background-color: var(--background-site);
border: none;
border-bottom: 1px solid var(--devider);
border-radius: 0;
-webkit-user-select: text;
-moz-select: text;
-ms-select: text;
user-select: text;
width: 100%;
&.parent {
border: none;
}
&.reactionNote {
background: none;
border-bottom: 1px solid var(--subtile-devider);
}
.userHeader {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
gap: 8px;
}
.header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.repostedBy {
padding-bottom: 8px;
display: flex;
>span {
>a {
margin-inline: 5px;
color: var(--text-tertiary);
text-decoration: none;
}
color: var(--text-tertiary);
font-size: 14px;
line-height: 14px;
font-weight: 400;
>span {
text-transform: lowercase;
}
}
}
}
.message {
color: var(--text-primary);
word-break: break-word;
font-size: 16px;
font-weight: 400;
line-height: 20px;
margin-top: 4px;
margin-bottom: 12px;
width: 100%;
overflow: hidden;
}
.replyingTo {
display: flex;
font-size: 15px;
font-weight: 400;
line-height: 20px;
margin-top: 6px;
max-width: 518px;
text-overflow: ellipsis;
white-space: nowrap;
.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);
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
&:hover {
text-decoration: underline;
}
}
}
}
.noteThread {
position: relative;
display: flex;
flex-direction: column;
@ -250,12 +361,16 @@
background-color: var(--background-card);
display: flex;
flex-direction: column;
padding-inline: 12px;
padding-inline: 0px;
padding-top: 0;
padding-bottom: 12px;
border-radius: 0;
border: none;
.header {
padding-inline: 12px;
}
.content {
grid-area: content;
display: flex;
@ -275,6 +390,7 @@
line-height: 24px;
width: 100%;
margin-bottom: 12px;
padding-inline: 12px;
a:hover {
text-decoration: underline;
@ -295,6 +411,7 @@
.time {
padding-block: 20px;
padding-inline: 12px;
border-bottom: 1px solid var(--devider);
margin-bottom: 16px;
color: var(--text-tertiary);
@ -334,97 +451,104 @@
}
}
.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);
}
}
}
.topZaps {
padding-inline: 12px;
}
.footer {
padding-inline: 12px;
}
}
}
.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);
}
}
}
}
.zapHighlightsCompact {
display: flex;
flex-wrap: wrap;

View File

@ -49,7 +49,7 @@ const Note: Component<{
id?: string,
parent?: boolean,
shorten?: boolean,
noteType?: 'feed' | 'primary' | 'notification' | 'reaction'
noteType?: 'feed' | 'primary' | 'notification' | 'reaction' | 'thread'
onClick?: () => void,
quoteCount?: number,
}> = (props) => {
@ -276,7 +276,10 @@ const Note: Component<{
<div class={styles.noteNotifications}>
<div class={styles.content}>
<div class={styles.message}>
<ParsedNote note={props.note} shorten={true} />
<ParsedNote
note={props.note}
shorten={true}
/>
</div>
<div class={styles.footer}>
@ -285,6 +288,7 @@ const Note: Component<{
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
size="short"
/>
</div>
</div>
@ -301,7 +305,9 @@ const Note: Component<{
>
<div class={styles.border}></div>
<NoteHeader note={props.note} primary={true} />
<div class={styles.header}>
<NoteHeader note={props.note} primary={true} />
</div>
<div class={styles.upRightFloater}>
<NoteContextTrigger
@ -313,14 +319,20 @@ const Note: Component<{
<div class={styles.content}>
<div class={styles.message}>
<ParsedNote note={props.note} width={Math.min(574, window.innerWidth)} />
<ParsedNote
note={props.note}
width={Math.min(640, window.innerWidth)}
margins={12}
/>
</div>
<NoteTopZaps
topZaps={reactionsState.topZaps}
zapCount={reactionsState.zapCount}
action={() => openReactionModal('zaps')}
/>
<div class={styles.topZaps}>
<NoteTopZaps
topZaps={reactionsState.topZaps}
zapCount={reactionsState.zapCount}
action={() => openReactionModal('zaps')}
/>
</div>
<div
class={styles.time}
@ -337,21 +349,22 @@ const Note: Component<{
</button>
</div>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
wide={true}
large={true}
onZapAnim={addTopZap}
/>
<div class={styles.footer}>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
size="wide"
large={true}
onZapAnim={addTopZap}
/>
</div>
</div>
</div>
</Match>
<Match when={noteType() === 'feed'}>
<A
id={props.id}
class={`${styles.note} ${props.parent ? styles.parent : ''}`}
@ -360,6 +373,67 @@ const Note: Component<{
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.userHeader}>
<A href={`/p/${props.note.user.npub}`}>
<Avatar user={props.note.user} size="vs" />
</A>
<NoteAuthorInfo
author={props.note.user}
time={props.note.post.created_at}
/>
<div class={styles.upRightFloater}>
<NoteContextTrigger
ref={noteContextMenu}
onClick={onContextMenuTrigger}
/>
</div>
</div>
<NoteReplyToHeader note={props.note} />
<div class={styles.message}>
<ParsedNote
note={props.note}
shorten={props.shorten}
width={Math.min(640, window.innerWidth - 72)}
margins={20}
/>
</div>
<NoteTopZapsCompact
note={props.note}
action={() => openReactionModal('zaps')}
topZaps={reactionsState.topZapsFeed}
topZapLimit={4}
/>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
onZapAnim={addTopZapFeed}
/>
</A>
</Match>
<Match when={noteType() === 'thread'}>
<A
id={props.id}
class={`${styles.noteThread} ${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()}>
@ -397,7 +471,8 @@ const Note: Component<{
<ParsedNote
note={props.note}
shorten={props.shorten}
width={Math.min(528, window.innerWidth - 72)}
width={Math.min(566, window.innerWidth - 72)}
margins={1}
/>
</div>
@ -408,13 +483,16 @@ const Note: Component<{
topZapLimit={4}
/>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
onZapAnim={addTopZapFeed}
/>
<div class={styles.footer}>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
onZapAnim={addTopZapFeed}
size="short"
/>
</div>
</div>
</div>
</A>
@ -455,6 +533,7 @@ const Note: Component<{
note={props.note}
shorten={props.shorten}
width={Math.min(528, window.innerWidth - 72)}
margins={12}
noLightbox={true}
altEmbeds={true}
/>

View File

@ -60,12 +60,12 @@ const NoteContextMenu: Component<{
const doMuteUser = () => {
account?.actions.addToMuteList(note()?.post.pubkey);
account?.actions.addToMuteList(note()?.pubkey);
props.onClose();
};
const doUnmuteUser = () => {
account?.actions.removeFromMuteList(note()?.post.pubkey);
account?.actions.removeFromMuteList(note()?.pubkey);
props.onClose();
};
@ -77,21 +77,21 @@ const NoteContextMenu: Component<{
const copyNoteLink = () => {
if (!props.data) return;
navigator.clipboard.writeText(`${window.location.origin}/e/${note().post.noteId}`);
navigator.clipboard.writeText(`${window.location.origin}/e/${note().noteId}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.notePrimalLinkCoppied));
};
const copyNoteText = () => {
if (!props.data) return;
navigator.clipboard.writeText(`${note().post.content}`);
navigator.clipboard.writeText(`${note().content}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.notePrimalTextCoppied));
};
const copyNoteId = () => {
if (!props.data) return;
navigator.clipboard.writeText(`${note().post.noteId}`);
navigator.clipboard.writeText(`${note().noteId}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.noteIdCoppied));
};
@ -128,7 +128,7 @@ const NoteContextMenu: Component<{
const onClickOutside = (e: MouseEvent) => {
if (
!props.data ||
!document?.getElementById(`note_context_${note().post.id}`)?.contains(e.target as Node)
!document?.getElementById(`note_context_${note().id}`)?.contains(e.target as Node)
) {
props.onClose()
}
@ -222,7 +222,7 @@ const NoteContextMenu: Component<{
];
};
const noteContext = () => account?.publicKey !== note()?.post.pubkey ?
const noteContext = () => account?.publicKey !== note()?.pubkey ?
[ ...noteContextForEveryone, ...noteContextForOtherPeople()] :
noteContextForEveryone;
@ -251,7 +251,7 @@ const NoteContextMenu: Component<{
/>
<PrimalMenu
id={`note_context_${note()?.post.id}`}
id={`note_context_${note()?.id}`}
items={noteContext()}
hidden={!props.open}
position="note_footer"

View File

@ -0,0 +1,418 @@
import { batch, Component, createEffect, Show } from 'solid-js';
import { MenuItem, PrimalArticle, PrimalNote, ZapOption } from '../../../types/primal';
import { sendArticleRepost, sendRepost, triggerImportEvents } from '../../../lib/notes';
import styles from './NoteFooter.module.scss';
import { useAccountContext } from '../../../contexts/AccountContext';
import { useToastContext } from '../../Toaster/Toaster';
import { useIntl } from '@cookbook/solid-intl';
import { truncateNumber } from '../../../lib/notifications';
import { canUserReceiveZaps, zapArticle, zapNote } from '../../../lib/zap';
import { useSettingsContext } from '../../../contexts/SettingsContext';
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 { getScreenCordinates } from '../../../utils';
import ZapAnimation from '../../ZapAnimation/ZapAnimation';
import { CustomZapInfo, useAppContext } from '../../../contexts/AppContext';
import ArticleFooterActionButton from './ArticleFooterActionButton';
import { NoteReactionsState } from '../Note';
import { SetStoreFunction } from 'solid-js/store';
import BookmarkNote from '../../BookmarkNote/BookmarkNote';
import BookmarkArticle from '../../BookmarkNote/BookmarkArticle';
export const lottieDuration = () => zapMD.op * 1_000 / zapMD.fr;
const ArticleFooter: Component<{
note: PrimalArticle,
size?: 'wide' | 'normal' | 'short',
id?: string,
state: NoteReactionsState,
updateState: SetStoreFunction<NoteReactionsState>,
customZapInfo: CustomZapInfo,
large?: boolean,
onZapAnim?: (zapOption: ZapOption) => void,
}> = (props) => {
const account = useAccountContext();
const toast = useToastContext();
const intl = useIntl();
const settings = useSettingsContext();
const app = useAppContext();
let medZapAnimation: HTMLElement | undefined;
let quickZapDelay = 0;
let footerDiv: HTMLDivElement | undefined;
let repostMenu: HTMLDivElement | undefined;
const size = () => props.size ?? 'normal';
const repostMenuItems: MenuItem[] = [
{
action: () => doRepost(),
label: 'Repost Note',
icon: 'feed_repost',
},
{
action: () => doQuote(),
label: 'Quote Note',
icon: 'quote',
},
];
const onClickOutside = (e: MouseEvent) => {
if (
!document?.getElementById(`repost_menu_${props.note.id}`)?.contains(e.target as Node)
) {
props.updateState('isRepostMenuVisible', () => false);
}
}
createEffect(() => {
if (props.state.isRepostMenuVisible) {
document.addEventListener('click', onClickOutside);
}
else {
document.removeEventListener('click', onClickOutside);
}
});
const showRepostMenu = (e: MouseEvent) => {
e.preventDefault();
props.updateState('isRepostMenuVisible', () => true);
};
const doQuote = () => {
if (!account?.hasPublicKey()) {
account?.actions.showGetStarted();
return;
}
props.updateState('isRepostMenuVisible', () => false);
account?.actions?.quoteNote(`nostr:${props.note.naddr}`);
account?.actions?.showNewNoteForm();
};
const doRepost = async () => {
if (!account) {
return;
}
if (!account.hasPublicKey()) {
account.actions.showGetStarted();
return;
}
if (account.relays.length === 0) {
toast?.sendWarning(
intl.formatMessage(t.noRelaysConnected),
);
return;
}
props.updateState('isRepostMenuVisible', () => false);
const { success } = await sendArticleRepost(props.note, account.relays, account.relaySettings);
if (success) {
batch(() => {
props.updateState('reposts', (r) => r + 1);
props.updateState('reposted', () => true);
});
toast?.sendSuccess(
intl.formatMessage(t.repostSuccess),
);
}
else {
toast?.sendWarning(
intl.formatMessage(t.repostFailed),
);
}
};
const doReply = () => {};
const doLike = async (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!account) {
return;
}
if (!account.hasPublicKey()) {
account.actions.showGetStarted();
return;
}
if (account.relays.length === 0) {
toast?.sendWarning(
intl.formatMessage(t.noRelaysConnected),
);
return;
}
const success = await account.actions.addLike(props.note);
if (success) {
batch(() => {
props.updateState('likes', (l) => l + 1);
props.updateState('liked', () => true);
});
}
};
const startZap = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
e.stopPropagation();
if (!account?.hasPublicKey()) {
account?.actions.showGetStarted();
props.updateState('isZapping', () => false);
return;
}
if (account.relays.length === 0) {
toast?.sendWarning(
intl.formatMessage(t.noRelaysConnected),
);
return;
}
if (!canUserReceiveZaps(props.note.user)) {
toast?.sendWarning(
intl.formatMessage(t.zapUnavailable),
);
props.updateState('isZapping', () => false);
return;
}
quickZapDelay = setTimeout(() => {
app?.actions.openCustomZapModal(props.customZapInfo);
props.updateState('isZapping', () => true);
}, 500);
};
const commitZap = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
e.stopPropagation();
clearTimeout(quickZapDelay);
if (!account?.hasPublicKey()) {
account?.actions.showGetStarted();
return;
}
if (account.relays.length === 0 || !canUserReceiveZaps(props.note.user)) {
return;
}
if (app?.customZap === undefined) {
doQuickZap();
}
};
const animateZap = () => {
setTimeout(() => {
props.updateState('hideZapIcon', () => true);
if (!medZapAnimation) {
return;
}
let newLeft = 33;
let newTop = -6;
if (size() === 'wide' && props.large) {
newLeft = 14;
newTop = -10;
}
if (size() === 'short') {
newLeft = 14;
newTop = -6;
}
medZapAnimation.style.left = `${newLeft}px`;
medZapAnimation.style.top = `${newTop}px`;
const onAnimDone = () => {
batch(() => {
props.updateState('showZapAnim', () => false);
props.updateState('hideZapIcon', () => false);
props.updateState('zapped', () => true);
});
medZapAnimation?.removeEventListener('complete', onAnimDone);
}
medZapAnimation.addEventListener('complete', onAnimDone);
try {
// @ts-ignore
medZapAnimation.seek(0);
// @ts-ignore
medZapAnimation.play();
} catch (e) {
console.warn('Failed to animte zap:', e);
onAnimDone();
}
}, 10);
};
const doQuickZap = async () => {
if (!account?.hasPublicKey()) {
account?.actions.showGetStarted();
return;
}
const amount = settings?.defaultZap.amount || 10;
const message = settings?.defaultZap.message || '';
const emoji = settings?.defaultZap.emoji;
batch(() => {
props.updateState('isZapping', () => true);
props.updateState('satsZapped', (z) => z + amount);
props.updateState('showZapAnim', () => true);
});
props.onZapAnim && props.onZapAnim({ amount, message, emoji })
setTimeout(async () => {
const success = await zapArticle(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());
}
const buttonTypeClasses: Record<string, string> = {
zap: styles.zapType,
like: styles.likeType,
reply: styles.replyType,
repost: styles.repostType,
};
createEffect(() => {
if (props.state.showZapAnim) {
animateZap();
}
});
const determineOrient = () => {
const coor = getScreenCordinates(repostMenu);
const height = 100;
return (coor.y || 0) + height < window.innerHeight + window.scrollY ? 'down' : 'up';
}
return (
<div
id={props.id}
class={`${styles.footer} ${styles[size()]}`}
ref={footerDiv}
onClick={(e) => {e.preventDefault();}}
>
<Show when={props.state.showZapAnim}>
<ZapAnimation
id={`note-med-zap-${props.note.id}`}
src={zapMD}
class={props.large ? styles.largeZapLottie : styles.mediumZapLottie}
ref={medZapAnimation}
/>
</Show>
<ArticleFooterActionButton
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}
/>
<ArticleFooterActionButton
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}
/>
<ArticleFooterActionButton
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.id}`}
class={`${styles.stat} ${props.state.reposted ? styles.highlighted : ''}`}
onClick={showRepostMenu}
title={props.state.reposts.toLocaleString()}
>
<div
class={`${buttonTypeClasses.repost}`}
ref={repostMenu}
>
<div
class={`${styles.icon} ${props.large ? styles.large : ''}`}
style={'visibility: visible'}
></div>
<div class={styles.statNumber}>
{props.state.reposts === 0 ? '' : truncateNumber(props.state.reposts, 2)}
</div>
<PrimalMenu
id={`repost_menu_${props.note.id}`}
items={repostMenuItems}
position="note_footer"
orientation={determineOrient()}
hidden={!props.state.isRepostMenuVisible}
/>
</div>
</button>
<div class={styles.bookmarkFoot}>
<BookmarkArticle
note={props.note}
large={props.large}
/>
</div>
</div>
)
}
export default hookForDev(ArticleFooter);

View File

@ -0,0 +1,51 @@
import { Component, createEffect, onCleanup } from 'solid-js';
import { PrimalArticle, 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 ArticleFooterActionButton: Component<{
type: 'zap' | 'like' | 'reply' | 'repost',
note: PrimalArticle,
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.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 ArticleFooterActionButton;

View File

@ -55,12 +55,38 @@
.footer {
display: grid;
grid-template-columns: 125px 125px 125px 125px auto;
grid-template-columns: 145px 145px 145px 145px auto;
position: relative;
width: 100%;
.bookmarkFoot {
display: flex;
justify-content: flex-end;
max-width: 20px;
}
&.wide {
grid-template-columns: 137px 137px 137px 135px auto;
grid-template-columns: 148px 148px 148px 148px auto;
.bookmarkFoot {
display: flex;
justify-content: flex-end;
max-width: 100%;
}
}
&.short {
grid-template-columns: 126px 126px 126px 126px auto;
.bookmarkFoot {
display: flex;
justify-content: flex-end;
max-width: 100%;
}
}
&.normal {
width: 100%;
}
.context {
@ -172,10 +198,6 @@
}
}
.bookmarkFoot {
display: flex;
justify-content: flex-end;
}
}
.largeZapLottie {

View File

@ -27,7 +27,7 @@ export const lottieDuration = () => zapMD.op * 1_000 / zapMD.fr;
const NoteFooter: Component<{
note: PrimalNote,
wide?: boolean,
size?: 'wide' | 'normal' | 'short',
id?: string,
state: NoteReactionsState,
updateState: SetStoreFunction<NoteReactionsState>,
@ -48,6 +48,8 @@ const NoteFooter: Component<{
let footerDiv: HTMLDivElement | undefined;
let repostMenu: HTMLDivElement | undefined;
const size = () => props.size ?? 'normal';
const repostMenuItems: MenuItem[] = [
{
action: () => doRepost(),
@ -61,6 +63,7 @@ const NoteFooter: Component<{
},
];
const onClickOutside = (e: MouseEvent) => {
if (
!document?.getElementById(`repost_menu_${props.note.post.id}`)?.contains(e.target as Node)
@ -222,12 +225,17 @@ const NoteFooter: Component<{
return;
}
let newLeft = props.wide ? 15 : 13;
let newTop = props.wide ? -6 : -6;
let newLeft = 33;
let newTop = -6;
if (props.large) {
newLeft = 2;
newTop = -9;
if (size() === 'wide' && props.large) {
newLeft = 14;
newTop = -10;
}
if (size() === 'short') {
newLeft = 14;
newTop = -6;
}
medZapAnimation.style.left = `${newLeft}px`;
@ -318,7 +326,12 @@ const NoteFooter: Component<{
}
return (
<div id={props.id} class={`${styles.footer} ${props.wide ? styles.wide : ''}`} ref={footerDiv} onClick={(e) => {e.preventDefault();}}>
<div
id={props.id}
class={`${styles.footer} ${styles[size()]}`}
ref={footerDiv}
onClick={(e) => e.preventDefault() }
>
<Show when={props.state.showZapAnim}>
<ZapAnimation
@ -395,6 +408,7 @@ const NoteFooter: Component<{
<BookmarkNote
note={props.note}
large={props.large}
right={true}
/>
</div>

View File

@ -4,13 +4,14 @@ import { useThreadContext } from "../../contexts/ThreadContext";
import Avatar from "../Avatar/Avatar";
import { TransitionGroup } from 'solid-transition-group';
import styles from "./Note.module.scss";
import { TopZap } from "../../types/primal";
import { PrimalUser, TopZap } from "../../types/primal";
const NoteTopZaps: Component<{
topZaps: TopZap[],
zapCount: number,
action: () => void,
id?: string,
users?: PrimalUser[]
}> = (props) => {
const threadContext = useThreadContext();
@ -42,27 +43,27 @@ const NoteTopZaps: Component<{
}
const zapSender = (zap: TopZap) => {
return threadContext?.users.find(u => u.pubkey === zap.pubkey);
return (props.users || 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>
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>
}
>
}
>
<div class={`${styles.zapHighlights}`}>
<TransitionGroup
name="top-zaps"

View File

@ -6,7 +6,7 @@
grid-template-areas: "content";
padding: 0px 16px 16px 0px;
border-radius: 4px;
// width: 600px;
// width: var(--center-col-w);
// pointer-events: none;
.content {

View File

@ -147,6 +147,7 @@ const ParsedNote: Component<{
shorten?: boolean,
isEmbeded?: boolean,
width?: number,
margins?: number,
noLightbox?: boolean,
altEmbeds?: boolean,
}> = (props) => {
@ -619,9 +620,10 @@ const ParsedNote: Component<{
let w: number | undefined = undefined;
if (mVideo) {
const margins = props.margins || 20
const ratio = mVideo.w / mVideo.h;
h = (noteWidth() / ratio);
w = h > 680 ? 680 * ratio : noteWidth();
h = ((noteWidth() - 2*margins) / ratio);
w = h > 680 ? 680 * ratio : noteWidth() - 2*margins;
h = h > 680 ? 680 : h;
}

View File

@ -0,0 +1,237 @@
.primalMarkdown {
width: 100%;
.toolbar {
display: flex;
gap: 8px;
}
.editor {
margin-block: 8px;
p {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-top: 0;
margin-bottom: 20px;
}
h1 {
color: var(--text-primary);
font-size: 32px;
font-weight: 700;
line-height: 40px;
margin-top: 10px;
margin-bottom: 10px;
}
h2 {
color: var(--text-primary);
font-size: 28px;
font-weight: 700;
line-height: 36px;
margin-top: 10px;
margin-bottom: 10px;
}
h3 {
color: var(--text-primary);
font-size: 24px;
font-weight: 700;
line-height: 32px;
margin-top: 10px;
margin-bottom: 10px;
}
h4 {
color: var(--text-primary);
font-size: 22px;
font-weight: 700;
line-height: 30px;
margin-top: 10px;
margin-bottom: 10px;
}
h5 {
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
margin-top: 10px;
margin-bottom: 10px;
}
h6 {
color: var(--text-primary);
font-size: 18px;
font-weight: 700;
line-height: 26px;
margin-top: 10px;
margin-bottom: 10px;
}
hr {
border-top:1px solid var(--subtile-devider);
margin-top: 10px;
margin-bottom: 20px;
}
ul {
margin-left: 0px;
padding-left: 14px;
margin-bottom: 20px;
li {
&::marker {
content: '';
}
padding-left: 8px;
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-top: 0;
margin-bottom: 12px;
ul {
padding-left: 30px;
}
}
}
ol {
margin-left: 0px;
padding-left: 24px;
margin-bottom: 20px;
li {
padding-left: 8px;
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-top: 0;
margin-bottom: 12px;
ol {
padding-left: 40px;
}
}
}
dl {
margin-left: 0px;
padding-left: 14px;
margin-bottom: 20px;
dt {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
dd {
padding-left: 8px;
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-top: 0;
margin-bottom: 12px;
dl {
padding-left: 30px;
}
}
}
img {
margin-top: 0;
margin-bottom: 20px;
border-radius: 4px;
overflow: hidden;
}
img + sup {
display: block;
margin-top: -6px;
}
blockquote {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
padding-left: 12px;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 20px;
border-left: 4px solid var(--text-tertiary);
}
a {
color: var(--accent-links);
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
pre, code, mark {
background-color: var(--background-input);
}
code {
color: var(--text-primary);
font-family: "Fira Mono", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
ins {
color: var(--warning-bright);
}
del {
color: inherit;
}
table {
th {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 24px;
border-bottom: 1px solid var(--text-secondary);
padding: 12px 8px;
* {
margin-bottom: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
}
td {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
border-bottom: 1px solid var(--subtile-devider);
padding: 12px 8px;
* {
margin-bottom: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
}
}
}
}

View File

@ -0,0 +1,264 @@
import { Component, createEffect, createSignal, For, lazy, Match, onCleanup, onMount, Show, Suspense, Switch } from 'solid-js';
import { editorViewOptionsCtx, Editor, rootCtx } from '@milkdown/core';
import {
commonmark,
toggleStrongCommand,
toggleEmphasisCommand,
} from '@milkdown/preset-commonmark';
import {
gfm,
insertTableCommand,
} from '@milkdown/preset-gfm';
import { callCommand, getMarkdown, replaceAll, insert, getHTML, outline } from '@milkdown/utils';
import { history, undoCommand, redoCommand } from '@milkdown/plugin-history';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import styles from './PrimalMarkdown.module.scss';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonGhost from '../Buttons/ButtonGhost';
import { Ctx } from '@milkdown/ctx';
import { npubToHex } from '../../lib/keys';
import { subscribeTo } from '../../sockets';
import { APP_ID } from '../../App';
import { getUserProfileInfo } from '../../lib/profile';
import { useAccountContext } from '../../contexts/AccountContext';
import { Kind } from '../../constants';
import { PrimalArticle, PrimalNote, PrimalUser } from '../../types/primal';
import { convertToUser, userName } from '../../stores/profile';
import { A } from '@solidjs/router';
import { createStore } from 'solid-js/store';
import { nip19 } from 'nostr-tools';
import { fetchNotes } from '../../handleNotes';
import { logError } from '../../lib/logger';
import EmbeddedNote from '../EmbeddedNote/EmbeddedNote';
import NoteImage from '../NoteImage/NoteImage';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
const PrimalMarkdown: Component<{
id?: string,
content?: string,
readonly?: boolean,
noteId: string,
}> = (props) => {
const account = useAccountContext();
let ref: HTMLDivElement | undefined;
let editor: Editor;
const [userMentions, setUserMentions] = createStore<Record<string, PrimalUser>>({});
const [noteMentions, setNoteMentions] = createStore<Record<string, PrimalNote>>({});
const id = () => {
return `note_${props.noteId}`;
}
const lightbox = new PhotoSwipeLightbox({
gallery: `#${id()}`,
children: `a.image_${props.noteId}`,
showHideAnimationType: 'zoom',
initialZoomLevel: 'fit',
secondaryZoomLevel: 2,
maxZoomLevel: 3,
pswpModule: () => import('photoswipe')
});
onMount(() => {
lightbox.init();
});
const fetchUserInfo = (npub: string) => {
const pubkey = npubToHex(npub);
const subId = `lf_fui_${APP_ID}`;
let user: PrimalUser;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
setUserMentions(() => ({ [user.npub]: { ...user }}))
return;
}
if (type === 'EVENT') {
if (content?.kind === Kind.Metadata) {
user = convertToUser(content);
}
}
});
getUserProfileInfo(pubkey, account?.publicKey, subId);
}
const fetchNoteInfo = async (npub: string) => {
const noteId = nip19.decode(npub).data;
const subId = `lf_fni_${APP_ID}`;
try {
const notes = await fetchNotes(account?.publicKey, [noteId], subId);
if (notes.length > 0) {
const note = notes[0];
setNoteMentions(() => ({ [note.post.noteId]: { ...note } }))
}
} catch (e) {
logError('Failed to fetch notes: ', e);
}
}
const isMention = (el: Element) => {
const regex = /nostr:([A-z0-9]+)/;
const content = el.innerHTML;
return regex.test(content)
}
const isImg = (el: Element) => {
// @ts-ignore
return el.firstChild?.tagName === 'IMG';
}
const renderImage = (el: Element) => {
const img = el.firstChild as HTMLImageElement;
return <NoteImage
class={`noteimage image_${props.noteId}`}
src={img.src}
/>
}
const renderMention = (el: Element) => {
const regex = /nostr:([A-z0-9]+)/;
const content = el.innerHTML;
const match = content.match(regex);
if (match === null || match.length < 2) return el;
const [nostr, id] = match;
if (id.startsWith('npub')) {
fetchUserInfo(id);
return (
<Show
when={userMentions[id] !== undefined}
fallback={<A href={`/p/${id}`}>{nostr}</A>}
>
<A href={`/p/${id}`}>@{userName(userMentions[id])}</A>
</Show>
);
}
if (id.startsWith('note')) {
fetchNoteInfo(id);
return (
<Show
when={noteMentions[id] !== undefined}
fallback={<A href={`/e/${id}`}>{nostr}</A>}
>
<EmbeddedNote
note={noteMentions[id]}
mentionedUsers={noteMentions[id].mentionedUsers || {}}
/>
</Show>
);
}
return el;
};
const [html, setHTML] = createSignal<string>();
onMount(async () => {
editor = await Editor.make()
.config((ctx) => {
ctx.set(rootCtx, ref);
ctx.update(editorViewOptionsCtx, prev => ({
...prev,
editable: () => !Boolean(props.readonly),
}))
})
.use(commonmark)
.use(gfm)
// .use(emoji)
.use(history)
// .use(userMention)
// .use(copilotPlugin)
// .use(noteMention)
// .use(slash)
// .use(mention)
.create();
insert(props.content || '')(editor.ctx);
setHTML(getHTML()(editor.ctx));
});
onCleanup(() => {
editor.destroy();
});
const htmlArray = () => {
const el = document.createElement('div');
el.innerHTML = html() || '';
return [ ...el.children ];
}
const undo = () => editor?.action(callCommand(redoCommand.key));
const redo = () => editor?.action(callCommand(redoCommand.key));
const bold = () => editor?.action(callCommand(toggleStrongCommand.key));
const italic = () => editor?.action(callCommand(toggleEmphasisCommand.key));
const table = () => editor?.action(callCommand(insertTableCommand.key));
return (
<div class={styles.primalMarkdown}>
<Show when={!(Boolean(props.readonly))}>
<div class={styles.toolbar}>
<ButtonGhost onClick={undo}>Undo</ButtonGhost>
<ButtonGhost onClick={redo}>Redo</ButtonGhost>
<ButtonGhost onClick={bold}>Bold</ButtonGhost>
<ButtonGhost onClick={italic}>Italic</ButtonGhost>
<ButtonGhost onClick={table}>Table</ButtonGhost>
</div>
</Show>
<div ref={ref} class={styles.editor} style="display: none;" />
<div id={id()} class={styles.editor}>
<For each={htmlArray()}>
{el => (
<Switch fallback={<>{el}</>}>
<Match when={isMention(el)}>
{renderMention(el)}
</Match>
<Match when={isImg(el)}>
{renderImage(el)}
</Match>
</Switch>
)}
</For>
</div>
{/* <ButtonPrimary
onClick={() => {
const tele = getMarkdown();
console.log('TELE: ', tele(editor.ctx));
}}
>
Export
</ButtonPrimary> */}
</div>
);
};
export default PrimalMarkdown;

View File

@ -4,7 +4,7 @@
justify-content: space-between;
border-bottom: 1px solid var(--background-input);
padding: 12px;
width: 600px;
width: var(--center-col-w);
.info {
display: flex;

View File

@ -110,7 +110,7 @@
border-bottom: 1px solid var(--background-input);
padding-block: 12px;
padding-inline: 20px;
width: min(600px, 100%);
width: min(ver(--center-col-w), 100%);
text-decoration: none;
.zapInfo {

View File

@ -13,6 +13,7 @@ import { store } from "../../services/StoreService";
import { userName } from "../../stores/profile";
import { profile as t, actions as tActions } from "../../translations";
import { PrimalUser } from "../../types/primal";
import ArticlePreview from "../ArticlePreview/ArticlePreview";
import Avatar from "../Avatar/Avatar";
import ButtonCopy from "../Buttons/ButtonCopy";
import Loader from "../Loader/Loader";
@ -133,6 +134,9 @@ const ProfileTabs: Component<{
setCurrentTab(() => value);
switch(value) {
case 'articles':
profile.articles.length === 0 && profile.actions.fetchArticles(profile.profileKey);
break;
case 'notes':
profile.notes.length === 0 && profile.actions.fetchNotes(profile.profileKey);
break;
@ -161,6 +165,18 @@ const ProfileTabs: Component<{
>
<Tabs.Root onChange={onChangeValue}>
<Tabs.List class={styles.profileTabs}>
<Show when={(profile?.userStats.long_form_note_count || 0) > 0}>
<Tabs.Trigger class={styles.profileTab} value="articles">
<div class={styles.stat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.userStats?.long_form_note_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.articles)}
</div>
</div>
</Tabs.Trigger>
</Show>
<Tabs.Trigger class={styles.profileTab} value="notes">
<div class={styles.stat}>
<div class={styles.statNumber}>
@ -231,6 +247,62 @@ const ProfileTabs: Component<{
<Tabs.Indicator class={styles.profileTabIndicator} />
</Tabs.List>
<Tabs.Content class={styles.tabContent} value="articles">
<div class={styles.profileNotes}>
<Switch
fallback={
<div class={styles.loader}>
<Loader />
</div>
}>
<Match when={isMuted(profile?.profileKey)}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.isMuted,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
<button
onClick={unMuteProfile}
>
{intl.formatMessage(tActions.unmute)}
</button>
</div>
</Match>
<Match when={isFiltered()}>
<div class={styles.mutedProfile}>
{intl.formatMessage(t.isFiltered)}
<button
onClick={addToAllowlist}
>
{intl.formatMessage(tActions.addToAllowlist)}
</button>
</div>
</Match>
<Match when={profile && profile.articles.length === 0 && !profile.isFetching}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.noNotes,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
</div>
</Match>
<Match when={profile && profile.articles.length > 0}>
<For each={profile?.articles}>
{article => (
<ArticlePreview article={article} />
)}
</For>
<Paginator
loadNextPage={() => {
profile?.actions.fetchNextArticlesPage();
}}
isSmall={true}
/>
</Match>
</Switch>
</div>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value="notes">
<div class={styles.profileNotes}>
<Switch

View File

@ -33,6 +33,28 @@
line-height: 20px;
}
.selectionIconBig {
width: 14px;
height: 14px;
display: inline-block;
margin-inline: 8px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/caret.svg) no-repeat 0 / 14px;
mask: url(../../assets/icons/caret.svg) no-repeat 0 / 14px;
}
.triggerBig {
background-color: var(--background-site);
margin: 0;
padding: 0;
border: none;
color: var(--text-secondary);
font-size: 24px;
font-weight: 300;
line-height: 32px;
}
.listbox {
background-color: var(--background-input);
color: var(--text-primary);

View File

@ -18,6 +18,7 @@ const SelectionBox: Component<{
options: SelectionOption[],
onChange: (option: any) => void,
initialValue?: string | SelectionOption,
big?: boolean,
value?: string | SelectionOption,
id?: string,
}> = (props) => {
@ -55,12 +56,12 @@ const SelectionBox: Component<{
onChange={props.onChange}
gutter={8}
>
<Select.Trigger class={styles.trigger}>
<Select.Trigger class={props.big ? styles.triggerBig : styles.trigger}>
<Select.Value<SelectionOption>>
{state => state.selectedOption()?.label || ''}
</Select.Value>
<Select.Icon>
<div class={styles.selectionIcon}></div>
<div class={props.big ? styles.selectionIconBig : styles.selectionIcon}></div>
</Select.Icon>
</Select.Trigger>
<Select.Content>

View File

@ -0,0 +1,290 @@
.subscribeToAuthor {
position: fixed;
min-width: 472px;
min-height: 344px;
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;
}
.userInfo {
display: flex;
gap: 8px;
.userData {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
.userName {
display: flex;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.nip05 {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.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);
}
}
}
.modalBody {
.tiers {
display: flex;
gap: 12px;
min-height: 220px;
.tier {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
border: 1px solid var(--subtile-devider);
border-radius: 8px;
background: none;
margin: 0;
padding: 16px;
width: 400px;
.tierTitle {
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
line-height: 20px;
}
.content {
display: flex;
border-top: 1px solid var(--subtile-devider);
padding-top: 16px;
color: var(--text-secondary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
width: 100%;
overflow: hidden;
}
.perks {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
width: 100%;
.perk {
display: flex;
align-items: center;
gap: 4px;
// &::before {
// content: '';
// }
.text {
color: var(--text-secondary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.checkIcon {
width: 16px;
height: 16px;
display: inline-block;
background-color: var(--success-color);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0 / 80%;
mask: url(../../assets/icons/check.svg) no-repeat 0 / 80%;
&.left {
margin-right: 8px;
}
&.right {
margin-left: 8px;
}
}
}
}
&.selected {
border: 1px solid var(--accent);
}
}
}
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
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;
}
.selectCosts {
}
.selectTrigger {
background-color: var(--background-input);
width: 360px;
border: none;
outline: none;
margin: 0;
padding: 0;
.selectValue {
.cost {
.duration {
display: flex;
gap: 6px;
.chevIcon {
width: 6px;
height: 16px;
background-color: var(--text-tertiary);
-webkit-mask: url(../../assets/icons/chevron_right.svg) no-repeat 0 / 100%;
mask: url(../../assets/icons/chevron_right.svg) no-repeat 0 / 100%;
rotate: 90deg;
}
}
}
}
}
.selectContent {
background-color: var(--background-sheet);
z-index: 9999;
width: 390px;
.selectListbox {
border: 1px solid var(--subtile-devider);
border-radius: 8px;
background-color: var(--background-sheet);
padding: 0;
.cost {
border-radius: 8px;
padding-block: 12px;
padding-inline: 12px;
cursor: pointer;
&:hover {
background-color: var(--background-input);
}
}
}
}
.cost {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.amount {
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
line-height: 20px;
}
.duration {
color: var(--text-secondary);
font-size: 16px;
font-weight: 400;
line-height: 16px;
}
}
.noTiers {
display: flex;
justify-content: center;
width: 100%;
color: var(--text-secondary);
font-size: 16px;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
}

View File

@ -0,0 +1,383 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, For, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Kind } from '../../constants';
import { hookForDev } from '../../lib/devTools';
import { NostrTier, PrimalUser } from '../../types/primal';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import styles from './SubscribeToAuthorModal.module.scss';
import { userName } from '../../stores/profile';
import Avatar from '../Avatar/Avatar';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import { APP_ID } from '../../App';
import { subsTo, subTo } from '../../sockets';
import { getAuthorSubscriptionTiers } from '../../lib/feed';
import ButtonSecondary from '../Buttons/ButtonSecondary';
import { Select } from '@kobalte/core';
import Loader from '../Loader/Loader';
import { logInfo } from '../../lib/logger';
import { getExchangeRate, getMembershipStatus } from '../../lib/membership';
import { useAccountContext } from '../../contexts/AccountContext';
export const satsInBTC = 100_000_000;
export type TierCost = {
amount: string,
unit: string,
cadence: string,
id: string,
}
export type Tier = {
title: string,
content: string,
id: string,
perks: string[],
costs: TierCost[],
activeCost: TierCost | undefined,
client: string,
event: NostrTier,
};
export type TierStore = {
tiers: Tier[],
selectedTier: Tier | undefined,
selectedCost: TierCost | undefined,
isFetchingTiers: boolean,
exchangeRate: Record<string, Record<string, number>>,
}
export const payUnits = ['sats', 'sat', 'msat', 'msats', 'USD', 'usd', ''];
const SubscribeToAuthorModal: Component<{
id?: string,
author: PrimalUser | undefined,
onClose: () => void,
onSubscribe: (tier: Tier, cost: TierCost, exchangeRate?: Record<string, Record<string, number>>) => void,
}> = (props) => {
const account = useAccountContext();
const [store, updateStore] = createStore<TierStore>({
tiers: [],
selectedTier: undefined,
selectedCost: undefined,
isFetchingTiers: false,
exchangeRate: {},
});
let walletSocket: WebSocket | undefined;
createEffect(() => {
const author = props.author;
if (author) {
getTiers(author);
}
});
createEffect(() => {
if (props.author && (!walletSocket || walletSocket.readyState === WebSocket.CLOSED)) {
openWalletSocket(() => {
if (!walletSocket || walletSocket.readyState !== WebSocket.OPEN) return;
const subId = `er_${APP_ID}`;
const unsub = subTo(walletSocket, subId, (type, _, content) => {
if (type === 'EVENT') {
const response: { rate: string } = JSON.parse(content?.content || '{ "rate": 1 }');
const BTCForTarget = parseFloat(response.rate) || 1;
const satsToTarget = BTCForTarget / satsInBTC;
const targetToBTC = 1 / BTCForTarget;
const targetToSats = 1 / satsToTarget;
updateStore('exchangeRate', () => ({
USD: {
sats: targetToSats,
BTC: targetToBTC,
USD: 1,
},
sats: {
sats: 1,
USD: satsToTarget,
BTC: 1 / satsInBTC,
},
BTC: {
sats: satsInBTC,
USD: BTCForTarget,
BTC: 1,
}
}));
}
if (type === 'EOSE') {
unsub();
walletSocket?.close();
}
});
getExchangeRate(account?.publicKey, subId, "USD", walletSocket);
});
} else {
walletSocket?.close();
}
})
const getTiers = (author: PrimalUser) => {
if (!author) return;
const subId = `subscription_tiers_${APP_ID}`;
let tiers: Tier[] = [];
const unsub = subsTo(subId, {
onEvent: (_, content) => {
if (content.kind === Kind.TierList) {
return;
}
if (content.kind === Kind.Tier) {
const t = content as NostrTier;
let costs = t.tags?.filter((t: string[]) => t[0] === 'amount').map((t: string[]) => (
{
amount: t[1],
unit: t[2],
cadence: t[3],
id: `${t[1]}_${t[2]}_${t[3]}`
})) || [];
const tier = {
title: (t.tags?.find((t: string[]) => t[0] === 'title') || [])[1] || t.content || '',
id: t.id || '',
content: t.content || '',
perks: t.tags?.filter((t: string[]) => t[0] === 'perk').map((t: string[]) => t[1]) || [],
costs,
client: (t.tags?.find((t: string[]) => t[0] === 'client') || [])[1] || t.content || '',
event: t,
activeCost: costs[0],
}
tiers.push(tier)
return;
}
},
onEose: () => {
unsub();
updateStore('isFetchingTiers', () => false);
updateStore('tiers', () => [...tiers]);
const tier: Tier | undefined = tiers.length > 0 ? Object.assign(tiers[0]) : undefined;
updateStore('selectedTier', () => tier ? ({ ...tier }) : undefined);
updateStore('selectedCost', () => tier ? ({ ...tier?.costs[0] }) : undefined);
},
})
updateStore('isFetchingTiers', () => true);
getAuthorSubscriptionTiers(author.pubkey, subId)
}
const selectTier = (tier: Tier) => {
if (tier.id !== store.selectedTier?.id) {
updateStore('selectedTier', () => ({ ...tier }));
updateStore('selectedCost', (sc) => ({ ...costOptions(tier)[0] }) );
}
}
const isSelectedTier = (tier: Tier) => tier.id === store.selectedTier?.id;
const costOptions = (tier: Tier) => {
return tier.costs.filter(cost => payUnits.includes(cost.unit));
}
const displayCost = (cost: TierCost | undefined) => {
let text = '';
switch(cost?.unit) {
case 'msat':
case 'msats':
case '':
text = `${Math.ceil(parseInt(cost?.amount || '0') / 1_000)} sats`;
break;
case 'sats':
case 'sat':
text = `${cost.amount} sats`;
break;
case 'USD':
case 'usd':
text = `${cost.amount} USD`;
}
return text;
};
const openWalletSocket = (onOpen: () => void) => {
walletSocket = new WebSocket('wss://wallet.primal.net/v1');
walletSocket.addEventListener('close', () => {
logInfo('WALLET SOCKET CLOSED');
});
walletSocket.addEventListener('open', () => {
logInfo('WALLET SOCKET OPENED');
onOpen();
});
}
return (
<Modal open={props.author !== undefined} onClose={props.onClose}>
<div id={props.id} class={styles.subscribeToAuthor}>
<div class={styles.header}>
<div class={styles.userInfo}>
<Avatar user={props.author} />
<div class={styles.userData}>
<div class={styles.userName}>
{userName(props.author)}
<VerificationCheck user={props.author} />
</div>
<div class={styles.nip05}>
{props.author?.nip05}
</div>
</div>
</div>
<button class={styles.close} onClick={props.onClose}>
</button>
</div>
<div class={styles.modalBody}>
<div class={styles.tiers}>
<Show
when={!store.isFetchingTiers}
fallback={<div><Loader/></div>}
>
<For
each={store.tiers}
fallback={
<div class={styles.noTiers}>
No compatible tiers found
</div>
}
>
{(tier) => (
<button
class={`${styles.tier} ${isSelectedTier(tier) ? styles.selected : ''}`}
onClick={() => selectTier(tier)}
>
<div class={styles.tierTitle}>{tier.title}</div>
<Show
when={costOptions(tier).length > 1 && store.selectedTier?.id === tier.id}
fallback={<div class={styles.cost}>
<div class={styles.amount}>
{displayCost(costOptions(tier)[0])}
</div>
<div class={styles.duration}>
{costOptions(tier)[0].cadence}
</div>
</div>}
>
<Select.Root
class={styles.selectCosts}
options={costOptions(tier)}
optionValue="id"
value={store.selectedCost}
onChange={(cost) => {
// updateStore('tiers', index(), 'activeCost', () => ({ ...cost }));
// updateStore('selectedTier', 'activeCost', () => ({ ...cost }));
updateStore('selectedCost', () => ({ ...cost }));
}}
itemComponent={props => (
<Select.Item item={props.item} class={styles.cost}>
<div class={styles.amount}>
{displayCost(props.item.rawValue)}
</div>
<div class={styles.duration}>
{props.item.rawValue.cadence}
</div>
</Select.Item>
)}
>
<Select.Trigger class={styles.selectTrigger}>
<Select.Value class={styles.selectValue}>
{state => {
const cost = state.selectedOption() as TierCost;
return (
<div class={styles.cost}>
<div class={styles.amount}>
{displayCost(cost)}
</div>
<div class={styles.duration}>
<div>{cost?.cadence}</div>
<div class={styles.chevIcon}></div>
</div>
</div>
)
}}
</Select.Value>
</Select.Trigger>
<Select.Portal>
<Select.Content class={styles.selectContent}>
<Select.Listbox class={styles.selectListbox} />
</Select.Content>
</Select.Portal>
</Select.Root>
</Show>
<div class={styles.content}>
{tier.content}
</div>
<div class={styles.perks}>
<For each={tier.perks}>
{perk => (
<div class={styles.perk}>
<div class={styles.checkIcon}></div>
<div class={styles.text}>{perk}</div>
</div>
)}
</For>
</div>
</button>
)}
</For>
</Show>
</div>
</div>
<div class={styles.footer}>
<div class={styles.payAction}>
<ButtonSecondary
light={true}
onClick={props.onClose}
>
cancel
</ButtonSecondary>
</div>
<Show when={store.selectedTier}>
<div class={styles.payAction}>
<ButtonPrimary
onClick={() => store.selectedTier && store.selectedCost && props.onSubscribe(store.selectedTier, store.selectedCost, store.exchangeRate)}
>
subscribe
</ButtonPrimary>
</div>
</Show>
</div>
</div>
</Modal>
);
}
export default hookForDev(SubscribeToAuthorModal);

View File

@ -13,6 +13,7 @@ export const emptyPage: FeedPage = {
messages: [],
postStats: {},
noteActions: {},
topZaps: {},
}
export const nostrHighlights ='9a500dccc084a138330a1d1b2be0d5e86394624325d25084d3eca164e7ea698a';
@ -101,16 +102,19 @@ export enum Kind {
ChannelHideMessage = 43,
ChannelMuteUser = 44,
LongForm = 30_023,
Subscribe = 7_001,
Unsubscribe = 7_002,
Zap = 9_735,
MuteList = 10_000,
RelayList = 10_002,
Bookmarks = 10_003,
CategorizedPeople = 30_000,
TierList = 17_000,
CategorizedPeople = 30_000,
LongForm = 30_023,
Settings = 30_078,
Tier = 37_001,
ACK = 10_000_098,
NoteStats = 10_000_100,
@ -141,6 +145,8 @@ export enum Kind {
UserRelays=10_000_139,
RelayHint=10_000_141,
NoteQuoteStats=10_000_143,
WordCount=10_000_144,
FeaturedAuthors=10_000_148,
WALLET_OPERATION = 10_000_300,
}

View File

@ -21,6 +21,7 @@ import {
PrimalNote,
PrimalUser,
NostrEventContent,
PrimalArticle,
} from '../types/primal';
import { Kind, pinEncodePrefix, relayConnectingTimeout } from "../constants";
import { isConnected, refreshSocketListeners, removeSocketListeners, socket, subscribeTo, reset, subTo } from "../sockets";
@ -79,7 +80,7 @@ export type AccountContextStore = {
showNewNoteForm: () => void,
hideNewNoteForm: () => void,
setActiveUser: (user: PrimalUser) => void,
addLike: (note: PrimalNote) => Promise<boolean>,
addLike: (note: PrimalNote | PrimalArticle) => Promise<boolean>,
setPublicKey: (pubkey: string | undefined) => void,
addFollow: (pubkey: string, cb?: (remove: boolean, pubkey: string) => void) => void,
removeFollow: (pubkey: string, cb?: (remove: boolean, pubkey: string) => void) => void,
@ -517,15 +518,15 @@ export function AccountProvider(props: { children: JSXElement }) {
updateStore('showNewNoteForm', () => false);
};
const addLike = async (note: PrimalNote) => {
if (store.likes.includes(note.post.id)) {
const addLike = async (note: PrimalNote | PrimalArticle) => {
if (store.likes.includes(note.id)) {
return false;
}
const { success } = await sendLike(note, store.relays, store.relaySettings);
if (success) {
updateStore('likes', (likes) => [ ...likes, note.post.id]);
updateStore('likes', (likes) => [ ...likes, note.id]);
saveLikes(store.publicKey, store.likes);
}

View File

@ -7,8 +7,11 @@ import {
onMount,
useContext
} from "solid-js";
import { PrimalNote, PrimalUser, ZapOption } from "../types/primal";
import { PrimalArticle, PrimalNote, PrimalUser, ZapOption } from "../types/primal";
import { CashuMint } from "@cashu/cashu-ts";
import { Tier, TierCost } from "../components/SubscribeToAuthorModal/SubscribeToAuthorModal";
import { Kind } from "../constants";
import { sendEvent } from "../lib/notes";
export type ReactionStats = {
@ -21,7 +24,7 @@ export type ReactionStats = {
export type CustomZapInfo = {
profile?: PrimalUser,
note?: PrimalNote,
note?: PrimalNote | PrimalArticle,
onConfirm: (zapOption: ZapOption) => void,
onSuccess: (zapOption: ZapOption) => void,
onFail: (zapOption: ZapOption) => void,
@ -29,7 +32,7 @@ export type CustomZapInfo = {
};
export type NoteContextMenuInfo = {
note: PrimalNote,
note: PrimalNote | PrimalArticle,
position: DOMRect | undefined,
openCustomZap?: () => void,
openReactions?: () => void,
@ -66,13 +69,15 @@ export type AppContextStore = {
showConfirmModal: boolean,
confirmInfo: ConfirmInfo | undefined,
cashuMints: Map<string, CashuMint>,
subscribeToAuthor: PrimalUser | undefined,
subscribeToTier: (tier: Tier) => void,
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,
openContextMenu: (note: PrimalNote | PrimalArticle, position: DOMRect | undefined, openCustomZapModal: () => void, openReactionModal: () => void) => void,
closeContextMenu: () => void,
openLnbcModal: (lnbc: string, onPay: () => void) => void,
closeLnbcModal: () => void,
@ -81,6 +86,8 @@ export type AppContextStore = {
openConfirmModal: (confirmInfo: ConfirmInfo) => void,
closeConfirmModal: () => void,
getCashuMint: (url: string) => CashuMint | undefined,
openAuthorSubscribeModal: (author: PrimalUser | undefined, subscribeTo: (tier: Tier, cost: TierCost) => void) => void,
closeAuthorSubscribeModal: () => void,
},
}
@ -106,6 +113,8 @@ const initialData: Omit<AppContextStore, 'actions'> = {
showConfirmModal: false,
confirmInfo: undefined,
cashuMints: new Map(),
subscribeToAuthor: undefined,
subscribeToTier: () => {},
};
export const AppContext = createContext<AppContextStore>();
@ -155,7 +164,7 @@ export const AppProvider = (props: { children: JSXElement }) => {
};
const openContextMenu = (
note: PrimalNote,
note: PrimalNote | PrimalArticle,
position: DOMRect | undefined,
openCustomZap: () => void,
openReactions: () => void,
@ -221,6 +230,17 @@ export const AppProvider = (props: { children: JSXElement }) => {
return store.cashuMints.get(formatted);
};
const openAuthorSubscribeModal = (author: PrimalUser | undefined, subscribeTo: (tier: Tier, cost: TierCost) => void) => {
if (!author) return;
updateStore('subscribeToAuthor', () => ({ ...author }));
updateStore('subscribeToTier', () => subscribeTo);
};
const closeAuthorSubscribeModal = () => {
updateStore('subscribeToAuthor', () => undefined);
};
// EFFECTS --------------------------------------
onMount(() => {
@ -273,6 +293,8 @@ export const AppProvider = (props: { children: JSXElement }) => {
openCashuModal,
closeCashuModal,
getCashuMint,
openAuthorSubscribeModal,
closeAuthorSubscribeModal,
}
});

View File

@ -605,39 +605,6 @@ export const HomeProvider = (props: { children: ContextChildren }) => {
return;
}
// if (content.kind === Kind.EventZapInfo) {
// const zapInfo = JSON.parse(content.content)
// const eventId = zapInfo.event_id || 'UNKNOWN';
// if (eventId === 'UNKNOWN') return;
// const zap: TopZap = {
// id: zapInfo.zap_receipt_id,
// amount: parseInt(zapInfo.amount_sats || '0'),
// pubkey: zapInfo.sender,
// 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;
// }
};
const savePage = (page: FeedPage, scope?: 'future') => {

View File

@ -29,6 +29,7 @@ import {
NostrUserContent,
NostrUserZaps,
NoteActions,
PrimalArticle,
PrimalNote,
PrimalUser,
PrimalZap,
@ -52,6 +53,7 @@ import { setLinkPreviews } from "../lib/notes";
import { subscribeTo } from "../sockets";
import { parseBolt11 } from "../utils";
import { readRecomendedUsers, saveRecomendedUsers } from "../lib/localStore";
import { fetchUserArticles } from "../handleNotes";
export type UserStats = {
pubkey: string,
@ -63,6 +65,7 @@ export type UserStats = {
total_zap_count: number,
total_satszapped: number,
relay_count: number,
long_form_note_count?: number,
};
export type ProfileContextStore = {
@ -71,6 +74,7 @@ export type ProfileContextStore = {
userStats: UserStats,
fetchedUserStats: boolean,
knownProfiles: VanityProfiles,
articles: PrimalArticle[],
notes: PrimalNote[],
replies: PrimalNote[],
zaps: PrimalZap[],
@ -91,6 +95,7 @@ export type ProfileContextStore = {
repliesPage: FeedPage,
reposts: Record<string, string> | undefined,
lastNote: PrimalNote | undefined,
lastArticle: PrimalArticle | undefined,
lastReply: PrimalNote | undefined,
following: string[],
sidebar: FeedPage & { notes: PrimalNote[] },
@ -118,6 +123,9 @@ export type ProfileContextStore = {
fetchNextRepliesPage: () => void,
fetchNotes: (noteId: string | undefined, until?: number) => void,
fetchNextPage: () => void,
fetchArticles: (noteId: string | undefined, until?: number) => void,
fetchNextArticlesPage: () => void,
clearArticles: () => void,
updatePage: (content: NostrEventContent) => void,
savePage: (page: FeedPage) => void,
updateRepliesPage: (content: NostrEventContent) => void,
@ -156,6 +164,7 @@ export const initialData = {
userStats: { ...emptyStats },
fetchedUserStats: false,
knownProfiles: { names: {} },
articles: [],
notes: [],
replies: [],
isFetching: false,
@ -170,6 +179,7 @@ export const initialData = {
zappers: {},
zapListOffset: 0,
lastNote: undefined,
lastArticle: undefined,
lastReply: undefined,
lastZap: undefined,
following: [],
@ -465,6 +475,20 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
updateStore('isFetchingReplies', () => false);
};
const fetchArticles = async (pubkey: string | undefined, until = 0, limit = 20) => {
if (!pubkey) {
return;
}
updateStore('isFetching', () => true);
let articles = await fetchUserArticles(account?.publicKey, pubkey, 'authored', `profile_articles_${APP_ID}`, until, limit);
articles = articles.filter(a => a.id !== store.lastArticle?.id);
updateStore('articles', (arts) => [ ...arts, ...articles]);
updateStore('isFetching', () => false);
}
const fetchNotes = (pubkey: string | undefined, until = 0, limit = 20) => {
if (!pubkey) {
return;
@ -499,6 +523,11 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
}));
};
const clearArticles = () => {
updateStore('articles', () => []);
updateStore('lastArticle', () => undefined);
};
const clearReplies = () => {
updateStore('repliesPage', () => ({ messages: [], users: {}, postStats: {}, noteActions: {} }));
updateStore('replies', () => []);
@ -545,6 +574,28 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
}
};
const fetchNextArticlesPage = () => {
const lastArticle = store.articles[store.articles.length - 1];
if (!lastArticle) {
return;
}
updateStore('lastArticle', () => ({ ...lastArticle }));
const criteria = paginationPlan('latest');
const noteData: Record<string, any> = lastArticle.repost ?
lastArticle.repost.note :
lastArticle.msg;
const until = noteData[criteria];
if (until > 0 && store.profileKey) {
fetchArticles(store.profileKey, until);
}
};
const fetchNextRepliesPage = () => {
const lastReply = store.replies[store.replies.length - 1];
@ -1357,6 +1408,9 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
clearNotes,
fetchNotes,
fetchNextPage,
fetchArticles,
fetchNextArticlesPage,
clearArticles,
updatePage,
savePage,
saveReplies,

View File

@ -0,0 +1,840 @@
import { nip19 } from "nostr-tools";
import { createContext, createEffect, onCleanup, useContext } from "solid-js";
import { createStore, reconcile, unwrap } from "solid-js/store";
import { APP_ID } from "../App";
import { Kind, minKnownProfiles } from "../constants";
import { getArticlesFeed, getEvents, getExploreFeed, getFeed, getFutureArticlesFeed, getFutureExploreFeed, getFutureFeed } from "../lib/feed";
import { fetchStoredFeed, saveStoredFeed } from "../lib/localStore";
import { setLinkPreviews } from "../lib/notes";
import { getRecomendedArticleIds, getScoredUsers, searchContent } from "../lib/search";
import { isConnected, refreshSocketListeners, removeSocketListeners, socket } from "../sockets";
import { sortingPlan, convertToNotes, parseEmptyReposts, paginationPlan, isInTags, isRepostInCollection, convertToArticles, isLFRepostInCollection } from "../stores/note";
import {
ContextChildren,
FeedPage,
NostrEOSE,
NostrEvent,
NostrEventContent,
NostrMentionContent,
NostrNoteActionsContent,
NostrNoteContent,
NostrStatsContent,
NostrUserContent,
NoteActions,
PrimalArticle,
PrimalFeed,
PrimalUser,
SelectionOption,
TopZap,
} from "../types/primal";
import { parseBolt11 } from "../utils";
import { useAccountContext } from "./AccountContext";
import { useSettingsContext } from "./SettingsContext";
type ReadsContextStore = {
notes: PrimalArticle[],
isFetching: boolean,
scrollTop: number,
selectedFeed: PrimalFeed | undefined,
page: FeedPage,
lastNote: PrimalArticle | undefined,
reposts: Record<string, string> | undefined,
mentionedNotes: Record<string, NostrNoteContent>,
recomendedReads: string[],
future: {
notes: PrimalArticle[],
page: FeedPage,
reposts: Record<string, string> | undefined,
scope: string,
timeframe: string,
latest_at: number,
},
sidebar: {
notes: PrimalArticle[],
page: FeedPage,
isFetching: boolean,
query: SelectionOption | undefined,
},
actions: {
saveNotes: (newNotes: PrimalArticle[]) => void,
clearNotes: () => void,
fetchNotes: (topic: string, subId: string, until?: number) => void,
fetchNextPage: () => void,
selectFeed: (feed: PrimalFeed | undefined) => void,
updateScrollTop: (top: number) => void,
updatePage: (content: NostrEventContent) => void,
savePage: (page: FeedPage) => void,
checkForNewNotes: (topic: string | undefined) => void,
loadFutureContent: () => void,
doSidebarSearch: (query: string) => void,
updateSidebarQuery: (selection: SelectionOption) => void,
getFirstPage: () => void,
}
}
const initialHomeData = {
notes: [],
isFetching: false,
scrollTop: 0,
selectedFeed: undefined,
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
wordCount: {},
},
reposts: {},
lastNote: undefined,
mentionedNotes: {},
future: {
notes: [],
reposts: {},
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
wordCount: {},
},
scope: '',
timeframe: '',
latest_at: 0,
},
sidebar: {
notes: [],
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
wordCount: {},
},
isFetching: false,
query: undefined,
},
recomendedReads: [],
};
export const ReadsContext = createContext<ReadsContextStore>();
export const ReadsProvider = (props: { children: ContextChildren }) => {
const settings = useSettingsContext();
const account = useAccountContext();
// ACTIONS --------------------------------------
const updateSidebarQuery = (selection: SelectionOption) => {
updateStore('sidebar', 'query', () => ({ ...selection }));
};
const saveSidebarNotes = (newNotes: PrimalArticle[]) => {
updateStore('sidebar', 'notes', () => [ ...newNotes.slice(0, 24) ]);
updateStore('sidebar', 'isFetching', () => false);
};
const updateSidebarPage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
updateStore('sidebar', 'page', 'users',
(usrs) => ({ ...usrs, [user.pubkey]: { ...user } })
);
return;
}
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (store.sidebar.page.messages.find(m => m.id === message.id)) {
return;
}
updateStore('sidebar', 'page', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
updateStore('sidebar', '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('sidebar', '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('sidebar', 'page', 'noteActions',
(actions) => ({ ...actions, [noteActions.event_id]: { ...noteActions } })
);
return;
}
};
const saveSidebarPage = (page: FeedPage) => {
const newPosts = convertToArticles(page);
saveSidebarNotes(newPosts);
};
const doSidebarSearch = (query: string) => {
const subid = `reads_recomended_${APP_ID}`;
updateStore('sidebar', 'isFetching', () => true);
updateStore('sidebar', 'notes', () => []);
updateStore('sidebar', 'page', { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {}, topZaps: {} });
getRecomendedArticleIds(subid);
}
const clearFuture = () => {
updateStore('future', () => ({
notes: [],
reposts: {},
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
},
scope: '',
timeframe: '',
latest_at: 0,
}))
}
const saveNotes = (newNotes: PrimalArticle[], scope?: 'future') => {
if (scope) {
updateStore(scope, 'notes', (notes) => [ ...notes, ...newNotes ]);
return;
}
updateStore('notes', (notes) => [ ...notes, ...newNotes ]);
updateStore('isFetching', () => false);
};
const checkForNewNotes = (topic: string | undefined) => {
if (!topic) {
return;
}
if (store.future.notes.length > 100) {
return;
}
const [scope, timeframe] = topic.split(';');
if (scope !== store.future.scope || timeframe !== store.future.timeframe) {
clearFuture();
updateStore('future', 'scope', () => scope);
updateStore('future', 'timeframe', () => timeframe);
}
let since = 0;
if (store.notes[0]) {
since = store.notes[0].repost ?
store.notes[0].repost.note.created_at :
store.notes[0].published;
}
if (store.future.notes[0]) {
const lastFutureNote = unwrap(store.future.notes).sort((a, b) => b.published - a.published)[0];
since = lastFutureNote.repost ?
lastFutureNote.repost.note.created_at :
lastFutureNote.published;
}
updateStore('future', 'page', () =>({
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
}))
if (scope && timeframe) {
if (timeframe !== 'latest') {
return;
}
getFutureExploreFeed(
account?.publicKey,
`reads_future_${APP_ID}`,
scope,
timeframe,
since,
);
return;
}
getFutureArticlesFeed(account?.publicKey, topic, `reads_future_${APP_ID}`, since);
}
const loadFutureContent = () => {
if (store.future.notes.length === 0) {
return;
}
updateStore('notes', (notes) => [...store.future.notes, ...notes]);
clearFuture();
};
const fetchNotes = (topic: string, subId: string, until = 0, includeReplies?: boolean) => {
const t = topic === 'none' ? '' : topic;//account?.publicKey || '532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb';
const [scope, timeframe] = t.split(';');
updateStore('isFetching', true);
updateStore('page', () => ({ messages: [], users: {}, postStats: {} }));
if (scope && timeframe) {
if (scope === 'search') {
searchContent(account?.publicKey, `reads_feed_${subId}`, decodeURI(timeframe));
return;
}
getExploreFeed(
account?.publicKey,
`reads_feed_${subId}`,
scope,
timeframe,
until,
);
return;
}
getArticlesFeed(account?.publicKey, t, `reads_feed_${subId}`, until, 20);
};
const clearNotes = () => {
updateStore('scrollTop', () => 0);
updateStore('page', () => ({ messages: [], users: {}, postStats: {}, noteActions: {} }));
updateStore('notes', () => []);
updateStore('reposts', () => undefined);
updateStore('lastNote', () => undefined);
clearFuture();
};
const fetchNextPage = () => {
if (store.isFetching) {
return;
}
const lastNote = store.notes[store.notes.length - 1];
if (!lastNote) {
return;
}
updateStore('lastNote', () => ({ ...lastNote }));
const topic = store.selectedFeed?.hex;
const includeReplies = store.selectedFeed?.includeReplies;
if (!topic) {
return;
}
const [scope, timeframe] = topic.split(';');
if (scope === 'search') {
return;
}
const pagCriteria = timeframe || 'latest';
const criteria = 'published'; //paginationPlan(pagCriteria);
const noteData: Record<string, any> = lastNote.repost ?
lastNote.repost.note :
lastNote;
const until = noteData[criteria];
if (until > 0) {
fetchNotes(topic, `${APP_ID}`, until, includeReplies);
}
};
const updateScrollTop = (top: number) => {
updateStore('scrollTop', () => top);
};
let currentFeed: PrimalFeed | undefined;
const selectFeed = (feed: PrimalFeed | undefined) => {
if (feed?.hex !== undefined && (feed.hex !== currentFeed?.hex || feed.includeReplies !== currentFeed?.includeReplies)) {
currentFeed = { ...feed };
// saveStoredFeed(account?.publicKey, currentFeed);
updateStore('selectedFeed', reconcile({...feed}));
clearNotes();
fetchNotes(feed.hex , `${APP_ID}`, 0, feed.includeReplies);
}
};
const getFirstPage = () => {
const feed = store.selectedFeed;
if (!feed?.hex) return;
clearNotes();
fetchNotes(feed.hex , `${APP_ID}`, 0, feed.includeReplies);
};
const updatePage = (content: NostrEventContent, scope?: 'future') => {
if (content.kind === Kind.WordCount) {
const count = JSON.parse(content.content) as { event_id: string, words: number };
if (scope) {
updateStore(scope, 'page', 'wordCount',
() => ({ [count.event_id]: count.words })
);
return;
}
updateStore('page', 'wordCount',
() => ({ [count.event_id]: count.words })
);
return;
}
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
if (scope) {
updateStore(scope, 'page', 'users',
(usrs) => ({ ...usrs, [user.pubkey]: { ...user } })
);
return;
}
updateStore('page', 'users',
(usrs) => ({ ...usrs, [user.pubkey]: { ...user } })
);
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
const isRepost = message.kind === Kind.Repost;
if (scope) {
const isFirstNote = message.kind === Kind.LongForm ?
store.notes[0]?.id === message.id :
store.notes[0]?.repost?.note.noteId === message.id;
const scopeNotes = store[scope].notes;
const isaAlreadyIn = message.kind === Kind.Text &&
scopeNotes &&
scopeNotes.find(n => n.id === message.id);
let isAlreadyReposted = isLFRepostInCollection(store[scope].page.messages, message);
// const isAlreadyFetched = message.kind === Kind.Text ?
// store.future.notes[0]?.post?.noteId === messageId :
// store.future.notes[0]?.repost?.note.noteId === messageId;
if (isFirstNote || isaAlreadyIn || isAlreadyReposted) return;
updateStore(scope, 'page', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
return;
}
const isLastNote = message.kind === Kind.LongForm ?
store.lastNote?.id === message.id :
store.lastNote?.repost?.note.noteId === message.id;
let isAlreadyReposted = isRepostInCollection(store.page.messages, message);
if (isLastNote || isAlreadyReposted) return;
updateStore('page', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
if (scope) {
updateStore(scope, 'page', 'postStats',
(stats) => ({ ...stats, [stat.event_id]: { ...stat } })
);
return;
}
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);
if (scope) {
updateStore(scope, 'page', 'mentions',
(mentions) => ({ ...mentions, [mention.id]: { ...mention } })
);
return;
}
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;
if (scope) {
updateStore(scope, 'page', 'noteActions',
(actions) => ({ ...actions, [noteActions.event_id]: { ...noteActions } })
);
return;
}
updateStore('page', 'noteActions',
(actions) => ({ ...actions, [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;
}
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,
};
if (scope) {
const oldZaps = store[scope].page.topZaps[eventId];
if (oldZaps === undefined) {
updateStore(scope, 'page', '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(scope, 'page', 'topZaps', eventId, () => [ ...newZaps ]);
return;
}
const oldZaps = store.page.topZaps[eventId];
if (oldZaps === undefined) {
updateStore('page', '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('page', 'topZaps', eventId, () => [ ...newZaps ]);
return;
}
};
const savePage = (page: FeedPage, scope?: 'future') => {
const topic = (store.selectedFeed?.hex || '').split(';');
// const sortingFunction = sortingPlan(topic[1]);
const topZaps = scope ? store[scope].page.topZaps : store.page.topZaps
const newPosts = convertToArticles(page, topZaps);
saveNotes(newPosts, scope);
};
// SOCKET HANDLERS ------------------------------
const onMessage = (event: MessageEvent) => {
const message: NostrEvent | NostrEOSE = JSON.parse(event.data);
const [type, subId, content] = message;
if (subId === `reads_recomended_${APP_ID}`) {
if (type === 'EOSE') {
// saveSidebarPage(store.sidebar.page);
return;
}
if (!content) {
return;
}
if (type === 'EVENT') {
const recomended = JSON.parse(content?.content || '{}');
const ids = recomended.reads.reduce((acc: string[], r: string[]) => r[0] ? [ ...acc, r[0] ] : acc, []);
updateStore('recomendedReads', () => [ ...ids ])
return;
}
}
if (subId === `reads_sidebar_${APP_ID}`) {
if (type === 'EOSE') {
saveSidebarPage(store.sidebar.page);
return;
}
if (!content) {
return;
}
if (type === 'EVENT') {
updateSidebarPage(content);
return;
}
}
if (subId === `reads_feed_${APP_ID}`) {
if (type === 'EOSE') {
const reposts = parseEmptyReposts(store.page);
const ids = Object.keys(reposts);
if (ids.length === 0) {
savePage(store.page);
return;
}
updateStore('reposts', () => reposts);
getEvents(account?.publicKey, ids, `reads_reposts_${APP_ID}`);
return;
}
if (type === 'EVENT') {
updatePage(content);
return;
}
}
if (subId === `reads_reposts_${APP_ID}`) {
if (type === 'EOSE') {
savePage(store.page);
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;
}
}
if (subId === `reads_future_${APP_ID}`) {
if (type === 'EOSE') {
const reposts = parseEmptyReposts(store.future.page);
const ids = Object.keys(reposts);
if (ids.length === 0) {
savePage(store.future.page, 'future');
return;
}
updateStore('future', 'reposts', () => reposts);
getEvents(account?.publicKey, ids, `reads_future_reposts_${APP_ID}`);
return;
}
if (type === 'EVENT') {
updatePage(content, 'future');
return;
}
}
if (subId === `reads_future_reposts_${APP_ID}`) {
if (type === 'EOSE') {
savePage(store.future.page, 'future');
return;
}
if (type === 'EVENT') {
const repostId = (content as NostrNoteContent).id;
const reposts = store.future.reposts || {};
const parent = store.future.page.messages.find(m => m.id === reposts[repostId]);
if (parent) {
updateStore('future', 'page', 'messages', (msg) => msg.id === parent.id, 'content', () => JSON.stringify(content));
}
return;
}
}
};
const onSocketClose = (closeEvent: CloseEvent) => {
const webSocket = closeEvent.target as WebSocket;
removeSocketListeners(
webSocket,
{ message: onMessage, close: onSocketClose },
);
};
// EFFECTS --------------------------------------
createEffect(() => {
if (isConnected()) {
refreshSocketListeners(
socket(),
{ message: onMessage, close: onSocketClose },
);
}
});
createEffect(() => {
if (account?.isKeyLookupDone && account.publicKey) {
selectFeed({ hex: account.publicKey, name: 'My Reads'});
}
});
onCleanup(() => {
removeSocketListeners(
socket(),
{ message: onMessage, close: onSocketClose },
);
});
// STORES ---------------------------------------
const [store, updateStore] = createStore<ReadsContextStore>({
...initialHomeData,
actions: {
saveNotes,
clearNotes,
fetchNotes,
fetchNextPage,
selectFeed,
updateScrollTop,
updatePage,
savePage,
checkForNewNotes,
loadFutureContent,
doSidebarSearch,
updateSidebarQuery,
getFirstPage,
},
});
// RENDER -------------------------------------
return (
<ReadsContext.Provider value={store}>
{props.children}
</ReadsContext.Provider>
);
}
export const useReadsContext = () => useContext(ReadsContext);

783
src/handleNotes.ts Normal file
View File

@ -0,0 +1,783 @@
import { nip19 } from "nostr-tools";
import { Kind } from "./constants";
import { getEvents, getUserArticleFeed } from "./lib/feed";
import { decodeIdentifier, hexToNpub } from "./lib/keys";
import { getParametrizedEvents, setLinkPreviews } from "./lib/notes";
import { getUserProfileInfo } from "./lib/profile";
import { updateStore, store } from "./services/StoreService";
import { subscribeTo } from "./sockets";
import { convertToArticles, convertToNotes } from "./stores/note";
import { convertToUser } from "./stores/profile";
import { account } from "./translations";
import { EventCoordinate, FeedPage, NostrEventContent, NostrEventType, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalArticle, PrimalNote, PrimalUser, TopZap } from "./types/primal";
import { parseBolt11 } from "./utils";
export const fetchNotes = (pubkey: string | undefined, noteIds: string[], subId: string) => {
return new Promise<PrimalNote[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
}
let lastNote: PrimalNote | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToNotes(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getEvents(pubkey, [...noteIds], subId, true);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.post?.noteId !== nip19.noteEncode(message.id)) {
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;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
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,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};
export const fetchArticles = (noteIds: string[], subId: string) => {
return new Promise<PrimalArticle[]>((resolve, reject) => {
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
wordCount: {},
}
const events = noteIds.reduce<EventCoordinate[]>((acc, id) => {
const d = decodeIdentifier(id);
if (!d.data || d.type !== 'naddr') return acc;
const { pubkey, identifier, kind } = d.data;
return [
...acc,
{ identifier, pubkey, kind },
]
}, []);
let lastNote: PrimalArticle | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToArticles(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getParametrizedEvents(events, subId);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.noteId !== nip19.noteEncode(message.id)) {
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;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
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,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.WordCount) {
const count = JSON.parse(content.content) as { event_id: string, words: number };
if (!page.wordCount) {
page.wordCount = {};
}
page.wordCount[count.event_id] = count.words
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};
export const fetchArticleThread = (pubkey: string | undefined, noteIds: string, subId: string) => {
return new Promise<PrimalArticle[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
wordCount: {},
}
let primaryArticle: PrimalArticle | undefined;
let lastNote: PrimalArticle | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToArticles(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getEvents(pubkey, [...noteIds], subId, true);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.noteId !== nip19.noteEncode(message.id)) {
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;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
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,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};
export const fetchUserArticles = (userPubkey: string | undefined, pubkey: string | undefined, type: 'authored' | 'replies' | 'bookmarks', subId: string, until = 0, limit = 10) => {
return new Promise<PrimalArticle[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
wordCount: {},
}
let lastNote: PrimalArticle | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToArticles(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getUserArticleFeed(userPubkey, pubkey, subId, type, until, limit);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.noteId !== nip19.noteEncode(message.id)) {
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;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
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,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.WordCount) {
const count = JSON.parse(content.content) as { event_id: string, words: number };
if (!page.wordCount) {
page.wordCount = {};
}
page.wordCount[count.event_id] = count.words
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};
export const fetchUserProfile = (userPubkey: string | undefined, pubkey: string | undefined, subId: string) => {
return new Promise<PrimalUser>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let user: PrimalUser | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
user ? resolve(user) : reject('user not found');
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getUserProfileInfo(pubkey, userPubkey, subId);
const updatePage = (content: NostrEventContent) => {
if (content?.kind === Kind.Metadata) {
let userData = JSON.parse(content.content);
if (!userData.displayName || typeof userData.displayName === 'string' && userData.displayName.trim().length === 0) {
userData.displayName = userData.display_name;
}
userData.pubkey = content.pubkey;
userData.npub = hexToNpub(content.pubkey);
userData.created_at = content.created_at;
user = { ...userData };
return;
}
};
});
}

View File

@ -4,6 +4,8 @@
@import 'photoswipe/style.css';
@import "./monokai-sublime.scss";
/* Default theme */
:root[data-theme="dark"],
:root[data-theme="sunset"],
@ -48,7 +50,7 @@
--border-radius-big: 12px;
--border-radius-large: 16px;
--central-content-width: 600px;
--central-content-width: 640px;
--sidebar-section-icon-gradient: linear-gradient(175.11deg, #FA9A43 6.94%, #FA4343 29.79%, #5B12A4 97.76%), linear-gradient(170.29deg, #CCCCCC 12.73%, #808080 94.98%), #D9D9D9;
@ -60,10 +62,10 @@
--warning-color: #FA3C3C;
--success-color: #66E205;
--left-col-w: 188px;
--center-col-w: 600px;
--left-col-w: 182px;
--center-col-w: 640px;
--right-col-w: 348px;
--full-site-w: 1137px;
--full-site-w: 1172px;
--header-height: 84px;
background-color: var(--background-site);
@ -124,6 +126,29 @@ a {
border: 2px solid red;
}
body::after{
position:absolute; width:0; height:0; overflow:hidden; z-index:-1; // hide images
content:
url(./assets/icons/nav/bookmarks.svg)
url(./assets/icons/nav/bookmarks_selected.svg)
url(./assets/icons/nav/home.svg)
url(./assets/icons/nav/home_selected.svg)
url(./assets/icons/nav/search.svg)
url(./assets/icons/nav/search_selected.svg)
url(./assets/icons/nav/messages.svg)
url(./assets/icons/nav/messages_selected.svg)
url(./assets/icons/nav/notifications.svg)
url(./assets/icons/nav/notifications_selected.svg)
url(./assets/icons/nav/downloads.svg)
url(./assets/icons/nav/downloads_selected.svg)
url(./assets/icons/nav/settings.svg)
url(./assets/icons/nav/settings_selected.svg)
url(./assets/icons/nav/long.svg)
url(./assets/icons/nav/long_selected.svg)
url(./assets/images/reads_image_dark.png)
url(./assets/images/reads_image_light.png);
}
.reply_icon {
-webkit-mask: url(./assets/icons/feed_reply.svg) no-repeat 0 / 100%;
mask: url(./assets/icons/feed_reply_fill.svg) no-repeat 0 / 100%;

View File

@ -27,11 +27,12 @@ export const getFeed = (user_pubkey: string | undefined, pubkey: string | undef
return;
}
const start = until === 0 ? 'since' : 'until';
const time = until === 0 ? Math.ceil((new Date()).getTime()/1_000 ): until;
let payload = { limit, [start]: until, pubkey };
let payload = { limit, until: time, pubkey };
if (user_pubkey) {
// @ts-ignore dynamic property
payload.user_pubkey = user_pubkey;
}
if (include_replies) {
@ -46,6 +47,51 @@ export const getFeed = (user_pubkey: string | undefined, pubkey: string | undef
]));
}
export const getArticlesFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, until = 0, limit = 20) => {
// if (!pubkey) {
// return;
// }
const start = until === 0 ? 'since' : 'until';
let payload = { limit, [start]: until };
if (pubkey && pubkey?.length > 0) {
// @ts-ignore
payload.pubkey = pubkey;
}
if (user_pubkey) {
// @ts-ignore
payload.user_pubkey = user_pubkey;
}
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["long_form_content_feed", payload]},
]));
}
export const getFutureArticlesFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, since: number) => {
if (!pubkey) {
return;
}
let payload: { since: number, pubkey: string, user_pubkey?: string, limit: number } =
{ since, pubkey, limit: 100 };
if (user_pubkey) {
payload.user_pubkey = user_pubkey;
}
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["long_form_content_feed", payload]},
]));
};
export const getEvents = (user_pubkey: string | undefined, eventIds: string[], subid: string, extendResponse?: boolean) => {
let payload: {event_ids: string[], user_pubkey?: string, extended_response?: boolean } =
@ -95,6 +141,34 @@ export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | un
{cache: ["feed", payload]},
]));
}
export const getUserArticleFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies' | 'bookmarks', until = 0, limit = 20, offset = 0) => {
if (!pubkey) {
return;
}
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,
{cache: ["long_form_content_feed", payload]},
]));
}
export const getFutureUserFeed = (
user_pubkey: string | undefined,
@ -152,6 +226,23 @@ export const getThread = (user_pubkey: string | undefined, postId: string, subid
]));
}
export const getArticleThread = (user_pubkey: string | undefined, pubkey: string, identifier: string, kind: number, subid: string, until = 0, limit = 100) => {
let payload: { user_pubkey?: string, limit: number, pubkey: string, kind: number, identifier: string, until?: number } =
{ pubkey, identifier, kind , limit } ;
if (user_pubkey) {
payload.user_pubkey = user_pubkey;
}
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["long_form_content_thread_view", payload]},
]));
}
export const getFutureExploreFeed = (
user_pubkey: string | undefined,
subid: string,
@ -264,3 +355,37 @@ export const getMostZapped4h = (
]},
]));
};
export const getReadsTopics = (
subid: string,
) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["get_reads_topics"]},
]));
};
export const getFeaturedAuthors = (
subid: string,
) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["get_featured_authors"]},
]));
};
export const getAuthorSubscriptionTiers = (
pubkey: string | undefined,
subid: string,
) => {
if (!pubkey) return;
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["creator_paid_tiers", { pubkey }]},
]));
};

View File

@ -36,3 +36,39 @@ export const getMembershipStatus = async (pubkey: string | undefined, subId: str
return false;
}
}
export const getExchangeRate = async (pubkey: string | undefined, subId: string, currency: string, socket: WebSocket) => {
if (!pubkey) {
return;
}
const content = JSON.stringify(
["exchange_rate", { target_currency: currency }],
);
const event = {
content,
kind: Kind.WALLET_OPERATION,
created_at: Math.ceil((new Date()).getTime() / 1000),
tags: [],
};
const signedEvent = await signEvent(event);
const message = JSON.stringify([
"REQ",
subId,
{cache: ["wallet", { operation_event: signedEvent }]},
]);
if (socket) {
const e = new CustomEvent('send', { detail: { message, ws: socket }});
socket.send(message);
socket.dispatchEvent(e);
} else {
throw('no_socket');
}
}

View File

@ -5,7 +5,7 @@ import { createStore } from "solid-js/store";
import LinkPreview from "../components/LinkPreview/LinkPreview";
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 { EventCoordinate, MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalArticle, PrimalNote, SendNoteResult } from "../types/primal";
import { npubToHex } from "./keys";
import { logError, logInfo, logWarning } from "./logger";
import { getMediaUrl as getMediaUrlDefault } from "./media";
@ -308,13 +308,13 @@ export const importEvents = (events: NostrRelaySignedEvent[], subid: string) =>
type NostrEvent = { content: string, kind: number, tags: string[][], created_at: number };
export const sendLike = async (note: PrimalNote, relays: Relay[], relaySettings?: NostrRelays) => {
export const sendLike = async (note: PrimalNote | PrimalArticle, relays: Relay[], relaySettings?: NostrRelays) => {
const event = {
content: '+',
kind: Kind.Reaction,
tags: [
['e', note.post.id],
['p', note.post.pubkey],
['e', note.id],
['p', note.pubkey],
],
created_at: Math.floor((new Date()).getTime() / 1000),
};
@ -337,6 +337,20 @@ export const sendRepost = async (note: PrimalNote, relays: Relay[], relaySetting
return await sendEvent(event, relays, relaySettings);
}
export const sendArticleRepost = async (note: PrimalArticle, relays: Relay[], relaySettings?: NostrRelays) => {
const event = {
content: JSON.stringify(note.msg),
kind: Kind.Repost,
tags: [
['e', note.id],
['p', note.pubkey],
],
created_at: Math.floor((new Date()).getTime() / 1000),
};
return await sendEvent(event, relays, relaySettings);
}
export const sendNote = async (text: string, relays: Relay[], tags: string[][], relaySettings?: NostrRelays) => {
const event = {
content: text,
@ -601,3 +615,12 @@ export const getParametrizedEvent = (pubkey: string, identifier: string, kind: n
{cache: ["parametrized_replaceable_event", { pubkey, kind, identifier, extended_response: true }]},
]));
};
export const getParametrizedEvents = (events: EventCoordinate[], subid: string) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["parametrized_replaceable_events", { events, extended_response: true }]},
]));
};

View File

@ -75,3 +75,11 @@ export const getScoredUsers = (user_pubkey: string | undefined, selector: string
{cache: ['scored', { user_pubkey, selector }]},
]));
};
export const getRecomendedArticleIds = (subid: string) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ['get_recommended_reads']},
]));
};

View File

@ -1,7 +1,9 @@
import { bech32 } from "@scure/base";
// @ts-ignore Bad types in nostr-tools
import { nip57, Relay, utils } from "nostr-tools";
import { PrimalNote, PrimalUser } from "../types/primal";
import { Tier } from "../components/SubscribeToAuthorModal/SubscribeToAuthorModal";
import { Kind } from "../constants";
import { NostrRelaySignedEvent, PrimalArticle, PrimalNote, PrimalUser } from "../types/primal";
import { logError } from "./logger";
import { enableWebLn, sendPayment, signEvent } from "./nostrAPI";
@ -19,7 +21,52 @@ export const zapNote = async (note: PrimalNote, sender: string | undefined, amou
const sats = Math.round(amount * 1000);
let payload = {
profile: note.post.pubkey,
profile: note.pubkey,
event: note.id,
amount: sats,
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);
const event = encodeURIComponent(JSON.stringify(signedEvent));
const r2 = await (await fetch(`${callback}?amount=${sats}&nostr=${event}`)).json();
const pr = r2.pr;
await enableWebLn();
await sendPayment(pr);
return true;
} catch (reason) {
console.error('Failed to zap: ', reason);
return false;
}
}
export const zapArticle = async (note: PrimalArticle, sender: string | undefined, amount: number, comment = '', relays: Relay[]) => {
if (!sender) {
return false;
}
const callback = await getZapEndpoint(note.user);
if (!callback) {
return false;
}
const sats = Math.round(amount * 1000);
let payload = {
profile: note.pubkey,
event: note.msg.id,
amount: sats,
relays: relays.map(r => r.url)
@ -93,6 +140,67 @@ export const zapProfile = async (profile: PrimalUser, sender: string | undefined
}
}
export const zapSubscription = async (subEvent: NostrRelaySignedEvent, recipient: PrimalUser, sender: string | undefined, relays: Relay[], exchangeRate?: Record<string, Record<string, number>>) => {
if (!sender || !recipient) {
return false;
}
const callback = await getZapEndpoint(recipient);
if (!callback) {
return false;
}
const costTag = subEvent.tags.find(t => t [0] === 'amount');
if (!costTag) return false;
let sats = 0;
if (costTag[2] === 'sats') {
sats = parseInt(costTag[1]) * 1_000;
}
if (costTag[2] === 'msat') {
sats = parseInt(costTag[1]);
}
if (costTag[2] === 'USD' && exchangeRate && exchangeRate['USD']) {
let usd = parseFloat(costTag[1]);
sats = Math.ceil(exchangeRate['USD'].sats * usd * 1_000);
}
let payload = {
profile: recipient.pubkey,
event: subEvent.id,
amount: sats,
relays: relays.map(r => r.url)
};
if (subEvent.content.length > 0) {
// @ts-ignore
payload.comment = comment;
}
const zapReq = nip57.makeZapRequest(payload);
try {
const signedEvent = await signEvent(zapReq);
const event = encodeURIComponent(JSON.stringify(signedEvent));
const r2 = await (await fetch(`${callback}?amount=${sats}&nostr=${event}`)).json();
const pr = r2.pr;
await enableWebLn();
await sendPayment(pr);
return true;
} catch (reason) {
console.error('Failed to zap: ', reason);
return false;
}
}
export const getZapEndpoint = async (user: PrimalUser): Promise<string | null> => {
try {
let lnurl: string = ''

80
src/monokai-sublime.scss Normal file
View File

@ -0,0 +1,80 @@
/*
Monokai Sublime style. Derived from Monokai by noformnocontent http://nn.mit-license.org/
*/
.hljs {
background: #23241f;
color: #f8f8f2;
}
.hljs-tag,
.hljs-subst {
color: #f8f8f2;
}
.hljs-strong,
.hljs-emphasis {
color: #a8a8a2;
}
.hljs-bullet,
.hljs-quote,
.hljs-number,
.hljs-regexp,
.hljs-literal,
.hljs-link {
color: #ae81ff;
}
.hljs-code,
.hljs-title,
.hljs-section,
.hljs-selector-class {
color: #a6e22e;
}
.hljs-strong {
font-weight: bold;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-name,
.hljs-attr {
color: #f92672;
}
.hljs-symbol,
.hljs-attribute {
color: #66d9ef;
}
.hljs-params,
.hljs-title.class_,
.hljs-class .hljs-title {
color: #f8f8f2;
}
.hljs-string,
.hljs-type,
.hljs-built_in,
.hljs-selector-id,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-addition,
.hljs-variable,
.hljs-template-variable {
color: #e6db74;
}
.hljs-comment,
.hljs-deletion,
.hljs-meta {
color: #75715e;
}

View File

@ -11,14 +11,14 @@
.exploreHeader {
display: flex;
flex-direction: column;
width: 600px;
width: var(--center-col-w);
margin-left: -16px;
.exploreCaption {
display: flex;
align-items: center;
padding-left: 16px;
width: 600px;
width: var(--center-col-w);
height: 84px;
border-bottom: 1px solid var(--devider);
}
@ -28,7 +28,7 @@
display: flex;
justify-content: flex-end;
align-items: center;
width: 600px;
width: var(--center-col-w);
height: 41px;
background: none;
border-bottom: 1px solid var(--devider);

View File

@ -4,11 +4,18 @@
min-height: 100vh;
}
.readsFeed {
position: relative;
border: none;
min-height: 100vh;
border-top: 1px solid var(--devider);
}
.paginate {
color: var(--text-tertiary-2);
position: absolute;
bottom: 1280px;
width: 600px;
width: var(--center-col-w);
height: 100px;
}

View File

@ -0,0 +1,162 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--devider);
a {
text-decoration: none;
}
.author {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 6px;
.userInfo {
display: flex;
flex-direction: column;
.userName {
display: flex;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
}
.nip05 {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
}
}
}
.topBar {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 18px;
margin-top: 18px;
padding-inline: 20px;
.left {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
max-width: 80%;
.time {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 700;
}
.client {
&::before {
content: '';
}
color: var(--text-tertiary);
font-size: 14px;
font-weight: 700;
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.right {
display: flex;
justify-content: flex-end;
align-items: center;
}
}
.longform {
display: flex;
flex-direction: column;
gap: 20px;
position: relative;
margin-bottom: 22px;
margin-inline: 20px;
.title {
color: var(--text-primary);
font-size: 36px;
font-weight: 700;
line-height: 44px;
}
.summary {
display: flex;
gap: 12px;
.border {
display: block;
min-width: 4px;
border-radius: 2px;
background-color: var(--subtile-devider);
}
.text {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
.image {
width: 100%;
object-fit: contain;
border-radius: 12px;
overflow: hidden;
}
.content {
* {
color: var(--text-primary);
}
p, li {
font-size: 18px;
font-weight: 500;
}
a {
color: var(--accent-links);
}
pre, code, mark {
background-color: var(--background-input);
}
ins {
color: var(--warning-bright);
}
}
.tags {
width: 100%;
.tag {
display: inline-block;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
background-color: var(--background-input);
padding: 6px 10px;
border-radius: 12px;
width: fit-content;
margin-block: 4px;
margin-right: 6px;
}
}
}

1024
src/pages/Longform.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
padding-inline: 16px;
margin-bottom: 8px;
width: fit-content;
max-width: calc(600px + 12px - 40px);
max-width: calc(var(--center-col-w) + 12px - 40px);
}
@mixin thread($align-end) {
@ -62,9 +62,8 @@
.messagesContent {
position: relative;
display: flex;
width: calc(var(--left-col-w) + var(--right-col-w) + 424px);
width: fit-content;
.senders {
width: 334px;
@ -249,6 +248,7 @@
height: calc(100vh - var(--header-height));
display: flex;
flex-direction: column-reverse;
width: 662px;
.messages {
width: 100%;
@ -322,7 +322,7 @@
.textAreaBorder {
padding: 1px;
border-radius: 8px;
width: 538px;
width: 576px;
height: 40px;
box-sizing: border-box;
@ -338,7 +338,7 @@
margin: 0;
padding-inline: 16px;
padding-block: 8px;
width: 536px;
width: 100%;
max-height: none;
&:focus {

247
src/pages/NoteThread.tsx Normal file
View File

@ -0,0 +1,247 @@
import { Component, createEffect, createMemo, For, onCleanup, onMount, Show } from 'solid-js';
import Note from '../components/Note/Note';
import styles from './NoteThread.module.scss';
import { useNavigate, useParams } from '@solidjs/router';
import { PrimalNote, PrimalUser, SendNoteResult } from '../types/primal';
import PeopleList from '../components/PeopleList/PeopleList';
import ReplyToNote from '../components/ReplyToNote/ReplyToNote';
import { nip19 } from 'nostr-tools';
import { useThreadContext } from '../contexts/ThreadContext';
import Wormhole from '../components/Wormhole/Wormhole';
import { useAccountContext } from '../contexts/AccountContext';
import { sortByRecency } from '../stores/note';
import { useIntl } from '@cookbook/solid-intl';
import Search from '../components/Search/Search';
import { placeholders as tPlaceholders, thread as t } from '../translations';
import { userName } from '../stores/profile';
import PageTitle from '../components/PageTitle/PageTitle';
import NavHeader from '../components/NavHeader/NavHeader';
import Loader from '../components/Loader/Loader';
import { isIOS } from '../components/BannerIOS/BannerIOS';
import { unwrap } from 'solid-js/store';
const NoteThread: Component<{ noteId: string }> = (props) => {
const account = useAccountContext();
const params = useParams();
const intl = useIntl();
const navigate = useNavigate();
let repliesHolder: HTMLDivElement | undefined;
let initialPostId = '';
const postId = () => {
const { noteId } = props;
if (noteId.startsWith('note')) {
return noteId;
}
if (noteId.startsWith('nevent')) {
return nip19.noteEncode(nip19.decode(noteId).data.id);
}
return nip19.noteEncode(noteId);
};
const threadContext = useThreadContext();
const primaryNote = createMemo(() => {
let note = threadContext?.notes.find(n => n.post.noteId === postId());
// Return the note if found
if (note) {
return note;
}
// Since there is no note see if this is a repost
note = threadContext?.notes.find(n => n.repost?.note.noteId === postId());
// If reposted note found redirect to it's thread
note && navigate(`/e/${note?.post.noteId}`)
return note;
});
const parentNotes = () => {
const note = primaryNote();
if (!note) {
return [];
}
return sortByRecency(
threadContext?.notes.filter(n =>
n.post.id !== note.post.id && n.post.created_at <= note.post.created_at,
) || [],
true,
);
};
const replyNotes = () => {
const note = primaryNote();
if (!note) {
return [];
}
return threadContext?.notes.filter(n =>
n.post.id !== note.post.id && n.post.created_at >= note.post.created_at,
) || [];
};
const people = () => {
const authors = (threadContext?.notes || []).
reduce<PrimalUser[]>((acc, n) => acc.find(u => u.pubkey === n.user.pubkey) ? [...acc] : [ ...acc, { ...n.user }], []);
const mentions = Object.values(primaryNote()?.mentionedUsers || {}).
filter((u) => !authors.find(a => u.pubkey === a.pubkey));
return [ ...authors, ...mentions ];
};
const isFetching = () => threadContext?.isFetching;
createEffect(() => {
const pid = postId();
if (pid !== initialPostId) {
threadContext?.actions.fetchNotes(pid);
initialPostId = pid;
}
});
let observer: IntersectionObserver | undefined;
createEffect(() => {
if (!primaryNote() || threadContext?.isFetching) return;
const pn = document.getElementById('primary_note');
if (!pn) return;
setTimeout(() => {
const threadHeader = 80;
const iOSBanner = 54;
const rect = pn.getBoundingClientRect();
const wh = window.innerHeight - threadHeader;
const block = rect.height < wh && parentNotes().length > 0 ?
'end' : 'start';
pn.scrollIntoView({ block });
if (block === 'start') {
const moreScroll = threadHeader + (isIOS() ? iOSBanner : 0);
window.scrollBy({ top: -moreScroll });
}
}, 100);
});
onCleanup(() => {
const pn = document.getElementById('primary_note');
pn && observer?.unobserve(pn);
});
const onNotePosted = (result: SendNoteResult) => {
threadContext?.actions.fetchNotes(postId());
};
return (
<div>
<PageTitle title={
intl.formatMessage(
t.pageTitle,
{ name: userName(primaryNote()?.user) },
)}
/>
<Wormhole
to="search_section"
>
<Search />
</Wormhole>
<Wormhole to='right_sidebar'>
<PeopleList
note={primaryNote()}
people={people()}
label={intl.formatMessage(t.sidebar)}
mentionLabel={intl.formatMessage(t.sidebarMentions)}
/>
</Wormhole>
<NavHeader title="Thread" />
<Show when={account?.isKeyLookupDone}>
<Show
when={!isFetching()}
fallback={<Loader />}
>
<div class={styles.parentsHolder}>
<For each={parentNotes()}>
{note =>
<div>
<Note
note={note}
parent={true}
shorten={true}
noteType="thread"
/>
</div>
}
</For>
</div>
<Show
when={primaryNote()}
fallback={
<div class={styles.missingNote}>
<p>
{intl.formatMessage(tPlaceholders.missingNote.firstLine)}
</p>
<p>
{intl.formatMessage(tPlaceholders.missingNote.secondLine)}
</p>
</div>
}>
<div id="primary_note">
<Note
note={primaryNote() as PrimalNote}
noteType="primary"
quoteCount={threadContext?.quoteCount}
/>
<Show when={account?.hasPublicKey()}>
<ReplyToNote
note={primaryNote() as PrimalNote}
onNotePosted={onNotePosted}
/>
</Show>
</div>
</Show>
<div class={styles.repliesHolder} ref={repliesHolder}>
<For each={replyNotes()}>
{note =>
<div>
<Note
note={note}
shorten={true}
noteType="thread"
/>
</div>
}
</For>
</div>
</Show>
</Show>
</div>
)
}
export default NoteThread;

View File

@ -18,7 +18,7 @@
position: fixed;
top: 140px;
left: calc(calc(100vw - var(--full-site-w)) / 2 + var(--left-col-w));
width: 600px;
width: var(--center-col-w);
z-index: 20;
display: flex;
justify-content: center;
@ -100,7 +100,7 @@
}
.notificationTabContent {
width: 602px;
width: 100%;
}
.notificationTabIndicator {

View File

@ -83,7 +83,14 @@ const Notifications: Component = () => {
const [relatedNotes, setRelatedNotes] = createStore<NotificationStore>({
notes: [],
users: [],
page: { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {} },
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
},
reposts: {},
})
@ -91,7 +98,15 @@ const Notifications: Component = () => {
notes: [],
users: {},
userStats: {},
page: { messages: [], users: {}, postStats: {}, notifications: [], mentions: {}, noteActions: {} },
page: {
messages: [],
users: {},
postStats: {},
notifications: [],
mentions: {},
noteActions: {},
topZaps: {},
},
reposts: {},
notifications: [],
})

View File

@ -19,13 +19,13 @@ import { useProfileContext } from '../contexts/ProfileContext';
import { useAccountContext } from '../contexts/AccountContext';
import Wormhole from '../components/Wormhole/Wormhole';
import { useIntl } from '@cookbook/solid-intl';
import { sanitize } from '../lib/notes';
import { sanitize, sendEvent } from '../lib/notes';
import { shortDate } from '../lib/dates';
import styles from './Profile.module.scss';
import StickySidebar from '../components/StickySidebar/StickySidebar';
import ProfileSidebar from '../components/ProfileSidebar/ProfileSidebar';
import { MenuItem, VanityProfiles, ZapOption } from '../types/primal';
import { MenuItem, PrimalUser, VanityProfiles, ZapOption } from '../types/primal';
import PageTitle from '../components/PageTitle/PageTitle';
import FollowButton from '../components/FollowButton/FollowButton';
import Search from '../components/Search/Search';
@ -44,6 +44,13 @@ import NoteImage from '../components/NoteImage/NoteImage';
import ProfileQrCodeModal from '../components/ProfileQrCodeModal/ProfileQrCodeModal';
import { CustomZapInfo, useAppContext } from '../contexts/AppContext';
import ProfileAbout from '../components/ProfileAbout/ProfileAbout';
import ButtonPrimary from '../components/Buttons/ButtonPrimary';
import { Tier, TierCost } from '../components/SubscribeToAuthorModal/SubscribeToAuthorModal';
import { Kind } from '../constants';
import { getAuthorSubscriptionTiers } from '../lib/feed';
import { zapSubscription } from '../lib/zap';
import { updateStore, store } from '../services/StoreService';
import { subsTo } from '../sockets';
const Profile: Component = () => {
@ -66,6 +73,8 @@ const Profile: Component = () => {
const [confirmMuteUser, setConfirmMuteUser] = createSignal(false);
const [openQr, setOpenQr] = createSignal(false);
const [hasTiers, setHasTiers] = createSignal(false);
const lightbox = new PhotoSwipeLightbox({
gallery: '#central_header',
children: 'a.profile_image',
@ -118,11 +127,13 @@ const Profile: Component = () => {
const setProfile = (hex: string | undefined) => {
profile?.actions.setProfileKey(hex);
profile?.actions.clearArticles();
profile?.actions.clearNotes();
profile?.actions.clearReplies();
profile?.actions.clearContacts();
profile?.actions.clearZaps();
profile?.actions.clearFilterReason();
setHasTiers(() => false);
}
let keyIsDone = false
@ -503,6 +514,94 @@ const Profile: Component = () => {
},
});
createEffect(() => {
if (profile?.userProfile) {
getTiers(profile.userProfile);
}
});
const getTiers = (author: PrimalUser) => {
if (!author) return;
const subId = `article_tiers_${APP_ID}`;
const unsub = subsTo(subId, {
onEvent: (_, content) => {
if (content.kind === Kind.TierList) {
return;
}
if (content.kind === Kind.Tier) {
setHasTiers(() => true);
return;
}
},
onEose: () => {
unsub();
},
})
getAuthorSubscriptionTiers(author.pubkey, subId);
}
const doSubscription = async (tier: Tier, cost: TierCost, exchangeRate?: Record<string, Record<string, number>>) => {
const a = profile?.userProfile;
if (!a || !account || !cost) return;
const subEvent = {
kind: Kind.Subscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', tier.id],
['amount', cost.amount, cost.unit, cost.cadence],
['event', JSON.stringify(tier.event)],
// Copy any zap splits
...(tier.event.tags?.filter(t => t[0] === 'zap') || []),
],
}
const { success, note } = await sendEvent(subEvent, account.relays, account.relaySettings);
if (success && note) {
const isZapped = await zapSubscription(note, a, account.publicKey, account.relays, exchangeRate);
if (!isZapped) {
unsubscribe(note.id);
}
}
}
const unsubscribe = async (eventId: string) => {
const a = profile?.userProfile;;
if (!a || !account) return;
const unsubEvent = {
kind: Kind.Unsubscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', eventId],
],
};
await sendEvent(unsubEvent, account.relays, account.relaySettings);
}
const openSubscribe = () => {
app?.actions.openAuthorSubscribeModal(profile?.userProfile, doSubscription);
};
return (
<>
<PageTitle title={
@ -599,6 +698,14 @@ const Profile: Component = () => {
<FollowButton person={profile?.userProfile} large={true} />
</Show>
<Show when={hasTiers()}>
<ButtonPrimary
onClick={openSubscribe}
>
subscribe
</ButtonPrimary>
</Show>
<Show when={isCurrentUser()}>
<div class={styles.editProfileButton}>
<ButtonSecondary

View File

@ -0,0 +1,3 @@
.article {
color: var(--text-primary);
}

190
src/pages/Reads.tsx Normal file
View File

@ -0,0 +1,190 @@
import {
Component,
createEffect,
createSignal,
For,
Match,
onCleanup,
onMount,
Show,
Switch
} from 'solid-js';
import Note from '../components/Note/Note';
import styles from './Home.module.scss';
import HomeHeader from '../components/HomeHeader/HomeHeader';
import Loader from '../components/Loader/Loader';
import Paginator from '../components/Paginator/Paginator';
import HomeSidebar from '../components/HomeSidebar/HomeSidebar';
import Branding from '../components/Branding/Branding';
import HomeHeaderPhone from '../components/HomeHeaderPhone/HomeHeaderPhone';
import Wormhole from '../components/Wormhole/Wormhole';
import { scrollWindowTo } from '../lib/scroll';
import StickySidebar from '../components/StickySidebar/StickySidebar';
import { useHomeContext } from '../contexts/HomeContext';
import { useIntl } from '@cookbook/solid-intl';
import { createStore } from 'solid-js/store';
import { PrimalUser } from '../types/primal';
import Avatar from '../components/Avatar/Avatar';
import { userName } from '../stores/profile';
import { useAccountContext } from '../contexts/AccountContext';
import { reads, branding } from '../translations';
import Search from '../components/Search/Search';
import { setIsHome } from '../components/Layout/Layout';
import PageTitle from '../components/PageTitle/PageTitle';
import { useAppContext } from '../contexts/AppContext';
import { useReadsContext } from '../contexts/ReadsContext';
import ArticlePreview from '../components/ArticlePreview/ArticlePreview';
import PageCaption from '../components/PageCaption/PageCaption';
import ReadsSidebar from '../components/HomeSidebar/ReadsSidebar';
import ReedSelect from '../components/FeedSelect/ReedSelect';
import ReadsHeader from '../components/HomeHeader/ReadsHeader';
const Home: Component = () => {
const context = useReadsContext();
const account = useAccountContext();
const intl = useIntl();
const app = useAppContext();
const isPageLoading = () => context?.isFetching;
let checkNewNotesTimer: number = 0;
const [hasNewPosts, setHasNewPosts] = createSignal(false);
const [newNotesCount, setNewNotesCount] = createSignal(0);
const [newPostAuthors, setNewPostAuthors] = createStore<PrimalUser[]>([]);
const newPostCount = () => newNotesCount() < 100 ? newNotesCount() : 100;
onMount(() => {
setIsHome(true);
scrollWindowTo(context?.scrollTop);
});
createEffect(() => {
if ((context?.future.notes.length || 0) > 99 || app?.isInactive) {
clearInterval(checkNewNotesTimer);
return;
}
const hex = context?.selectedFeed?.hex;
if (checkNewNotesTimer) {
clearInterval(checkNewNotesTimer);
setHasNewPosts(false);
setNewNotesCount(0);
setNewPostAuthors(() => []);
}
const timeout = 25_000 + Math.random() * 10_000;
checkNewNotesTimer = setInterval(() => {
context?.actions.checkForNewNotes(hex);
}, timeout);
});
createEffect(() => {
const count = context?.future.notes.length || 0;
if (count === 0) {
return
}
if (!hasNewPosts()) {
setHasNewPosts(true);
}
if (newPostAuthors.length < 3) {
const users = context?.future.notes.map(note => note.user) || [];
const uniqueUsers = users.reduce<PrimalUser[]>((acc, user) => {
const isDuplicate = acc.find(u => u && u.pubkey === user.pubkey);
return isDuplicate ? acc : [ ...acc, user ];
}, []).slice(0, 3);
setNewPostAuthors(() => [...uniqueUsers]);
}
setNewNotesCount(count);
});
onCleanup(()=> {
clearInterval(checkNewNotesTimer);
setIsHome(false);
});
const loadNewContent = () => {
if (newNotesCount() > 100 || app?.appState === 'waking') {
context?.actions.getFirstPage();
return;
}
context?.actions.loadFutureContent();
scrollWindowTo(0, true);
setHasNewPosts(false);
setNewNotesCount(0);
setNewPostAuthors(() => []);
}
onMount(() => {
context?.actions.doSidebarSearch('')
})
return (
<div class={styles.homeContent}>
<PageTitle title={intl.formatMessage(branding)} />
<Wormhole
to="search_section"
>
<Search />
</Wormhole>
<PageCaption title={intl.formatMessage(reads.pageTitle)}>
<ReadsHeader
hasNewPosts={() => {}}
loadNewContent={() => {}}
newPostCount={() => {}}
newPostAuthors={[]}
/>
</PageCaption>
<StickySidebar>
<ReadsSidebar />
</StickySidebar>
<div class={styles.readsFeed}>
<Show
when={context?.notes && context.notes.length > 0}
>
<div class={styles.feed}>
<For each={context?.notes} >
{note => <ArticlePreview article={note} />}
</For>
</div>
</Show>
<Switch>
<Match
when={!isPageLoading() && context?.notes && context?.notes.length === 0}
>
<div class={styles.noContent}>
<Loader />
</div>
</Match>
<Match
when={isPageLoading()}
>
<div class={styles.noContent}>
<Loader />
</div>
</Match>
</Switch>
<Paginator loadNextPage={context?.actions.fetchNextPage}/>
</div>
</div>
)
}
export default Home;

View File

@ -11,14 +11,14 @@
.searchHeader {
display: flex;
flex-direction: column;
width: 600px;
width: var(--center-col-w);
margin-left: -16px;
.caption {
display: flex;
align-items: center;
padding-left: 16px;
width: 600px;
width: var(--center-col-w);
height: 84px;
border-bottom: 1px solid var(--devider);
font-weight: 400;
@ -33,7 +33,7 @@
display: flex;
justify-content: flex-end;
align-items: center;
width: 600px;
width: var(--center-col-w);
height: 41px;
background: none;
border-bottom: 1px solid var(--devider);

View File

@ -2,7 +2,7 @@
background-color: black;
color: #bfbfbf;
max-width: 840px;
min-width: 600px;
min-width: var(--center-col-w);
margin-inline: auto;
min-height: 100vh;
padding-inline: 20px;

View File

@ -1,236 +1,60 @@
import { Component, createEffect, createMemo, For, onCleanup, onMount, Show } from 'solid-js';
import Note from '../components/Note/Note';
import styles from './Thread.module.scss';
import { useNavigate, useParams } from '@solidjs/router';
import { PrimalNote, PrimalUser, SendNoteResult } from '../types/primal';
import PeopleList from '../components/PeopleList/PeopleList';
import ReplyToNote from '../components/ReplyToNote/ReplyToNote';
import { nip19 } from 'nostr-tools';
import { useThreadContext } from '../contexts/ThreadContext';
import { Component, onMount } from 'solid-js';
import Branding from '../components/Branding/Branding';
import Wormhole from '../components/Wormhole/Wormhole';
import { useAccountContext } from '../contexts/AccountContext';
import { sortByRecency } from '../stores/note';
import { useIntl } from '@cookbook/solid-intl';
import Search from '../components/Search/Search';
import { placeholders as tPlaceholders, thread as t } from '../translations';
import { userName } from '../stores/profile';
import appstoreImg from '../assets/images/appstore_download.svg';
import playstoreImg from '../assets/images/playstore_download.svg';
import gitHubLight from '../assets/icons/github_light.svg';
import gitHubDark from '../assets/icons/github.svg';
import primalDownloads from '../assets/images/primal_downloads.png';
import styles from './Downloads.module.scss';
import { downloads as t } from '../translations';
import { useIntl } from '@cookbook/solid-intl';
import StickySidebar from '../components/StickySidebar/StickySidebar';
import { appStoreLink, playstoreLink, apkLink } from '../constants';
import ExternalLink from '../components/ExternalLink/ExternalLink';
import PageCaption from '../components/PageCaption/PageCaption';
import PageTitle from '../components/PageTitle/PageTitle';
import NavHeader from '../components/NavHeader/NavHeader';
import Loader from '../components/Loader/Loader';
import { isIOS } from '../components/BannerIOS/BannerIOS';
import { unwrap } from 'solid-js/store';
import { useSettingsContext } from '../contexts/SettingsContext';
import { useParams } from '@solidjs/router';
import NotFound from './NotFound';
import NoteThread from './NoteThread';
import { nip19 } from 'nostr-tools';
import Longform from './Longform';
const EventPage: Component = () => {
const Thread: Component = () => {
const account = useAccountContext();
const params = useParams();
const intl = useIntl();
const navigate = useNavigate();
let repliesHolder: HTMLDivElement | undefined;
const render = () => {
const { id } = params;
let initialPostId = '';
if (!id) return <NotFound />;
const postId = () => {
if (params.postId.startsWith('note')) {
return params.postId;
if (id.startsWith('naddr1')) {
return <Longform naddr={id} />
}
if (params.postId.startsWith('nevent')) {
return nip19.noteEncode(nip19.decode(params.postId).data.id);
if (id.startsWith('note1')) {
return <NoteThread noteId={id} />
}
return nip19.noteEncode(params.postId);
if (id.startsWith('nevent1')) {
const noteId = nip19.noteEncode(nip19.decode(id).data.id);
return <NoteThread noteId={noteId} />
}
const noteId = nip19.noteEncode(id);
return <NoteThread noteId={noteId} />
};
const threadContext = useThreadContext();
const primaryNote = createMemo(() => {
let note = threadContext?.notes.find(n => n.post.noteId === postId());
// Return the note if found
if (note) {
return note;
}
// Since there is no note see if this is a repost
note = threadContext?.notes.find(n => n.repost?.note.noteId === postId());
// If reposted note found redirect to it's thread
note && navigate(`/e/${note?.post.noteId}`)
return note;
});
const parentNotes = () => {
const note = primaryNote();
if (!note) {
return [];
}
return sortByRecency(
threadContext?.notes.filter(n =>
n.post.id !== note.post.id && n.post.created_at <= note.post.created_at,
) || [],
true,
);
};
const replyNotes = () => {
const note = primaryNote();
if (!note) {
return [];
}
return threadContext?.notes.filter(n =>
n.post.id !== note.post.id && n.post.created_at >= note.post.created_at,
) || [];
};
const people = () => {
const authors = (threadContext?.notes || []).
reduce<PrimalUser[]>((acc, n) => acc.find(u => u.pubkey === n.user.pubkey) ? [...acc] : [ ...acc, { ...n.user }], []);
const mentions = Object.values(primaryNote()?.mentionedUsers || {}).
filter((u) => !authors.find(a => u.pubkey === a.pubkey));
return [ ...authors, ...mentions ];
};
const isFetching = () => threadContext?.isFetching;
createEffect(() => {
const pid = postId();
if (pid !== initialPostId) {
threadContext?.actions.fetchNotes(pid);
initialPostId = pid;
}
});
let observer: IntersectionObserver | undefined;
createEffect(() => {
if (!primaryNote() || threadContext?.isFetching) return;
const pn = document.getElementById('primary_note');
if (!pn) return;
setTimeout(() => {
const threadHeader = 80;
const iOSBanner = 54;
const rect = pn.getBoundingClientRect();
const wh = window.innerHeight - threadHeader;
const block = rect.height < wh && parentNotes().length > 0 ?
'end' : 'start';
pn.scrollIntoView({ block });
if (block === 'start') {
const moreScroll = threadHeader + (isIOS() ? iOSBanner : 0);
window.scrollBy({ top: -moreScroll });
}
}, 100);
});
onCleanup(() => {
const pn = document.getElementById('primary_note');
pn && observer?.unobserve(pn);
});
const onNotePosted = (result: SendNoteResult) => {
threadContext?.actions.fetchNotes(postId());
};
return (
<div>
<PageTitle title={
intl.formatMessage(
t.pageTitle,
{ name: userName(primaryNote()?.user) },
)}
/>
<Wormhole
to="search_section"
>
<Search />
</Wormhole>
<Wormhole to='right_sidebar'>
<PeopleList
note={primaryNote()}
people={people()}
label={intl.formatMessage(t.sidebar)}
mentionLabel={intl.formatMessage(t.sidebarMentions)}
/>
</Wormhole>
<NavHeader title="Thread" />
<Show when={account?.isKeyLookupDone}>
<Show
when={!isFetching()}
fallback={<Loader />}
>
<div class={styles.parentsHolder}>
<For each={parentNotes()}>
{note =>
<div>
<Note note={note} parent={true} shorten={true} />
</div>
}
</For>
</div>
<Show
when={primaryNote()}
fallback={
<div class={styles.missingNote}>
<p>
{intl.formatMessage(tPlaceholders.missingNote.firstLine)}
</p>
<p>
{intl.formatMessage(tPlaceholders.missingNote.secondLine)}
</p>
</div>
}>
<div id="primary_note">
<Note
note={primaryNote() as PrimalNote}
noteType="primary"
quoteCount={threadContext?.quoteCount}
/>
<Show when={account?.hasPublicKey()}>
<ReplyToNote
note={primaryNote() as PrimalNote}
onNotePosted={onNotePosted}
/>
</Show>
</div>
</Show>
<div class={styles.repliesHolder} ref={repliesHolder}>
<For each={replyNotes()}>
{note =>
<div>
<Note note={note} shorten={true} />
</div>
}
</For>
</div>
</Show>
</Show>
</div>
)
return <>{render()}</>;
}
export default Thread;
export default EventPage;

View File

@ -62,6 +62,7 @@
--icon-network-popular: url('./assets/icons/network_popular.svg');
--icon-network-trending: url('./assets/icons/network_trending.svg');
--reads-placeholder-image: url('./assets/images/reads_image_dark.png');
select {
background-color: var(--background-site);
@ -133,6 +134,7 @@
--icon-network-popular: url('./assets/icons/network_popular.svg');
--icon-network-trending: url('./assets/icons/network_trending.svg');
--reads-placeholder-image: url('./assets/images/reads_image_dark.png');
select {
background-color: var(--background-site);
@ -203,6 +205,7 @@
--icon-network-popular: url('./assets/icons/network_popular.svg');
--icon-network-trending: url('./assets/icons/network_trending.svg');
--reads-placeholder-image: url('./assets/images/reads_image_light.png');
select {
background-color: var(--background-site);
@ -274,6 +277,7 @@
--icon-network-popular: url('./assets/icons/network_popular.svg');
--icon-network-trending: url('./assets/icons/network_trending.svg');
--reads-placeholder-image: url('./assets/images/reads_image_light.png');
select {
background-color: var(--background-site);

View File

@ -1,6 +1,6 @@
import { createSignal } from "solid-js";
import { logError, logInfo } from "./lib/logger";
import { NostrEvent, NostrEOSE, NostrEventType, NostrEventContent, PrimalWindow } from "./types/primal";
import { NostrEvent, NostrEOSE, NostrEventType, NostrEventContent, PrimalWindow, NostrNotice } from "./types/primal";
export const [socket, setSocket] = createSignal<WebSocket>();
@ -142,3 +142,39 @@ export const subTo = (socket: WebSocket, subId: string, cb: (type: NostrEventTyp
socket.removeEventListener('message', listener);
};
};
export const subsTo = (
subId: string,
handlers?: {
onEvent?: (subId: string, content: NostrEventContent) => void,
onNotice?: (subId: string, reason: string) => void,
onEose?: (subId: string) => void,
},
) => {
const listener = (event: MessageEvent) => {
const message: NostrEvent | NostrEOSE | NostrNotice = JSON.parse(event.data);
const [type, subscriptionId] = message;
if (handlers && subId === subscriptionId) {
if (type === 'EVENT') {
handlers.onEvent && handlers.onEvent(subscriptionId, message[2]);
}
if (type === 'EOSE') {
handlers.onEose && handlers.onEose(subscriptionId);
}
if (type === 'NOTICE') {
handlers.onNotice && handlers.onNotice(subscriptionId, message[2])
}
}
};
socket()?.addEventListener('message', listener);
return () => {
socket()?.removeEventListener('message', listener);
};
};

View File

@ -4,7 +4,7 @@ import { Kind } from "../constants";
import { hexToNpub } from "../lib/keys";
import { logError } from "../lib/logger";
import { sanitize } from "../lib/notes";
import { RepostInfo, NostrNoteContent, FeedPage, PrimalNote, PrimalRepost, NostrEventContent, NostrEOSE, NostrEvent, PrimalUser, TopZap } from "../types/primal";
import { RepostInfo, NostrNoteContent, FeedPage, PrimalNote, PrimalRepost, NostrEventContent, NostrEOSE, NostrEvent, PrimalUser, TopZap, PrimalArticle } from "../types/primal";
import { convertToUser, emptyUser } from "./profile";
@ -87,6 +87,33 @@ export const isRepostInCollection = (collection: NostrNoteContent[], repost: Nos
return false;
};
export const isLFRepostInCollection = (collection: NostrNoteContent[], repost: NostrNoteContent) => {
const otherTags = collection.reduce((acc: string[][], m) => {
if (m.kind !== Kind.Repost) return acc;
const t = m.tags.find(t => t[0] === 'e');
if (!t) return acc;
return [...acc, t];
}, []);
if (repost.kind === Kind.Repost) {
const tag = repost.tags.find(t => t[0] === 'e');
return tag && !!otherTags.find(t => t[1] === tag[1]);
}
if (repost.kind === Kind.LongForm) {
const id = repost.id;
return !!otherTags.find(t => t[1] === id);
}
return false;
};
export const isInTags = (tags: string[][], tagName: string, value: string) => {
@ -124,6 +151,22 @@ const parseKind6 = (message: NostrNoteContent) => {
}
};
const parseLFKind6 = (message: NostrNoteContent) => {
try {
return JSON.parse(message.content);
} catch (e) {
return {
kind: Kind.LongForm,
content: '',
id: message.id,
created_at: message.created_at,
pubkey: message.pubkey,
sig: message.sig,
tags: message.tags,
}
}
};
// const getNoteReferences = (message: NostrNoteContent) => {
// const regex = /\#\[([0-9]*)\]/g;
// let refs = [];
@ -323,11 +366,159 @@ export const convertToNotes: ConvertToNotes = (page, topZaps) => {
replyTo: replyTo && replyTo[1],
tags: msg.tags,
id: msg.id,
noteId: nip19.noteEncode(msg.id),
pubkey: msg.pubkey,
topZaps: [ ...tz ],
content: sanitize(msg.content),
relayHints: page.relayHints,
};
});
}
type ConvertToArticles = (page: FeedPage | undefined, topZaps?: Record<string, TopZap[]>) => PrimalArticle[];
export const convertToArticles: ConvertToArticles = (page, topZaps) => {
if (page === undefined) {
return [];
}
const mentions = page.mentions || {};
return page.messages.map((message) => {
const msg: NostrNoteContent = message.kind === Kind.Repost ? parseKind6(message) : message;
const pubkey = msg.pubkey;
const identifier = (msg.tags.find(t => t[0] === 'd') || [])[1];
const kind = msg.kind;
const user = page?.users[msg.pubkey];
const stat = page?.postStats[msg.id];
const mentionIds = Object.keys(mentions)
let userMentionIds = msg.tags?.reduce((acc, t) => t[0] === 'p' ? [...acc, t[1]] : acc, []);
let tz: TopZap[] = [];
if (topZaps && topZaps[msg.id]) {
tz = topZaps[msg.id] || [];
for(let i=0; i<tz.length; i++) {
if (userMentionIds.includes(tz[i].pubkey)) continue;
userMentionIds.push(tz[i].pubkey);
}
}
let mentionedNotes: Record<string, PrimalNote> = {};
let mentionedUsers: Record<string, PrimalUser> = {};
if (mentionIds.length > 0) {
for (let i = 0;i<mentionIds.length;i++) {
const id = mentionIds[i];
const m = mentions && mentions[id];
if (!m) {
continue;
}
for (let i = 0;i<m.tags.length;i++) {
const t = m.tags[i];
if (t[0] === 'p') {
mentionedUsers[t[1]] = convertToUser(page.users[t[1]] || emptyUser(t[1]));
}
}
mentionedNotes[id] = {
// @ts-ignore TODO: Investigate this typing
post: { ...m, noteId: nip19.noteEncode(m.id) },
user: convertToUser(page.users[m.pubkey] || emptyUser(m.pubkey)),
mentionedUsers,
};
}
}
if (userMentionIds && userMentionIds.length > 0) {
for (let i = 0;i<userMentionIds.length;i++) {
const id = userMentionIds[i];
const m = page.users && page.users[id];
mentionedUsers[id] = convertToUser(m || emptyUser(id));
}
}
const wordCount = page.wordCount ? page.wordCount[message.id] || 0 : 0;
const noActions = {
event_id: msg.id,
liked: false,
replied: false,
reposted: false,
zapped: false,
};
let article: PrimalArticle = {
id: msg.id,
pubkey: msg.pubkey,
title: '',
summary: '',
image: '',
tags: [],
published: msg.created_at || 0,
content: sanitize(msg.content),
user: user ? convertToUser(user) : emptyUser(msg.pubkey),
topZaps: [...tz],
naddr: nip19.naddrEncode({ identifier, pubkey, kind }),
noteId: nip19.naddrEncode({ identifier, pubkey, kind }),
msg,
mentionedNotes,
mentionedUsers,
wordCount,
noteActions: (page.noteActions && page.noteActions[msg.id]) ?? noActions,
likes: stat?.likes || 0,
mentions: stat?.mentions || 0,
reposts: stat?.reposts || 0,
replies: stat?.replies || 0,
zaps: stat?.zaps || 0,
score: stat?.score || 0,
score24h: stat?.score24h || 0,
satszapped: stat?.satszapped || 0,
relayHints: page.relayHints,
};
msg.tags.forEach(tag => {
switch (tag[0]) {
case 't':
article.tags.push(tag[1]);
break;
case 'title':
article.title = tag[1];
break;
case 'summary':
article.summary = tag[1];
break;
case 'image':
article.image = tag[1];
break;
case 'published':
article.published = parseInt(tag[1]);
break;
case 'client':
article.client = tag[1];
break;
default:
break;
}
});
return article;
});
}
const sortBy = (a: PrimalNote, b: PrimalNote, property: string) => {
const aData: Record<string, any> = a.repost ? a.repost.note : a.post;

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