Refactor thread route

This commit is contained in:
Bojan Mojsilovic 2024-05-23 16:36:56 +02:00
parent 3bbb03c401
commit 2a7476f979
9 changed files with 447 additions and 355 deletions

View File

@ -43,7 +43,6 @@ const Moderation = lazy(() => import('./pages/Settings/Moderation'));
const Menu = lazy(() => import('./pages/Settings/Menu'));
const Landing = lazy(() => import('./pages/Landing'));
const AppDownloadQr = lazy(() => import('./pages/appDownloadQr'));
const Longform = lazy(() => import('./pages/Longform'));
const Terms = lazy(() => import('./pages/Terms'));
const Privacy = lazy(() => import('./pages/Privacy'));
@ -110,9 +109,8 @@ 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="/l/:naddr" component={Longform} />
<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

@ -334,97 +334,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

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

@ -152,6 +152,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

@ -41,11 +41,23 @@
}
.summary {
color: var(--text-secondary);
font-family: Lora;
font-size: 15px;
font-weight: 400;
line-height: 22px;
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 {

View File

@ -28,6 +28,7 @@ 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,
@ -288,7 +289,7 @@ It converts "HTML", but keep intact partial entries like "xxxHTMLyyy" and so on.
`;
const Longform: Component = () => {
const Longform: Component< { naddr: string } > = (props) => {
const account = useAccountContext();
const params = useParams();
const intl = useIntl();
@ -300,6 +301,8 @@ const Longform: Component = () => {
// @ts-ignore
const [author, setAuthor] = createStore<PrimalUser>();
const naddr = () => props.naddr;
const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({
likes: 0,
liked: false,
@ -327,8 +330,7 @@ const Longform: Component = () => {
return;
}
const naddr = params.naddr;
const subId = `author_${naddr}_${APP_ID}`;
const subId = `author_${naddr()}_${APP_ID}`;
const unsub = subscribeTo(subId, (type, subId, content) =>{
if (type === 'EOSE') {
@ -355,9 +357,7 @@ const Longform: Component = () => {
});
createEffect(() => {
const naddr = params.naddr;
if (naddr === 'test') {
if (naddr() === 'test') {
setNote(() => ({
title: 'Test Long-Form Note',
@ -375,12 +375,12 @@ const Longform: Component = () => {
return;
}
if (typeof naddr === 'string' && naddr.startsWith('naddr')) {
const decoded = decodeIdentifier(naddr);
if (typeof naddr() === 'string' && naddr().startsWith('naddr')) {
const decoded = decodeIdentifier(naddr());
const { pubkey, identifier, kind } = decoded.data;
const subId = `naddr_${naddr}_${APP_ID}`;
const subId = `naddr_${naddr()}_${APP_ID}`;
const unsub = subscribeTo(subId, (type, subId, content) =>{
if (type === 'EOSE') {
@ -405,7 +405,7 @@ const Longform: Component = () => {
published: content.created_at || 0,
content: content.content,
author: content.pubkey,
topZaps: [],
topZaps: note.topZaps || [],
}
content.tags.forEach(tag => {
@ -473,8 +473,8 @@ const Longform: Component = () => {
const oldZaps = note.topZaps;
if (oldZaps === undefined) {
setNote('topZaps', () => [{ ...zap }]);
if (!oldZaps || oldZaps.length === 0) {
setNote((n) => ({ ...n, topZaps: [{ ...zap }]}));
return;
}
@ -484,14 +484,15 @@ const Longform: Component = () => {
const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
setNote('topZaps', () => [ ...newZaps ]);
setNote((n) => ({ ...n, topZaps: [...newZaps]}));
return;
}
}
});
getParametrizedEvent(pubkey, identifier, kind, subId);
// getThread(account?.publicKey, naddr, subId)
getArticleThread(account?.publicKey, pubkey, identifier, kind, subId);
}
})
@ -519,20 +520,13 @@ const Longform: Component = () => {
{note.title}
</div>
<div class={styles.summary}>
{note.summary}
</div>
<img class={styles.image} src={note.image} />
<div class={styles.tags}>
<For each={note.tags}>
{tag => (
<div class={styles.tag}>
{tag}
</div>
)}
</For>
<div class={styles.summary}>
<div class={styles.border}></div>
<div class={styles.text}>
{note.summary}
</div>
</div>
<NoteTopZaps
@ -543,6 +537,15 @@ const Longform: Component = () => {
<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 || ''}

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

@ -0,0 +1,238 @@
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} />
</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>
)
}
export default NoteThread;

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;