diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index 15371630..bb4ba8dc 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -176,7 +176,7 @@ - + @@ -296,6 +296,34 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app/src/Element/Deck/Articles.tsx b/packages/app/src/Element/Deck/Articles.tsx new file mode 100644 index 00000000..c1240f00 --- /dev/null +++ b/packages/app/src/Element/Deck/Articles.tsx @@ -0,0 +1,10 @@ +import { useArticles } from "Feed/ArticlesFeed"; +import { orderDescending } from "SnortUtils"; +import Note from "../Note"; + +export default function Articles() { + const data = useArticles(); + return <> + {orderDescending(data.data ?? []).map(a => )} + +} \ No newline at end of file diff --git a/packages/app/src/Element/Deck/Nav.css b/packages/app/src/Element/Deck/Nav.css new file mode 100644 index 00000000..439fe474 --- /dev/null +++ b/packages/app/src/Element/Deck/Nav.css @@ -0,0 +1,12 @@ +nav.deck { + width: 48px; + height: calc(100vh - 20px); + padding: 10px 8px; + border-right: 1px solid var(--border-color); + text-align: center; +} + +nav.deck .avatar { + width: 40px; + height: 40px; +} \ No newline at end of file diff --git a/packages/app/src/Element/Deck/Nav.tsx b/packages/app/src/Element/Deck/Nav.tsx new file mode 100644 index 00000000..7a501dd3 --- /dev/null +++ b/packages/app/src/Element/Deck/Nav.tsx @@ -0,0 +1,42 @@ +import { useUserProfile } from "@snort/system-react"; +import Avatar from "Element/Avatar"; +import useLogin from "Hooks/useLogin"; +import "./Nav.css"; +import Icon from "Icons/Icon"; +import { Link } from "react-router-dom"; +import { profileLink } from "SnortUtils"; + +export function DeckNav() { + const { publicKey } = useLogin(); + const profile = useUserProfile(publicKey); + + const unreadDms = 0; + const hasNotifications = false; + + return +} \ No newline at end of file diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index 85deb9dc..875e4939 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -3,7 +3,7 @@ import React, { useMemo, useState, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useInView } from "react-intersection-observer"; import { useIntl, FormattedMessage } from "react-intl"; -import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap } from "@snort/system"; +import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap, tagToNostrLink, createNostrLinkToEvent } from "@snort/system"; import { System } from "index"; import useEventPublisher from "Feed/EventPublisher"; @@ -11,7 +11,6 @@ import Icon from "Icons/Icon"; import ProfileImage from "Element/ProfileImage"; import Text from "Element/Text"; import { - eventLink, getReactions, dedupeByPubkey, tagFilterOfTextRepost, @@ -40,6 +39,7 @@ import NoteReaction from "Element/NoteReaction"; import ProfilePreview from "Element/ProfilePreview"; import messages from "./messages"; +import { ProxyImg } from "./ProxyImg"; export interface NoteProps { data: TaggedNostrEvent; @@ -193,8 +193,42 @@ export function NoteInner(props: NoteProps) { } } + const innerContent = () => { + if (ev.kind === EventKind.LongFormTextNote) { + const title = findTag(ev, "title"); + const summary = findTag(ev, "simmary"); + const image = findTag(ev, "image"); + return ( +
+

+ {title} +

+
+

+ {summary} +

+ + {image && } +
+ +
+ ) + } else { + const body = ev?.content ?? ""; + return ( + + ); + } + } + const transformBody = () => { - const body = ev?.content ?? ""; if (deletions?.length > 0) { return ( @@ -222,20 +256,11 @@ export function NoteInner(props: NoteProps) { )} }> - + {innerContent()} ); } - return ( - - ); + return innerContent(); }; function goToEvent( @@ -253,13 +278,13 @@ export function NoteInner(props: NoteProps) { return; } - const link = eventLink(eTarget.id, eTarget.relays); + const link = createNostrLinkToEvent(eTarget); // detect cmd key and open in new tab if (e.metaKey) { - window.open(link, "_blank"); + window.open(`/e/${link.encode()}`, "_blank"); } else { - navigate(link, { - state: ev, + navigate(`/e/${link.encode()}`, { + state: eTarget, }); } } @@ -271,8 +296,8 @@ export function NoteInner(props: NoteProps) { } const maxMentions = 2; - const replyId = thread?.replyTo?.value ?? thread?.root?.value; - const replyRelayHints = thread?.replyTo?.relay ?? thread.root?.relay; + const replyTo = thread?.replyTo ?? thread?.root; + const replyLink = replyTo ? tagToNostrLink([replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0)) : undefined; const mentions: { pk: string; name: string; link: ReactNode }[] = []; for (const pk of thread?.pubKeys ?? []) { const u = UserCache.getFromCache(pk); @@ -305,9 +330,9 @@ export function NoteInner(props: NoteProps) { {pubMentions} {others} ) : ( - replyId && ( - - {hexToBech32(NostrPrefix.Event, replyId)?.substring(0, 12)} + replyLink && ( + + {replyLink.encode().substring(0, 12)} ) )} @@ -315,7 +340,7 @@ export function NoteInner(props: NoteProps) { ); } - const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls]; + const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls, EventKind.LongFormTextNote]; if (!canRenderAsTextNote.includes(ev.kind)) { const alt = findTag(ev, "alt"); if (alt) { @@ -393,7 +418,7 @@ export function NoteInner(props: NoteProps) { {options.showContextMenu && ( {}} + react={async () => { }} onTranslated={t => setTranslated(t)} setShowReactions={setShowReactions} /> diff --git a/packages/app/src/Element/ProfileImage.css b/packages/app/src/Element/ProfileImage.css index 70c62ec0..c48468cc 100644 --- a/packages/app/src/Element/ProfileImage.css +++ b/packages/app/src/Element/ProfileImage.css @@ -12,6 +12,8 @@ width: 48px; height: 48px; cursor: pointer; + position: relative; + z-index: 2; } a.pfp { diff --git a/packages/app/src/Element/RootTabs.css b/packages/app/src/Element/RootTabs.css new file mode 100644 index 00000000..cd715766 --- /dev/null +++ b/packages/app/src/Element/RootTabs.css @@ -0,0 +1,18 @@ +.root-type { + display: flex; + align-items: center; + justify-content: center; + } + + .root-type > button { + background: white; + color: black; + font-size: 16px; + padding: 10px 16px; + border-radius: 1000px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + } + \ No newline at end of file diff --git a/packages/app/src/Element/RootTabs.tsx b/packages/app/src/Element/RootTabs.tsx new file mode 100644 index 00000000..95f758d7 --- /dev/null +++ b/packages/app/src/Element/RootTabs.tsx @@ -0,0 +1,151 @@ +import "./RootTabs.css"; +import { useState, ReactNode, useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Menu, MenuItem } from "@szhsin/react-menu"; +import { FormattedMessage } from "react-intl"; + +import useLogin from "Hooks/useLogin"; +import Icon from "Icons/Icon"; + + +export type RootTab = "following" | "conversations" | "trending-notes" | "trending-people" | "suggested" | "tags" | "global"; + +export function RootTabs({ base }: { base?: string }) { + const navigate = useNavigate(); + const location = useLocation(); + const { publicKey: pubKey, tags } = useLogin(); + const [rootType, setRootType] = useState("following"); + + const menuItems = [ + { + tab: "following", + path: `${base}/notes`, + show: Boolean(pubKey), + element: ( + <> + + + + ), + }, + { + tab: "trending-notes", + path: `${base}/trending/notes`, + show: true, + element: ( + <> + + + + ), + }, + { + tab: "conversations", + path: `${base}/conversations`, + show: Boolean(pubKey), + element: ( + <> + + + + ), + }, + { + tab: "trending-people", + path: `${base}/trending/people`, + show: true, + element: ( + <> + + + + ), + }, + { + tab: "suggested", + path: `${base}/suggested`, + show: Boolean(pubKey), + element: ( + <> + + + + ), + }, + { + tab: "global", + path: `${base}/global`, + show: true, + element: ( + <> + + + + ), + }, + ] as Array<{ + tab: RootTab; + path: string; + show: boolean; + element: ReactNode; + }>; + + useEffect(() => { + const currentTab = menuItems.find(a => a.path === location.pathname)?.tab; + if (currentTab) { + setRootType(currentTab); + } + }, [location]); + + function currentMenuItem() { + if (location.pathname.startsWith(`${base}/t/`)) { + return ( + <> + + {location.pathname.split("/").slice(-1)} + + ); + } + return menuItems.find(a => a.tab === rootType)?.element; + } + + + return ( +
+ + {currentMenuItem()} + + + } + align="center" + menuClassName={() => "ctx-menu"}> +
+ +
+ +
+ {menuItems + .filter(a => a.show) + .map(a => ( + { + navigate(a.path); + }}> + {a.element} + + ))} + {tags.item.map(v => ( + { + navigate(`${base}/t/${v}`); + }}> + + {v} + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/packages/app/src/Element/SpotlightMedia.css b/packages/app/src/Element/SpotlightMedia.css index 4e5862e2..e24a159e 100644 --- a/packages/app/src/Element/SpotlightMedia.css +++ b/packages/app/src/Element/SpotlightMedia.css @@ -10,15 +10,15 @@ background: transparent; } -.modal.spotlight img, -.modal.spotlight video { +.spotlight img, +.spotlight video { max-width: 100vw; - max-height: 100vh; + max-height: 99vh; aspect-ratio: unset; width: unset; } -.modal.spotlight .details { +.spotlight .details { text-align: right; position: absolute; top: 28px; @@ -29,16 +29,17 @@ font-weight: 400; line-height: 24px; align-items: center; + user-select: none; } -.modal.spotlight .left { +.spotlight .left { position: absolute; left: 24px; top: 50vh; transform: rotate(180deg); } -.modal.spotlight .right { +.spotlight .right { position: absolute; right: 24px; top: 50vh; diff --git a/packages/app/src/Element/SpotlightMedia.tsx b/packages/app/src/Element/SpotlightMedia.tsx index f617fe48..dc4afef3 100644 --- a/packages/app/src/Element/SpotlightMedia.tsx +++ b/packages/app/src/Element/SpotlightMedia.tsx @@ -37,7 +37,7 @@ export function SpotlightMedia(props: SpotlightMediaProps) { }); } return ( - +
{idx + 1}/{props.images.length} @@ -49,6 +49,15 @@ export function SpotlightMedia(props: SpotlightMediaProps) { inc()} /> )} +
+ ); +} + + +export function SpotlightMediaModal(props: SpotlightMediaProps) { + return ( + + ); } diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index 91726f60..852bb470 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -1,6 +1,6 @@ import "./Text.css"; -import { useMemo, useState } from "react"; -import { HexKey, ParsedFragment, transformText } from "@snort/system"; +import { useState } from "react"; +import { HexKey, ParsedFragment } from "@snort/system"; import Invoice from "Element/Invoice"; import Hashtag from "Element/Hashtag"; @@ -8,7 +8,8 @@ import HyperText from "Element/HyperText"; import CashuNuts from "Element/CashuNuts"; import RevealMedia from "./RevealMedia"; import { ProxyImg } from "./ProxyImg"; -import { SpotlightMedia } from "./SpotlightMedia"; +import { SpotlightMediaModal } from "./SpotlightMedia"; +import { useTextTransformer } from "Hooks/useTextTransformCache"; export interface TextProps { id: string; @@ -24,8 +25,6 @@ export interface TextProps { onClick?: (e: React.MouseEvent) => void; } -const TextCache = new Map>(); - export default function Text({ id, content, @@ -42,13 +41,7 @@ export default function Text({ const [showSpotlight, setShowSpotlight] = useState(false); const [imageIdx, setImageIdx] = useState(0); - const elements = useMemo(() => { - const cached = TextCache.get(id); - if (cached) return cached; - const newCache = transformText(content, tags); - TextCache.set(id, newCache); - return newCache; - }, [content, id]); + const elements = useTextTransformer(id, content, tags); const images = elements.filter(a => a.type === "media" && a.mimeType?.startsWith("image")).map(a => a.content); @@ -114,7 +107,7 @@ export default function Text({ return (
{renderContent()} - {showSpotlight && setShowSpotlight(false)} idx={imageIdx} />} + {showSpotlight && setShowSpotlight(false)} idx={imageIdx} />}
); } diff --git a/packages/app/src/Element/Thread.css b/packages/app/src/Element/Thread.css index 4d2c6d2e..2969a821 100644 --- a/packages/app/src/Element/Thread.css +++ b/packages/app/src/Element/Thread.css @@ -55,54 +55,46 @@ position: absolute; left: calc(48px / 2 + 16px); top: 48px; - border-left: 1px solid var(--gray-superdark); + border-left: 1px solid var(--border-color); height: 100%; - z-index: -1; + z-index: 1; } .subthread-container.subthread-mid:not(.subthread-last) .line-container:before { content: ""; position: absolute; - border-left: 1px solid var(--gray-superdark); + border-left: 1px solid var(--border-color); left: calc(48px / 2 + 16px); top: 0; height: 48px; - z-index: -1; + z-index: 1; } .subthread-container.subthread-last .line-container:before { content: ""; position: absolute; - border-left: 1px solid var(--gray-superdark); + border-left: 1px solid var(--border-color); left: calc(48px / 2 + 16px); top: 0; height: 48px; - z-index: -1; -} - -.divider-container { - margin-right: 16px; + z-index: 1; } .divider { height: 1px; - background: var(--gray-superdark); + background: var(--border-color); } .divider.divider-small { margin-left: calc(16px + 61px); + margin-right: 16px; } .thread-container .collapsed, .thread-container .show-more-container { - background: var(--gray-superdark); min-height: 48px; } -.thread-container .collapsed { - background-color: var(--gray-superdark); -} - .thread-container .hidden-note { padding-left: 48px; } diff --git a/packages/app/src/Element/Thread.tsx b/packages/app/src/Element/Thread.tsx index 73532db7..c8028f5f 100644 --- a/packages/app/src/Element/Thread.tsx +++ b/packages/app/src/Element/Thread.tsx @@ -1,23 +1,21 @@ import "./Thread.css"; -import { useMemo, useState, ReactNode } from "react"; +import { useMemo, useState, ReactNode, useContext } from "react"; import { useIntl } from "react-intl"; -import { useNavigate, useLocation, Link, useParams } from "react-router-dom"; +import { useNavigate, Link, useParams } from "react-router-dom"; import { TaggedNostrEvent, u256, - EventKind, NostrPrefix, EventExt, - Thread as ThreadInfo, parseNostrLink, } from "@snort/system"; -import { eventLink, unwrap, getReactions, getAllReactions, findTag } from "SnortUtils"; +import { eventLink, getReactions, getAllReactions } from "SnortUtils"; import BackButton from "Element/BackButton"; import Note from "Element/Note"; import NoteGhost from "Element/NoteGhost"; import Collapsed from "Element/Collapsed"; -import useThreadFeed from "Feed/ThreadFeed"; +import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext"; import messages from "./messages"; @@ -162,9 +160,8 @@ const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate return ( <>
+ className={`subthread-container ${hasMultipleNotes ? "subthread-multi" : ""} ${isLast ? "subthread-last" : "subthread-mid" + }`}> + className={`subthread-container ${lastReply ? "" : "subthread-multi"} ${lastReply ? "subthread-last" : "subthread-mid" + }`}> + + +} + +export function Thread() { + const thread = useContext(ThreadContext); const navigate = useNavigate(); - const isSingleNote = thread.data?.filter(a => a.kind === EventKind.TextNote).length === 1; + const isSingleNote = thread.chains?.size === 1 && [thread.chains.values].every(v => v.length === 0); const { formatMessage } = useIntl(); function navigateThread(e: TaggedNostrEvent) { - setCurrentId(e.id); + thread.setCurrent(e.id); //const link = encodeTLV(e.id, NostrPrefix.Event, e.relays); } - const chains = useMemo(() => { - const chains = new Map>(); - if (thread.data) { - thread.data - ?.filter(a => a.kind === EventKind.TextNote) - .sort((a, b) => b.created_at - a.created_at) - .forEach(v => { - const t = EventExt.extractThread(v); - let replyTo = t?.replyTo?.value ?? t?.root?.value; - if (t?.root?.key === "a" && t?.root?.value) { - const parsed = t.root.value.split(":"); - replyTo = thread.data?.find( - a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2], - )?.id; - } - if (replyTo) { - if (!chains.has(replyTo)) { - chains.set(replyTo, [v]); - } else { - unwrap(chains.get(replyTo)).push(v); - } - } - }); - } - return chains; - }, [thread.data]); - - // Root is the parent of the current note or the current note if its a root note or the root of the thread - const root = useMemo(() => { - const currentNote = - thread.data?.find( - ne => - ne.id === currentId || - (link.type === NostrPrefix.Address && findTag(ne, "d") === currentId && ne.pubkey === link.author), - ) ?? (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined); - if (currentNote) { - const currentThread = EventExt.extractThread(currentNote); - const isRoot = (ne?: ThreadInfo) => ne === undefined; - - if (isRoot(currentThread)) { - return currentNote; - } - const replyTo = currentThread?.replyTo ?? currentThread?.root; - - // sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists - if (replyTo) { - if (replyTo.key === "a" && replyTo.value) { - const parsed = replyTo.value.split(":"); - return thread.data?.find( - a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2], - ); - } - if (replyTo.value) { - return thread.data?.find(a => a.id === replyTo.value); - } - } - - const possibleRoots = thread.data?.filter(a => { - const thread = EventExt.extractThread(a); - return isRoot(thread); - }); - if (possibleRoots) { - // worst case we need to check every possible root to see which one contains the current note as a child - for (const ne of possibleRoots) { - const children = chains.get(ne.id) ?? []; - - if (children.find(ne => ne.id === currentId)) { - return ne; - } - } - } - } - }, [thread.data, currentId, location]); - const parent = useMemo(() => { - if (root) { - const currentThread = EventExt.extractThread(root); + if (thread.root) { + const currentThread = EventExt.extractThread(thread.root); return ( currentThread?.replyTo?.value ?? currentThread?.root?.value ?? (currentThread?.root?.key === "a" && currentThread.root?.value) ); } - }, [root]); + }, [thread.root]); - const brokenChains = Array.from(chains?.keys()).filter(a => !thread.data?.some(b => b.id === a)); + const brokenChains = Array.from(thread.chains?.keys()).filter(a => !thread.data?.some(b => b.id === a)); function renderRoot(note: TaggedNostrEvent) { const className = `thread-root${isSingleNote ? " thread-root-single" : ""}`; @@ -337,20 +262,20 @@ export default function Thread() { } function renderChain(from: u256): ReactNode { - if (!from || !chains) { + if (!from || thread.chains.size === 0) { return; } - const replies = chains.get(from); - if (replies && currentId) { + const replies = thread.chains.get(from); + if (replies && thread.current) { return ( a.id), )} - chains={chains} + chains={thread.chains} onNavigate={navigateThread} /> ); @@ -359,7 +284,7 @@ export default function Thread() { function goBack() { if (parent) { - setCurrentId(parent); + thread.setCurrent(parent); } else { navigate(-1); } @@ -379,8 +304,8 @@ export default function Thread() {
- {root && renderRoot(root)} - {root && renderChain(root.id)} + {thread.root && renderRoot(thread.root)} + {thread.root && renderChain(thread.root.id)} {brokenChains.length > 0 &&

Other replies

} {brokenChains.map(a => { diff --git a/packages/app/src/Element/TimelineFollows.tsx b/packages/app/src/Element/TimelineFollows.tsx index c9649968..1980dd51 100644 --- a/packages/app/src/Element/TimelineFollows.tsx +++ b/packages/app/src/Element/TimelineFollows.tsx @@ -1,5 +1,5 @@ import "./Timeline.css"; -import { useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react"; +import { ReactNode, useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react"; import { FormattedMessage } from "react-intl"; import { TaggedNostrEvent, EventKind, u256, NostrEvent } from "@snort/system"; import { unixNow } from "@snort/shared"; @@ -19,6 +19,9 @@ import Icon from "Icons/Icon"; export interface TimelineFollowsProps { postsOnly: boolean; + liveStreams?: boolean; + noteFilter?: (ev: NostrEvent) => boolean; + noteRenderer?: (ev: NostrEvent) => ReactNode; } /** @@ -46,7 +49,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => { const a = nts.filter(a => a.kind !== EventKind.LiveEvent); return a ?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true)) - .filter(a => !isMuted(a.pubkey) && login.follows.item.includes(a.pubkey)); + .filter(a => !isMuted(a.pubkey) && login.follows.item.includes(a.pubkey) && (props.noteFilter?.(a) ?? true)); }, [props.postsOnly, muted, login.follows.timestamp], ); @@ -83,7 +86,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => { return ( <> - + {(props.liveStreams ?? true) && } {latestFeed.length > 0 && ( <>
onShowLatest()} ref={ref}> @@ -110,7 +113,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => { )} )} - {mainFeed.map(a => ( + {mainFeed.map(a => props.noteRenderer?.(a) ?? ( ))}
diff --git a/packages/app/src/Element/TrendingUsers.tsx b/packages/app/src/Element/TrendingUsers.tsx index 0fd51c90..af5cd25c 100644 --- a/packages/app/src/Element/TrendingUsers.tsx +++ b/packages/app/src/Element/TrendingUsers.tsx @@ -22,8 +22,8 @@ export default function TrendingUsers() { if (!userList) return ; return ( - <> +
- +
); } diff --git a/packages/app/src/Feed/ArticlesFeed.ts b/packages/app/src/Feed/ArticlesFeed.ts new file mode 100644 index 00000000..39aa81c2 --- /dev/null +++ b/packages/app/src/Feed/ArticlesFeed.ts @@ -0,0 +1,21 @@ +import { EventKind, NoteCollection, RequestBuilder } from "@snort/system"; +import { useRequestBuilder } from "@snort/system-react"; +import useLogin from "Hooks/useLogin"; +import { useMemo } from "react"; + +export function useArticles() { + const {publicKey, follows} = useLogin(); + + const sub = useMemo(() => { + if(!publicKey) return null; + const rb = new RequestBuilder(`articles:${publicKey}`); + rb.withFilter() + .kinds([EventKind.LongFormTextNote]) + .authors(follows.item) + .limit(20); + + return rb; + }, [follows.timestamp]); + + return useRequestBuilder(NoteCollection, sub); +} \ No newline at end of file diff --git a/packages/app/src/Hooks/useLoginRelays.tsx b/packages/app/src/Hooks/useLoginRelays.tsx new file mode 100644 index 00000000..8c961266 --- /dev/null +++ b/packages/app/src/Hooks/useLoginRelays.tsx @@ -0,0 +1,22 @@ +import { System } from "index"; +import { useEffect } from "react"; +import useLogin from "./useLogin"; + +export function useLoginRelays() { + const { relays } = useLogin(); + + useEffect(() => { + if (relays) { + (async () => { + for (const [k, v] of Object.entries(relays.item)) { + await System.ConnectToRelay(k, v); + } + for (const v of System.Sockets) { + if (!relays.item[v.address] && !v.ephemeral) { + System.DisconnectRelay(v.address); + } + } + })(); + } + }, [relays]); +} \ No newline at end of file diff --git a/packages/app/src/Hooks/useTextTransformCache.tsx b/packages/app/src/Hooks/useTextTransformCache.tsx new file mode 100644 index 00000000..f767aa39 --- /dev/null +++ b/packages/app/src/Hooks/useTextTransformCache.tsx @@ -0,0 +1,15 @@ +import { ParsedFragment, transformText } from "@snort/system"; + +const TextCache = new Map>(); + +export function transformTextCached(id: string, content: string, tags: Array>) { + const cached = TextCache.get(id); + if (cached) return cached; + const newCache = transformText(content, tags); + TextCache.set(id, newCache); + return newCache; +} + +export function useTextTransformer(id: string, content: string, tags: Array>) { + return transformTextCached(id, content, tags); +} \ No newline at end of file diff --git a/packages/app/src/Hooks/useTheme.tsx b/packages/app/src/Hooks/useTheme.tsx new file mode 100644 index 00000000..2739387d --- /dev/null +++ b/packages/app/src/Hooks/useTheme.tsx @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import useLogin from "./useLogin"; + +export function useTheme() { + const { preferences } = useLogin(); + + function setTheme(theme: "light" | "dark") { + const elm = document.documentElement; + if (theme === "light" && !elm.classList.contains("light")) { + elm.classList.add("light"); + } else if (theme === "dark" && elm.classList.contains("light")) { + elm.classList.remove("light"); + } + } + + useEffect(() => { + const osTheme = window.matchMedia("(prefers-color-scheme: light)"); + setTheme( + preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark" + ); + + osTheme.onchange = e => { + if (preferences.theme === "system") { + setTheme(e.matches ? "light" : "dark"); + } + }; + return () => { + osTheme.onchange = null; + }; + }, [preferences.theme]); + +} \ No newline at end of file diff --git a/packages/app/src/Hooks/useThreadContext.tsx b/packages/app/src/Hooks/useThreadContext.tsx new file mode 100644 index 00000000..841b6ab2 --- /dev/null +++ b/packages/app/src/Hooks/useThreadContext.tsx @@ -0,0 +1,110 @@ +import { unwrap } from "@snort/shared"; +import { EventExt, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, u256, Thread as ThreadInfo, } from "@snort/system"; +import useThreadFeed from "Feed/ThreadFeed"; +import { findTag } from "SnortUtils"; +import { ReactNode, createContext, useMemo, useState } from "react"; +import { useLocation } from "react-router-dom"; + +export interface ThreadContext { + current: string, + root?: TaggedNostrEvent, + chains: Map>, + data: Array, + setCurrent: (i: string) => void; +} + +export const ThreadContext = createContext({} as ThreadContext) + +export function ThreadContextWrapper({ link, children }: { link: NostrLink, children?: ReactNode }) { + const location = useLocation(); + const [currentId, setCurrentId] = useState(link.id); + const thread = useThreadFeed(link); + + const chains = useMemo(() => { + const chains = new Map>(); + if (thread.data) { + thread.data + ?.filter(a => a.kind === EventKind.TextNote) + .sort((a, b) => b.created_at - a.created_at) + .forEach(v => { + const t = EventExt.extractThread(v); + let replyTo = t?.replyTo?.value ?? t?.root?.value; + if (t?.root?.key === "a" && t?.root?.value) { + const parsed = t.root.value.split(":"); + replyTo = thread.data?.find( + a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2] + )?.id; + } + if (replyTo) { + if (!chains.has(replyTo)) { + chains.set(replyTo, [v]); + } else { + unwrap(chains.get(replyTo)).push(v); + } + } + }); + } + return chains; + }, [thread.data]); + + // Root is the parent of the current note or the current note if its a root note or the root of the thread + const root = useMemo(() => { + const currentNote = + thread.data?.find( + ne => + ne.id === currentId || + (link.type === NostrPrefix.Address && findTag(ne, "d") === currentId && ne.pubkey === link.author) + ) ?? (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined); + if (currentNote) { + const currentThread = EventExt.extractThread(currentNote); + const isRoot = (ne?: ThreadInfo) => ne === undefined; + + if (isRoot(currentThread)) { + return currentNote; + } + const replyTo = currentThread?.replyTo ?? currentThread?.root; + + // sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists + if (replyTo) { + if (replyTo.key === "a" && replyTo.value) { + const parsed = replyTo.value.split(":"); + return thread.data?.find( + a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2] + ); + } + if (replyTo.value) { + return thread.data?.find(a => a.id === replyTo.value); + } + } + + const possibleRoots = thread.data?.filter(a => { + const thread = EventExt.extractThread(a); + return isRoot(thread); + }); + if (possibleRoots) { + // worst case we need to check every possible root to see which one contains the current note as a child + for (const ne of possibleRoots) { + const children = chains.get(ne.id) ?? []; + + if (children.find(ne => ne.id === currentId)) { + return ne; + } + } + } + } + }, [thread.data, currentId, location]); + + const ctxValue = useMemo(() => { + return { + current: currentId, + root, + chains, + data: thread.data, + setCurrent: v => setCurrentId(v) + } as ThreadContext + }, [root, chains]); + + return + {children} + +} \ No newline at end of file diff --git a/packages/app/src/Pages/Deck.css b/packages/app/src/Pages/Deck.css new file mode 100644 index 00000000..49fb1a86 --- /dev/null +++ b/packages/app/src/Pages/Deck.css @@ -0,0 +1,97 @@ +.deck-layout { + display: flex; + height: 100vh; + overflow-y: hidden; +} + +.deck-layout .deck-cols { + display: flex; + height: 100vh; + overflow-y: hidden; + overflow-x: auto; +} + +.deck-layout .deck-cols .deck-col-header { + padding: 8px 16px; + border: 1px solid var(--border-color); + border-collapse: collapse; + font-size: 20px; + font-weight: 700; + min-height: 40px; + max-height: 40px; +} + +.deck-layout .deck-cols .deck-col-header:not(:last-of-type) { + border-right: 0; +} + +.deck-layout .deck-cols > div { + display: flex; + flex-direction: column; + height: 100vh; + width: 550px; + min-width: 550px; +} + +.deck-layout .deck-cols > div > div:not(:first-of-type) { + overflow-y: scroll; +} + +.image-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; +} + +.image-grid > .media-note { + border: 1px solid var(--border-color); + background-image: var(--img); + background-position: center; + background-size: cover; + aspect-ratio: 1; + cursor: pointer; +} + +.thread-overlay .modal-body { + background-color: unset; + padding: 0; + width: 100vw; + height: 100vh; + display: flex; + flex-direction: row; + border-radius: unset; + gap: 16px; + --border-color: #3A3A3A; +} + +.thread-overlay .modal-body > div:last-of-type { + width: 550px; + min-width: 550px; + height: 100vh; + overflow-y: auto; + background-color: var(--gray-superdark); +} + +.thread-overlay .spotlight { + flex-grow: 1; + margin: auto; + text-align: center; +} + +.thread-overlay .spotlight .details { + right: calc(28px + 550px + 16px); +} + +.thread-overlay .spotlight .right { + right: calc(24px + 550px + 16px); +} + +.thread-overlay .spotlight img, +.thread-overlay .spotlight video { + max-width: calc(100vw - 550px - 16px); +} + +.thread-overlay .main-content { + border: 0; + border-bottom: 1px solid var(--border-color); +} \ No newline at end of file diff --git a/packages/app/src/Pages/DeckLayout.tsx b/packages/app/src/Pages/DeckLayout.tsx new file mode 100644 index 00000000..a52aeaee --- /dev/null +++ b/packages/app/src/Pages/DeckLayout.tsx @@ -0,0 +1,107 @@ +import "./Deck.css"; +import { CSSProperties, useContext, useState } from "react"; +import { Outlet } from "react-router-dom"; +import { FormattedMessage } from "react-intl"; +import { NostrPrefix, createNostrLink } from "@snort/system"; + +import { DeckNav } from "Element/Deck/Nav"; +import useLoginFeed from "Feed/LoginFeed"; +import { useLoginRelays } from "Hooks/useLoginRelays"; +import { useTheme } from "Hooks/useTheme"; +import Articles from "Element/Deck/Articles"; +import TimelineFollows from "Element/TimelineFollows"; +import { transformTextCached } from "Hooks/useTextTransformCache"; +import Icon from "Icons/Icon"; +import NotificationsPage from "./Notifications"; +import useImgProxy from "Hooks/useImgProxy"; +import Modal from "Element/Modal"; +import { Thread } from "Element/Thread"; +import { RootTabs } from "Element/RootTabs"; +import { SpotlightMedia } from "Element/SpotlightMedia"; +import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext"; + +export function SnortDeckLayout() { + const [thread, setThread] = useState(); + + useLoginFeed(); + useTheme(); + useLoginRelays(); + const { proxy } = useImgProxy(); + + return
+ +
+
+
+
+ + +
+
+ +
+
+
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ { + const parsed = transformTextCached(e.id, e.content, e.tags); + const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/")); + return images.length > 0; + }} noteRenderer={e => { + const parsed = transformTextCached(e.id, e.content, e.tags); + const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/")); + + return
setThread(e.id)}>
+ }} /> +
+
+
+
+ + +
+
+ +
+
+
+ {thread && <> + setThread(undefined)} className="thread-overlay"> + + setThread(undefined)} /> +
+ +
+
+
+ } +
+} + +function SpotlightFromThread({ onClose }: { onClose: () => void }) { + const thread = useContext(ThreadContext); + + const parsed = thread.root ? transformTextCached(thread.root.id, thread.root.content, thread.root.tags) : []; + const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/")); + + return a.content)} idx={0} onClose={onClose} /> +} \ No newline at end of file diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index a428f941..3963c6ad 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -10,7 +10,6 @@ import messages from "./messages"; import Icon from "Icons/Icon"; import { RootState } from "State/Store"; import { setShow, reset } from "State/NoteCreator"; -import { System } from "index"; import useLoginFeed from "Feed/LoginFeed"; import { NoteCreator } from "Element/NoteCreator"; import { mapPlanName } from "./subscribe"; @@ -22,6 +21,8 @@ import Toaster from "Toaster"; import Spinner from "Icons/Spinner"; import { NostrPrefix, createNostrLink, tryParseNostrLink } from "@snort/system"; import { fetchNip05Pubkey } from "Nip05/Verifier"; +import { useTheme } from "Hooks/useTheme"; +import { useLoginRelays } from "Hooks/useLoginRelays"; export default function Layout() { const location = useLocation(); @@ -30,10 +31,13 @@ export default function Layout() { const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing; const dispatch = useDispatch(); const navigate = useNavigate(); - const { publicKey, relays, preferences, subscriptions } = useLogin(); + const { publicKey, subscriptions } = useLogin(); const currentSubscription = getCurrentSubscription(subscriptions); const [pageClass, setPageClass] = useState("page"); + useLoginFeed(); + useTheme(); + useLoginRelays(); const handleNoteCreatorButtonClick = () => { if (replyTo) { @@ -62,46 +66,6 @@ export default function Layout() { } }, [location]); - useEffect(() => { - if (relays) { - (async () => { - for (const [k, v] of Object.entries(relays.item)) { - await System.ConnectToRelay(k, v); - } - for (const v of System.Sockets) { - if (!relays.item[v.address] && !v.ephemeral) { - System.DisconnectRelay(v.address); - } - } - })(); - } - }, [relays]); - - function setTheme(theme: "light" | "dark") { - const elm = document.documentElement; - if (theme === "light" && !elm.classList.contains("light")) { - elm.classList.add("light"); - } else if (theme === "dark" && elm.classList.contains("light")) { - elm.classList.remove("light"); - } - } - - useEffect(() => { - const osTheme = window.matchMedia("(prefers-color-scheme: light)"); - setTheme( - preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark", - ); - - osTheme.onchange = e => { - if (preferences.theme === "system") { - setTheme(e.matches ? "light" : "dark"); - } - }; - return () => { - osTheme.onchange = null; - }; - }, [preferences.theme]); - return (
{!shouldHideHeader && ( @@ -220,7 +184,7 @@ const AccountHeader = () => { {unreadDms > 0 && } - + {hasNotifications && } button { - background: white; - color: black; - font-size: 16px; - padding: 10px 16px; - border-radius: 1000px; - display: flex; - align-items: center; - justify-content: center; - gap: 12px; -} diff --git a/packages/app/src/Pages/Root.tsx b/packages/app/src/Pages/Root.tsx index 8f03f52d..40f12a0d 100644 --- a/packages/app/src/Pages/Root.tsx +++ b/packages/app/src/Pages/Root.tsx @@ -1,8 +1,7 @@ -import { ReactNode, useEffect, useState } from "react"; -import { Link, Outlet, RouteObject, useLocation, useNavigate, useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Link, Outlet, RouteObject, useParams } from "react-router-dom"; import { FormattedMessage } from "react-intl"; -import { Menu, MenuItem } from "@szhsin/react-menu"; -import "./Root.css"; +import { unixNow } from "@snort/shared"; import Timeline from "Element/Timeline"; import { System } from "index"; @@ -10,165 +9,26 @@ import { TimelineSubject } from "Feed/TimelineFeed"; import { debounce, getRelayName, sha256 } from "SnortUtils"; import useLogin from "Hooks/useLogin"; import Discover from "Pages/Discover"; -import Icon from "Icons/Icon"; import TrendingUsers from "Element/TrendingUsers"; import TrendingNotes from "Element/TrendingPosts"; import HashTagsPage from "Pages/HashTagsPage"; import SuggestedProfiles from "Element/SuggestedProfiles"; import { TaskList } from "Tasks/TaskList"; import TimelineFollows from "Element/TimelineFollows"; +import { RootTabs } from "Element/RootTabs"; import messages from "./messages"; -import { unixNow } from "@snort/shared"; interface RelayOption { url: string; paid: boolean; } -type RootPage = "following" | "conversations" | "trending-notes" | "trending-people" | "suggested" | "tags" | "global"; - export default function RootPage() { - const navigate = useNavigate(); - const location = useLocation(); - const { publicKey: pubKey, tags, preferences } = useLogin(); - const [rootType, setRootType] = useState("following"); - - const menuItems = [ - { - tab: "following", - path: "/notes", - show: Boolean(pubKey), - element: ( - <> - - - - ), - }, - { - tab: "trending-notes", - path: "/trending/notes", - show: true, - element: ( - <> - - - - ), - }, - { - tab: "conversations", - path: "/conversations", - show: Boolean(pubKey), - element: ( - <> - - - - ), - }, - { - tab: "trending-people", - path: "/trending/people", - show: true, - element: ( - <> - - - - ), - }, - { - tab: "suggested", - path: "/suggested", - show: Boolean(pubKey), - element: ( - <> - - - - ), - }, - { - tab: "global", - path: "/global", - show: true, - element: ( - <> - - - - ), - }, - ] as Array<{ - tab: RootPage; - path: string; - show: boolean; - element: ReactNode; - }>; - - useEffect(() => { - if (location.pathname === "/") { - const t = pubKey ? preferences.defaultRootTab ?? "/notes" : "/trending/notes"; - navigate(t); - } else { - const currentTab = menuItems.find(a => a.path === location.pathname)?.tab; - if (currentTab) { - setRootType(currentTab); - } - } - }, [location]); - - function currentMenuItem() { - if (location.pathname.startsWith("/t/")) { - return ( - <> - - {location.pathname.split("/").slice(-1)} - - ); - } - return menuItems.find(a => a.tab === rootType)?.element; - } - return ( <> -
- - {currentMenuItem()} - - - } - align="center" - menuClassName={() => "ctx-menu"}> -
- -
- -
- {menuItems - .filter(a => a.show) - .map(a => ( - { - navigate(a.path); - }}> - {a.element} - - ))} - {tags.item.map(v => ( - { - navigate(`/t/${v}`); - }}> - - {v} - - ))} -
+
+
@@ -196,7 +56,7 @@ const FollowsHint = () => { return null; }; -const GlobalTab = () => { +export const GlobalTab = () => { const { relays } = useLogin(); const [relay, setRelay] = useState(); const [allRelays, setAllRelays] = useState(); @@ -272,7 +132,7 @@ const GlobalTab = () => { ); }; -const NotesTab = () => { +export const NotesTab = () => { return ( <> @@ -282,71 +142,81 @@ const NotesTab = () => { ); }; -const ConversationsTab = () => { +export const ConversationsTab = () => { return ; }; -const TagsTab = () => { +export const TagsTab = (params: { tag?: string }) => { const { tag } = useParams(); + const t = params.tag ?? tag ?? ""; const subject: TimelineSubject = { type: "hashtag", - items: [tag ?? ""], - discriminator: `tags-${tag}`, + items: [t], + discriminator: `tags-${t}`, streams: true, }; return ; }; +const DefaultTab = () => { + const { preferences, publicKey } = useLogin(); + const tab = publicKey ? preferences.defaultRootTab ?? `notes` : `trending/notes`; + const elm = RootTabRoutes.find(a => a.path === tab)?.element; + return elm; +} + +export const RootTabRoutes = [ + { + path: "", + element: + }, + { + path: "global", + element: , + }, + { + path: "notes", + element: , + }, + { + path: "conversations", + element: , + }, + { + path: "discover", + element: , + }, + { + path: "tag/:tag", + element: , + }, + { + path: "trending/notes", + element: , + }, + { + path: "trending/people", + element: , + }, + { + path: "suggested", + element: ( +
+ +
+ ), + }, + { + path: "t/:tag", + element: , + }, +]; + export const RootRoutes = [ { path: "/", element: , - children: [ - { - path: "global", - element: , - }, - { - path: "notes", - element: , - }, - { - path: "conversations", - element: , - }, - { - path: "discover", - element: , - }, - { - path: "tag/:tag", - element: , - }, - { - path: "trending/notes", - element: , - }, - { - path: "trending/people", - element: ( -
- -
- ), - }, - { - path: "suggested", - element: ( -
- -
- ), - }, - { - path: "/t/:tag", - element: , - }, - ], + children: RootTabRoutes, }, ] as RouteObject[]; diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e759b129..8584a12a 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -261,6 +261,10 @@ button.icon:hover { display: inline-flex; } +.light .btn { + color: #64748B; +} + .btn-warn { border-color: var(--error); } @@ -353,6 +357,7 @@ input:disabled { .f-center { justify-content: center; + align-items: center; } .f-1 { diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index 781a190d..6d6d4410 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -28,7 +28,7 @@ import Store from "State/Store"; import Layout from "Pages/Layout"; import LoginPage from "Pages/LoginPage"; import ProfilePage from "Pages/ProfilePage"; -import { RootRoutes } from "Pages/Root"; +import { RootRoutes, RootTabRoutes } from "Pages/Root"; import NotificationsPage from "Pages/Notifications"; import SettingsPage, { SettingsRoutes } from "Pages/SettingsPage"; import ErrorPage from "Pages/ErrorPage"; @@ -40,13 +40,14 @@ import HelpPage from "Pages/HelpPage"; import { NewUserRoutes } from "Pages/new"; import { WalletRoutes } from "Pages/WalletPage"; import NostrLinkHandler from "Pages/NostrLinkHandler"; -import Thread from "Element/Thread"; +import { ThreadRoute } from "Element/Thread"; import { SubscribeRoutes } from "Pages/subscribe"; import ZapPoolPage from "Pages/ZapPool"; import DebugPage from "Pages/Debug"; import { db } from "Db"; import { preload, RelayMetrics, UserCache, UserRelays } from "Cache"; import { LoginStore } from "Login"; +import { SnortDeckLayout } from "Pages/DeckLayout"; const WasmQueryOptimizer = { expandFilter: (f: ReqFilter) => { @@ -152,7 +153,7 @@ export const router = createBrowserRouter([ }, { path: "/e/:id", - element: , + element: , }, { path: "/p/:id", @@ -200,6 +201,18 @@ export const router = createBrowserRouter([ }, ], }, + { + path: "/deck", + element: , + loader: async () => { + if (!didInit) { + didInit = true; + return await initSite(); + } + return null; + }, + children: RootTabRoutes + } ]); const root = ReactDOM.createRoot(unwrap(document.getElementById("root"))); diff --git a/packages/system/src/event-kind.ts b/packages/system/src/event-kind.ts index 72a7e03d..ab9af77f 100644 --- a/packages/system/src/event-kind.ts +++ b/packages/system/src/event-kind.ts @@ -24,6 +24,7 @@ enum EventKind { TagLists = 30002, // NIP-51c Badge = 30009, // NIP-58 ProfileBadges = 30008, // NIP-58 + LongFormTextNote = 30023, // NIP-23 LiveEvent = 30311, // NIP-102 ZapstrTrack = 31337, SimpleChatMetadata = 39_000, // NIP-29 diff --git a/packages/system/src/nostr-link.ts b/packages/system/src/nostr-link.ts index 2aeedbba..2ab44fa2 100644 --- a/packages/system/src/nostr-link.ts +++ b/packages/system/src/nostr-link.ts @@ -22,6 +22,22 @@ export function linkToEventTag(link: NostrLink) { } } +export function tagToNostrLink(tag: Array) { + switch(tag[0]) { + case "e": { + return createNostrLink(NostrPrefix.Event, tag[1], tag.slice(2)); + } + case "p": { + return createNostrLink(NostrPrefix.Profile, tag[1], tag.slice(2)); + } + case "a": { + const [kind, author, dTag] = tag[1].split(":"); + return createNostrLink(NostrPrefix.Address, dTag, tag.slice(2), Number(kind), author); + } + } + throw new Error(`Unknown tag kind ${tag[0]}`); +} + export function createNostrLinkToEvent(ev: TaggedNostrEvent | NostrEvent) { const relays = "relays" in ev ? ev.relays : undefined;