Compare commits

...

16 Commits

Author SHA1 Message Date
Dominik
9e10e7a9f3
Merge 6c73249ba7 into 55f6c0a4d7 2024-05-28 12:26:37 -05:00
Bojan Mojsilovic
55f6c0a4d7 New home header 2024-05-28 19:12:26 +02:00
Bojan Mojsilovic
b4857db4bf New feed note layout 2024-05-28 18:57:00 +02:00
Bojan Mojsilovic
5883af3336 Widen content 2024-05-28 17:48:38 +02:00
Bojan Mojsilovic
83059fffa8 New nav menu styling 2024-05-28 17:25:20 +02:00
Bojan Mojsilovic
e7cac58cdf Fix test page 2024-05-28 16:41:51 +02:00
Bojan Mojsilovic
d88364d705 Enable reactions 2024-05-28 16:41:51 +02:00
Bojan Mojsilovic
1580ee6e1b Reads page 2024-05-28 16:41:51 +02:00
Bojan Mojsilovic
6d7a828c2d WIP 2024-05-28 16:41:51 +02:00
Bojan Mojsilovic
2a7476f979 Refactor thread route 2024-05-28 16:32:39 +02:00
Bojan Mojsilovic
3bbb03c401 Adjust styling for lf notes 2024-05-24 16:15:30 +02:00
Bojan Mojsilovic
7e635a29ae First markdown render try 2024-05-24 16:15:30 +02:00
Bojan Mojsilovic
428e5050c9 Markdown test 2024-05-24 16:15:30 +02:00
Bojan Mojsilovic
9eab38fd08 Basic longform render 2024-05-24 16:15:30 +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
69 changed files with 13091 additions and 509 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} />

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

View File

@ -0,0 +1,117 @@
.article {
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;
}
}
.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: 20px;
font-weight: 700;
line-height: 28px;
}
.summary {
color: var(--text-primary);
font-family: Lora;
font-size: 15px;
font-weight: 400;
line-height: 22px;
}
}
.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: 4px;
}
.estimate {
display: inline-block;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
}
}
}
.image {
min-width: 164px;
img {
width: 164px;
object-fit: scale-down;
}
}
}
.zaps {
margin-bottom: 16px;
}
}

View File

@ -0,0 +1,250 @@
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 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 = '';
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,
});
return (
<A class={styles.article} href={`/e/${props.article.naddr}`}>
<div class={styles.header}>
<div class={styles.userInfo}>
<Avatar user={props.article.author} size="micro"/>
<div class={styles.userName}>{userName(props.article.author)}</div>
<VerificationCheck user={props.article.author} />
<div class={styles.nip05}>{props.article.author.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}>
<For each={props.article.tags}>
{tag => (
<div class={styles.tag}>
{tag}
</div>
)}
</For>
<div class={styles.estimate}>
{Math.ceil(props.article.wordCount / 238)} minute read
</div>
</div>
</div>
<div class={styles.image}>
<img src={props.article.image} />
</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,160 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, createSignal, Match, Show, Switch } from 'solid-js';
import { APP_ID } from '../../App';
import { Kind } from '../../constants';
import { useAccountContext } from '../../contexts/AccountContext';
import { useAppContext } from '../../contexts/AppContext';
import { getUserFeed } from '../../lib/feed';
import { logWarning } from '../../lib/logger';
import { getBookmarks, sendBookmarks } from '../../lib/profile';
import { subscribeTo } from '../../sockets';
import { 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, large?: boolean }> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
const [isBookmarked, setIsBookmarked] = createSignal(false);
const [bookmarkInProgress, setBookmarkInProgress] = createSignal(false);
createEffect(() => {
setIsBookmarked(() => account?.bookmarks.includes(props.note.id) || false);
})
const updateBookmarks = async (bookmarkTags: string[][]) => {
if (!account) return;
const bookmarks = bookmarkTags.reduce((acc, t) =>
t[0] === 'e' ? [...acc, t[1]] : [...acc]
, []);
const date = Math.floor((new Date()).getTime() / 1000);
account.actions.updateBookmarks(bookmarks)
saveBookmarks(account.publicKey, bookmarks);
const { success, note} = await sendBookmarks([...bookmarkTags], date, '', account?.relays, account?.relaySettings);
if (success && note) {
triggerImportEvents([note], `bookmark_import_${APP_ID}`)
}
};
const addBookmark = async (bookmarkTags: string[][]) => {
if (account && !bookmarkTags.find(b => b[0] === 'e' && b[1] === props.note.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

@ -117,8 +117,9 @@
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);
@ -140,7 +141,7 @@
}
.feedPlaceholder {
width: 600px;
width: var(--center-col-w);
height: 20px;
background-color: red;
}
@ -156,7 +157,6 @@
padding-block: 21px;
padding-inline: 12px;
border: none;
border-bottom: 1px solid var(--devider);
border-radius: 0;
justify-content: flex-start;

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

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

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

@ -1,4 +1,115 @@
.note {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
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;
@ -334,97 +445,97 @@
}
}
.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);
}
}
}
}
}
}
.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) => {
@ -313,7 +313,7 @@ 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)} />
</div>
<NoteTopZaps
@ -351,7 +351,6 @@ const Note: Component<{
</Match>
<Match when={noteType() === 'feed'}>
<A
id={props.id}
class={`${styles.note} ${props.parent ? styles.parent : ''}`}
@ -360,6 +359,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)}
/>
</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}
wide={true}
/>
</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()}>

View File

@ -0,0 +1,406 @@
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,
wide?: boolean,
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 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.author)) {
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.author)) {
return;
}
if (app?.customZap === undefined) {
doQuickZap();
}
};
const animateZap = () => {
setTimeout(() => {
props.updateState('hideZapIcon', () => true);
if (!medZapAnimation) {
return;
}
let newLeft = props.wide ? 15 : 13;
let newTop = props.wide ? -6 : -6;
if (props.large) {
newLeft = 2;
newTop = -9;
}
medZapAnimation.style.left = `${newLeft}px`;
medZapAnimation.style.top = `${newTop}px`;
const onAnimDone = () => {
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} ${props.wide ? styles.wide : ''}`} 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

@ -47,22 +47,22 @@ const NoteTopZaps: Component<{
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

@ -0,0 +1,41 @@
.primalMarkdown {
width: 100%;
.toolbar {
display: flex;
gap: 8px;
}
.editor {
margin-block: 8px;
* {
color: var(--text-primary);
}
p, li {
font-family: Lora;
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
a {
color: var(--accent-links);
font-family: Lora;
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
pre, code, mark {
background-color: var(--background-input);
}
ins {
color: var(--warning-bright);
}
}
}

View File

@ -0,0 +1,226 @@
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 { 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';
const PrimalMarkdown: Component<{
id?: string,
content?: string,
readonly?: boolean,
}> = (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 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 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('npub1')) {
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('note1')) {
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 class={styles.editor}>
<For each={htmlArray()}>
{el => (
<Switch fallback={<>{el}</>}>
<Match when={isMention(el)}>
{renderMention(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

@ -9,13 +9,13 @@
}
.selectionIcon {
width: 10px;
height: 10px;
width: 14px;
height: 14px;
display: inline-block;
margin-inline: 4px;
margin-inline: 8px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/caret.svg) no-repeat 0 0/ 10px 10px;
mask: url(../../assets/icons/caret.svg) no-repeat 0 0/ 10px 10px;
-webkit-mask: url(../../assets/icons/caret.svg) no-repeat 0 / 14px;
mask: url(../../assets/icons/caret.svg) no-repeat 0 / 14px;
}
.selectionBox {
@ -28,9 +28,9 @@
padding: 0;
border: none;
color: var(--text-secondary);
font-size: 18px;
font-weight: 600;
line-height: 20px;
font-size: 24px;
font-weight: 300;
line-height: 32px;
}
.listbox {

View File

@ -13,6 +13,7 @@ export const emptyPage: FeedPage = {
messages: [],
postStats: {},
noteActions: {},
topZaps: {},
}
export const nostrHighlights ='9a500dccc084a138330a1d1b2be0d5e86394624325d25084d3eca164e7ea698a';
@ -141,6 +142,7 @@ export enum Kind {
UserRelays=10_000_139,
RelayHint=10_000_141,
NoteQuoteStats=10_000_143,
WordCount=10_000_144,
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,7 +7,7 @@ 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";
@ -21,7 +21,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,

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

@ -0,0 +1,818 @@
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 } 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 { 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>,
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,
},
};
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_sidebar_${APP_ID}`;
updateStore('sidebar', 'isFetching', () => true);
updateStore('sidebar', 'notes', () => []);
updateStore('sidebar', 'page', { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {} });
getScoredUsers(account?.publicKey, query, 10, 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 [scope, timeframe] = topic.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, topic, `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);
console.log('READS STATS: ', stat)
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;
console.log('READS ACTIONS: ', content)
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_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 && settings?.defaultFeed) {
const storedFeed = fetchStoredFeed(account.publicKey);
selectFeed(storedFeed || settings?.defaultFeed);
}
});
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);

184
src/handleNotes.ts Normal file
View File

@ -0,0 +1,184 @@
import { nip19 } from "nostr-tools";
import { Kind } from "./constants";
import { getEvents } from "./lib/feed";
import { setLinkPreviews } from "./lib/notes";
import { updateStore, store } from "./services/StoreService";
import { subscribeTo } from "./sockets";
import { convertToNotes } from "./stores/note";
import { account } from "./translations";
import { FeedPage, NostrEventContent, NostrEventType, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalNote, 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 note: PrimalNote;
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;
}
};
});
};

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;
@ -61,9 +63,9 @@
--success-color: #66E205;
--left-col-w: 188px;
--center-col-w: 600px;
--center-col-w: 640px;
--right-col-w: 348px;
--full-site-w: 1137px;
--full-site-w: 1177px;
--header-height: 84px;
background-color: var(--background-site);
@ -124,6 +126,27 @@ 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);
}
.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

@ -46,6 +46,45 @@ 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, pubkey };
if (user_pubkey) {
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 } =
@ -152,6 +191,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,

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 { 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.author.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,

View File

@ -1,7 +1,7 @@
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 { PrimalArticle, PrimalNote, PrimalUser } from "../types/primal";
import { logError } from "./logger";
import { enableWebLn, sendPayment, signEvent } from "./nostrAPI";
@ -50,6 +50,51 @@ export const zapNote = async (note: PrimalNote, sender: string | undefined, amou
}
}
export const zapArticle = async (note: PrimalArticle, sender: string | undefined, amount: number, comment = '', relays: Relay[]) => {
if (!sender) {
return false;
}
const callback = await getZapEndpoint(note.author);
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)
};
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 zapProfile = async (profile: PrimalUser, sender: string | undefined, amount: number, comment = '', relays: Relay[]) => {
if (!sender || !profile) {
return false;

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

@ -8,7 +8,7 @@
color: var(--text-tertiary-2);
position: absolute;
bottom: 1280px;
width: 600px;
width: var(--center-col-w);
height: 100px;
}

View File

@ -0,0 +1,108 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--devider);
.author {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 6px;
.userName {
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
}
}
.time {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 700;
}
}
.longform {
display: flex;
flex-direction: column;
gap: 20px;
position: relative;
margin-bottom: 48px;
margin-inline: 20px;
.title {
color: var(--text-primary);
font-size: 32px;
font-weight: 700;
line-height: 40px;
}
.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-family: Lora;
font-size: 15px;
font-weight: 400;
line-height: 22px;
}
}
.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: 4px;
}
}
}

559
src/pages/Longform.tsx Normal file
View File

@ -0,0 +1,559 @@
import { useIntl } from "@cookbook/solid-intl";
import { useParams } from "@solidjs/router";
import { Component, createEffect, createSignal, For, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { APP_ID } from "../App";
import { Kind } from "../constants";
import { useAccountContext } from "../contexts/AccountContext";
import { decodeIdentifier } from "../lib/keys";
import { getParametrizedEvent } from "../lib/notes";
import { subscribeTo } from "../sockets";
import { SolidMarkdown } from "solid-markdown";
import styles from './Longform.module.scss';
import Loader from "../components/Loader/Loader";
import { NostrUserContent, PrimalUser, TopZap } from "../types/primal";
import { getUserProfileInfo } from "../lib/profile";
import { convertToUser, userName } from "../stores/profile";
import Avatar from "../components/Avatar/Avatar";
import { shortDate } from "../lib/dates";
import hljs from 'highlight.js'
import mdFoot from 'markdown-it-footnote';
import { full as mdEmoji } from 'markdown-it-emoji';
import PrimalMarkdown from "../components/PrimalMarkdown/PrimalMarkdown";
import NoteTopZaps from "../components/Note/NoteTopZaps";
import { parseBolt11 } from "../utils";
import { NoteReactionsState } from "../components/Note/Note";
import NoteFooter from "../components/Note/NoteFooter/NoteFooter";
import { getArticleThread, getThread } from "../lib/feed";
export type LongFormData = {
title: string,
summary: string,
image: string,
tags: string[],
published: number,
content: string,
author: string,
topZaps: TopZap[],
};
const emptyLongNote = {
title: '',
summary: '',
image: '',
tags: [],
published: 0,
content: '',
author: '',
topZaps: [],
}
const test = `
# h1 Heading 8-)
## h2 Heading
### h3 Heading
#### h4 Heading
##### h5 Heading
###### h6 Heading
## Mentions
nostr:npub19f2765hdx8u9lz777w7azed2wsn9mqkf2gvn67mkldx8dnxvggcsmhe9da
nostr:note1tv033d7y088x8e90n5ut8htlsyy4yuwsw2fpgywq62w8xf0qcv8q8xvvhg
## Horizontal Rules
___
---
***
## Typographic replacements
Enable typographer option to see result.
(c) (C) (r) (R) (tm) (TM) (p) (P) +-
test.. test... test..... test?..... test!....
!!!!!! ???? ,, -- ---
"Smartypants, double quotes" and 'single quotes'
## Emphasis
**This is bold text**
__This is bold text__
*This is italic text*
_This is italic text_
~~Strikethrough~~
## Blockquotes
> Blockquotes can also be nested...
>> ...by using additional greater-than signs right next to each other...
> > > ...or with spaces between arrows.
## Lists
Unordered
+ Create a list by starting a line with \`+\`, \`-\`, or \`*\`
+ Sub-lists are made by indenting 2 spaces:
- Marker character change forces new list start:
* Ac tristique libero volutpat at
+ Facilisis in pretium nisl aliquet
- Nulla volutpat aliquam velit
+ Very easy!
Ordered
1. Lorem ipsum dolor sit amet
2. Consectetur adipiscing elit
3. Integer molestie lorem at massa
1. You can use sequential numbers...
1. ...or keep all the numbers as \`1.\`
Start numbering with offset:
57. foo
1. bar
## Code
Inline \`code\`
Indented code
// Some comments
line 1 of code
line 2 of code
line 3 of code
Block code "fences"
\`\`\`
Sample text here...
\`\`\`
Syntax highlighting
\`\`\` js
var foo = function (bar) {
return bar++;
};
console.log(foo(5));
\`\`\`
## Tables
| Option | Description |
| ------ | ----------- |
| data | path to data files to supply the data that will be passed into templates. |
| engine | engine to be used for processing templates. Handlebars is the default. |
| ext | extension to be used for dest files. |
Right aligned columns
| Option | Description |
| ------:| -----------:|
| data | path to data files to supply the data that will be passed into templates. |
| engine | engine to be used for processing templates. Handlebars is the default. |
| ext | extension to be used for dest files. |
## Links
[link text](http://dev.nodeca.com)
[link with title](http://nodeca.github.io/pica/demo/ "title text!")
Autoconverted link https://github.com/nodeca/pica (enable linkify to see)
## Images
![Minion](https://octodex.github.com/images/minion.png)
![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat")
Like links, Images also have a footnote style syntax
![Alt text][id]
With a reference later in the document defining the URL location:
[id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat"
## Plugins
The killer feature of \`markdown-it\` is very effective support of
[syntax plugins](https://www.npmjs.org/browse/keyword/markdown-it-plugin).
### [Emojies](https://github.com/markdown-it/markdown-it-emoji)
> Classic markup: :wink: :cry: :laughing: :yum:
>
> Shortcuts (emoticons): :-) :-( 8-) ;)
see [how to change output](https://github.com/markdown-it/markdown-it-emoji#change-output) with twemoji.
### [Subscript](https://github.com/markdown-it/markdown-it-sub) / [Superscript](https://github.com/markdown-it/markdown-it-sup)
- 19^th^
- H~2~O
### [\<ins>](https://github.com/markdown-it/markdown-it-ins)
there is some ++Inserted text++ here
### [\<mark>](https://github.com/markdown-it/markdown-it-mark)
==Marked text==
### [Footnotes](https://github.com/markdown-it/markdown-it-footnote)
Footnote 1 link[^first].
Footnote 2 link[^second].
Inline footnote^[Text of inline footnote] definition.
Duplicated footnote reference[^second].
[^first]: Footnote **can have markup**
and multiple paragraphs.
[^second]: Footnote text.
### [Definition lists](https://github.com/markdown-it/markdown-it-deflist)
Term 1
: Definition 1
with lazy continuation.
Term 2 with *inline markup*
: Definition 2
{ some code, part of Definition 2 }
Third paragraph of definition 2.
_Compact style:_
Term 1
~ Definition 1
Term 2
~ Definition 2a
~ Definition 2b
### [Abbreviations](https://github.com/markdown-it/markdown-it-abbr)
This is HTML abbreviation example.
It converts "HTML", but keep intact partial entries like "xxxHTMLyyy" and so on.
*[HTML]: Hyper Text Markup Language
`;
const Longform: Component< { naddr: string } > = (props) => {
const account = useAccountContext();
const params = useParams();
const intl = useIntl();
const [note, setNote] = createStore<LongFormData>({...emptyLongNote});
const [pubkey, setPubkey] = createSignal<string>('');
// @ts-ignore
const [author, setAuthor] = createStore<PrimalUser>();
const naddr = () => props.naddr;
const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({
likes: 0,
liked: false,
reposts: 0,
reposted: false,
replies: 0,
replied: false,
zapCount: 0,
satsZapped: 0,
zapped: false,
zappedAmount: 0,
zappedNow: false,
isZapping: false,
showZapAnim: false,
hideZapIcon: false,
moreZapsAvailable: false,
isRepostMenuVisible: false,
topZaps: [],
topZapsFeed: [],
quoteCount: 0,
});
createEffect(() => {
if (!pubkey()) {
return;
}
const subId = `author_${naddr()}_${APP_ID}`;
const unsub = subscribeTo(subId, (type, subId, content) =>{
if (type === 'EOSE') {
unsub();
return;
}
if (type === 'EVENT') {
if (!content) {
return;
}
if(content.kind === Kind.Metadata) {
const userContent = content as NostrUserContent;
const user = convertToUser(userContent);
setAuthor(() => ({ ...user }));
}
}
})
getUserProfileInfo(pubkey(), account?.publicKey, subId);
});
createEffect(() => {
if (naddr() === 'naddr1_test') {
setNote(() => ({
title: 'Test Long-Form Note',
summary: 'This is a markdown test to show all elements of the markdown',
image: '',
tags: ['test', 'markdown', 'demo'],
published: (new Date()).getTime() / 1_000,
content: test,
author: account?.publicKey,
topZaps: [],
}));
setPubkey(() => note.author);
return;
}
if (typeof naddr() === 'string' && naddr().startsWith('naddr')) {
const decoded = decodeIdentifier(naddr());
const { pubkey, identifier, kind } = decoded.data;
const subId = `naddr_${naddr()}_${APP_ID}`;
const unsub = subscribeTo(subId, (type, subId, content) =>{
if (type === 'EOSE') {
unsub();
return;
}
if (type === 'EVENT') {
if (!content) {
return;
}
if(content.kind === Kind.LongForm) {
setPubkey(() => content.pubkey);
let n: LongFormData = {
title: '',
summary: '',
image: '',
tags: [],
published: content.created_at || 0,
content: content.content,
author: content.pubkey,
topZaps: note.topZaps || [],
}
content.tags.forEach(tag => {
switch (tag[0]) {
case 't':
n.tags.push(tag[1]);
break;
case 'title':
n.title = tag[1];
break;
case 'summary':
n.summary = tag[1];
break;
case 'image':
n.image = tag[1];
break;
case 'published':
n.published = parseInt(tag[1]);
break;
case 'content':
n.content = tag[1];
break;
case 'author':
n.author = tag[1];
break;
default:
break;
}
});
setNote(() => ({...n}));
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
const oldZaps = note.topZaps;
if (!oldZaps || oldZaps.length === 0) {
setNote((n) => ({ ...n, topZaps: [{ ...zap }]}));
return;
}
if (oldZaps.find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
setNote((n) => ({ ...n, topZaps: [...newZaps]}));
return;
}
}
});
// getThread(account?.publicKey, naddr, subId)
getArticleThread(account?.publicKey, pubkey, identifier, kind, subId);
}
})
return (
<>
<div class={styles.header}>
<div class={styles.author}>
<Show when={author}>
<Avatar user={author} size="xs" />
<div class={styles.userName}>
{userName(author)}
</div>
</Show>
</div>
<div class={styles.time}>
{shortDate(note.published)}
</div>
</div>
<div class={styles.longform}>
<Show
when={note.content.length > 0}
fallback={<Loader />}
>
<div class={styles.title}>
{note.title}
</div>
<img class={styles.image} src={note.image} />
<div class={styles.summary}>
<div class={styles.border}></div>
<div class={styles.text}>
{note.summary}
</div>
</div>
<NoteTopZaps
topZaps={note.topZaps}
zapCount={reactionsState.zapCount}
action={() => {}}
/>
<PrimalMarkdown content={note.content || ''} readonly={true} />
<div class={styles.tags}>
<For each={note.tags}>
{tag => (
<div class={styles.tag}>
{tag}
</div>
)}
</For>
</div>
{/* <div class={styles.content} innerHTML={inner()}>
<SolidMarkdown
children={note.content || ''}
/>
</div> */}
</Show>
</div>
</>);
}
export default Longform;

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;

View File

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

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

@ -0,0 +1,174 @@
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';
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.author) || [];
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(() => []);
}
return (
<div class={styles.homeContent}>
<PageTitle title={intl.formatMessage(branding)} />
<Wormhole
to="search_section"
>
<Search />
</Wormhole>
<PageCaption title={intl.formatMessage(reads.pageTitle)} />
<StickySidebar>
<HomeSidebar />
</StickySidebar>
<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>
)
}
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

@ -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,151 @@ export const convertToNotes: ConvertToNotes = (page, topZaps) => {
replyTo: replyTo && replyTo[1],
tags: msg.tags,
id: msg.id,
pubkey: msg.pubkey,
topZaps: [ ...tz ],
};
});
}
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: msg.content,
author: convertToUser(user),
topZaps: [...tz],
naddr: 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,
};
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;
default:
break;
}
});
return article;
});
}
const sortBy = (a: PrimalNote, b: PrimalNote, property: string) => {
const aData: Record<string, any> = a.repost ? a.repost.note : a.post;

View File

@ -68,10 +68,13 @@ export const userName = (user: PrimalUser | undefined) => {
if (!user) {
return '';
}
const npub = user.npub || hexToNpub(user.pubkey) || '';
const name = user.display_name ||
user.displayName ||
user.name ||
truncateNpub(user.npub);
truncateNpub(npub);
return name ?
name :
@ -82,10 +85,12 @@ export const authorName = (user: PrimalUser | undefined) => {
if (!user) {
return '';
}
const npub = user.npub || hexToNpub(user.pubkey) || '';
const name = user.display_name ||
user.displayName ||
user.name ||
truncateNpub(user.npub);
truncateNpub(npub);
return name ?
name :

View File

@ -709,6 +709,11 @@ export const navBar = {
defaultMessage: 'Home',
description: 'Label for the nav bar item link to Home page',
},
reads: {
id: 'navbar.reads',
defaultMessage: 'Reads',
description: 'Label for the nav bar item link to Reads page',
},
explore: {
id: 'navbar.explore',
defaultMessage: 'Explore',
@ -2127,6 +2132,14 @@ export const followWarning = {
},
};
export const reads = {
pageTitle: {
id: 'reads.pageTitle',
defaultMessage: 'Reads',
description: 'Reads page title',
},
};
export const bookmarks = {
pageTitle: {
id: 'bookmarks.pageTitle',

48
src/types/primal.d.ts vendored
View File

@ -235,6 +235,13 @@ export type NostrQuoteStatsInfo = {
tags?: string[][],
};
export type NostrWordCount = {
kind: Kind.WordCount,
content: string,
created_at?: number,
tags?: string[][],
};
export type NostrEventContent =
NostrNoteContent |
NostrUserContent |
@ -266,7 +273,8 @@ export type NostrEventContent =
NostrBookmarks |
NostrRelayHint |
NostrZapInfo |
NostrQuoteStatsInfo;
NostrQuoteStatsInfo |
NostrWordCount;
export type NostrEvent = [
type: "EVENT",
@ -279,6 +287,12 @@ export type NostrEOSE = [
subkey: string,
];
export type NostrNotice = [
type: "NOTICE",
subkey: string,
reason: string,
];
export type NoteActions = {
event_id: string,
liked: boolean,
@ -328,6 +342,7 @@ export type FeedPage = {
topZaps: Record<string, TopZap[]>,
since?: number,
until?: number,
wordCount?: Record<string, number>,
};
export type TrendingNotesStore = {
@ -482,10 +497,41 @@ export type PrimalNote = {
mentionedUsers?: Record<string, PrimalUser>,
replyTo?: string,
id: string,
pubkey: string,
tags: string[][],
topZaps: TopZap[],
};
export type PrimalArticle = {
title: string,
summary: string,
image: string,
tags: string[],
published: number,
content: string,
author: PrimalUser,
topZaps: TopZap[],
repost?: PrimalRepost,
mentionedNotes?: Record<string, PrimalNote>,
mentionedUsers?: Record<string, PrimalUser>,
replyTo?: string,
id: string,
pubkey: string,
naddr: string,
msg: NostrNoteContent,
wordCount: number,
noteActions: NoteActions,
likes: number,
mentions: number,
reposts: number,
replies: number,
zaps: number,
score: number,
score24h: number,
satszapped: number,
};
export type PrimalFeed = {
name: string,
npub?: string,