From f53f5ca9cbf8b05e36ec98f5c63b110476ffedcd Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Sat, 31 Aug 2024 16:29:55 -0500 Subject: [PATCH] add simple article view add support for wiki links in text notes --- .changeset/sweet-spiders-tickle.md | 5 + .changeset/tender-hornets-rest.md | 5 + src/app.tsx | 9 ++ .../event-types/embedded-article.tsx | 35 +++---- src/components/external-embeds/types/wiki.tsx | 24 +++++ .../note/timeline-note/text-note-contents.tsx | 2 + .../generic-note-timeline/article-note.tsx | 18 ---- .../generic-note-timeline/timeline-item.tsx | 4 - src/components/timestamp.tsx | 2 +- src/helpers/nostr/event.ts | 93 +----------------- src/helpers/nostr/threading.ts | 94 +++++++++++++++++++ src/helpers/thread.ts | 4 +- src/views/articles/article.tsx | 72 ++++++++++++++ .../articles/components/article-card.tsx | 64 +++++++++++++ .../articles/components/article-menu.tsx | 52 ++++++++++ .../articles/components/article-tags.tsx | 16 ++++ src/views/articles/index.tsx | 71 ++++++++++++++ src/views/other-stuff/apps.ts | 1 + src/views/user/articles.tsx | 4 +- 19 files changed, 438 insertions(+), 137 deletions(-) create mode 100644 .changeset/sweet-spiders-tickle.md create mode 100644 .changeset/tender-hornets-rest.md create mode 100644 src/components/external-embeds/types/wiki.tsx delete mode 100644 src/components/timeline-page/generic-note-timeline/article-note.tsx create mode 100644 src/helpers/nostr/threading.ts create mode 100644 src/views/articles/article.tsx create mode 100644 src/views/articles/components/article-card.tsx create mode 100644 src/views/articles/components/article-menu.tsx create mode 100644 src/views/articles/components/article-tags.tsx create mode 100644 src/views/articles/index.tsx diff --git a/.changeset/sweet-spiders-tickle.md b/.changeset/sweet-spiders-tickle.md new file mode 100644 index 000000000..54cb1e734 --- /dev/null +++ b/.changeset/sweet-spiders-tickle.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add support for wiki links in text notes diff --git a/.changeset/tender-hornets-rest.md b/.changeset/tender-hornets-rest.md new file mode 100644 index 000000000..5580fad08 --- /dev/null +++ b/.changeset/tender-hornets-rest.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add simple article view diff --git a/src/app.tsx b/src/app.tsx index 813ccb7c5..b99a622ec 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -103,6 +103,8 @@ import PerformanceSettings from "./views/settings/performance"; import PrivacySettings from "./views/settings/privacy"; import PostSettings from "./views/settings/post"; import AccountSettings from "./views/settings/accounts"; +import ArticlesHomeView from "./views/articles"; +import ArticleView from "./views/articles/article"; const TracksView = lazy(() => import("./views/tracks")); const UserTracksTab = lazy(() => import("./views/user/tracks")); const UserVideosTab = lazy(() => import("./views/user/videos")); @@ -407,6 +409,13 @@ const router = createHashRouter([ { path: "explore", element: }, ], }, + { + path: "articles", + children: [ + { path: "", element: }, + { path: ":naddr", element: }, + ], + }, { path: "c/:community", children: [ diff --git a/src/components/embed-event/event-types/embedded-article.tsx b/src/components/embed-event/event-types/embedded-article.tsx index 11179430e..80a6d94c9 100644 --- a/src/components/embed-event/event-types/embedded-article.tsx +++ b/src/components/embed-event/event-types/embedded-article.tsx @@ -1,5 +1,5 @@ -import { useContext } from "react"; -import { Box, Card, CardBody, CardProps, Flex, Heading, Image, LinkBox, Tag, Text, useToast } from "@chakra-ui/react"; +import { Box, Card, CardBody, CardProps, Flex, Heading, Image, LinkBox, Text, useToast } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; import { getArticleImage, @@ -11,25 +11,19 @@ import { NostrEvent } from "../../../types/nostr-event"; import UserAvatarLink from "../../user/user-avatar-link"; import UserLink from "../../user/user-link"; import Timestamp from "../../timestamp"; -import { AppHandlerContext } from "../../../providers/route/app-handler-provider"; -import relayHintService from "../../../services/event-relay-hint"; +import ArticleTags from "../../../views/articles/components/article-tags"; +import HoverLinkOverlay from "../../hover-link-overlay"; +import useShareableEventAddress from "../../../hooks/use-shareable-event-address"; export default function EmbeddedArticle({ article, ...props }: Omit & { article: NostrEvent }) { - const toast = useToast(); const title = getArticleTitle(article); const image = getArticleImage(article); const summary = getArticleSummary(article); - const { openAddress } = useContext(AppHandlerContext); - - const open = () => { - const naddr = relayHintService.getSharableEventAddress(article); - if (naddr) openAddress(naddr); - else toast({ status: "error", description: "Failed to get address" }); - }; + const naddr = useShareableEventAddress(article); return ( - + {image && ( - {title} + + + {title} + + {summary} - {article.tags - .filter((t) => t[0] === "t" && t[1]) - .map(([_, hashtag]: string[], i) => ( - - #{hashtag} - - ))} + + ); diff --git a/src/components/external-embeds/types/wiki.tsx b/src/components/external-embeds/types/wiki.tsx new file mode 100644 index 000000000..31ceb6f5f --- /dev/null +++ b/src/components/external-embeds/types/wiki.tsx @@ -0,0 +1,24 @@ +import { EmbedableContent, embedJSX } from "../../../helpers/embeds"; +import WikiLink from "../../../views/wiki/components/wiki-link"; + +export function embedNostrWikiLinks(content: EmbedableContent) { + return embedJSX(content, { + name: "embedWikiLinks", + regexp: /\[\[(\w+)(?:\|(\w+))?\]\]/gi, + render: (match, isEndOfLine) => { + try { + const topic = match[1]; + const label = match[2] || topic; + + if (topic) { + return {label}; + } + } catch (e) { + if (e instanceof Error) { + console.error("Failed to embed link", match[0], e.message); + } + } + return null; + }, + }); +} diff --git a/src/components/note/timeline-note/text-note-contents.tsx b/src/components/note/timeline-note/text-note-contents.tsx index 99d9ace28..574d36dac 100644 --- a/src/components/note/timeline-note/text-note-contents.tsx +++ b/src/components/note/timeline-note/text-note-contents.tsx @@ -34,6 +34,7 @@ import { } from "../../external-embeds"; import { LightboxProvider } from "../../lightbox-provider"; import MediaOwnerProvider from "../../../providers/local/media-owner-provider"; +import { embedNostrWikiLinks } from "../../external-embeds/types/wiki"; function buildContents(event: NostrEvent | EventTemplate, simpleLinks = false) { let content: EmbedableContent = [event.content.trim()]; @@ -75,6 +76,7 @@ function buildContents(event: NostrEvent | EventTemplate, simpleLinks = false) { content = embedNostrHashtags(content, event); content = embedNipDefinitions(content); content = embedEmoji(content, event); + content = embedNostrWikiLinks(content); return content; } diff --git a/src/components/timeline-page/generic-note-timeline/article-note.tsx b/src/components/timeline-page/generic-note-timeline/article-note.tsx deleted file mode 100644 index 9e8d7092a..000000000 --- a/src/components/timeline-page/generic-note-timeline/article-note.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { memo } from "react"; -import { Box } from "@chakra-ui/react"; - -import { NostrEvent } from "../../../types/nostr-event"; -import EmbeddedArticle from "../../embed-event/event-types/embedded-article"; -import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref"; - -function ArticleNote({ article }: { article: NostrEvent }) { - const ref = useEventIntersectionRef(article); - - return ( - - - - ); -} - -export default memo(ArticleNote); diff --git a/src/components/timeline-page/generic-note-timeline/timeline-item.tsx b/src/components/timeline-page/generic-note-timeline/timeline-item.tsx index d7683fc86..6c7185e74 100644 --- a/src/components/timeline-page/generic-note-timeline/timeline-item.tsx +++ b/src/components/timeline-page/generic-note-timeline/timeline-item.tsx @@ -5,7 +5,6 @@ import { Box, Text } from "@chakra-ui/react"; import { ErrorBoundary } from "../../error-boundary"; import ReplyNote from "./reply-note"; import RepostEvent from "./repost-event"; -import ArticleNote from "./article-note"; import StreamNote from "./stream-note"; import RelayRecommendation from "./relay-recommendation"; import BadgeAwardCard from "../../../views/badges/components/badge-award-card"; @@ -29,9 +28,6 @@ function TimelineItem({ event, visible, minHeight }: { event: NostrEvent; visibl case kinds.GenericRepost: content = ; break; - case kinds.LongFormArticle: - content = ; - break; case STREAM_KIND: content = ; break; diff --git a/src/components/timestamp.tsx b/src/components/timestamp.tsx index 80faacafd..7bc61b448 100644 --- a/src/components/timestamp.tsx +++ b/src/components/timestamp.tsx @@ -5,7 +5,7 @@ export default function Timestamp({ timestamp, ...props }: { timestamp: number } const date = dayjs.unix(timestamp); const now = dayjs(); - let display = date.format("L"); + let display = date.format("ll"); if (now.diff(date, "week") <= 6) { if (now.diff(date, "d") >= 1) { diff --git a/src/helpers/nostr/event.ts b/src/helpers/nostr/event.ts index 80f63d29f..a8617ab42 100644 --- a/src/helpers/nostr/event.ts +++ b/src/helpers/nostr/event.ts @@ -11,6 +11,7 @@ import { safeDecode } from "../nip19"; import { safeRelayUrl, safeRelayUrls } from "../relay"; import RelaySet from "../../classes/relay-set"; import { truncateId } from "../string"; +import { getNip10References } from "./threading"; export { truncateId as truncatedId }; @@ -40,7 +41,7 @@ export function isReply(event: NostrEvent | DraftNostrEvent) { if (event[isReplySymbol] !== undefined) return event[isReplySymbol] as boolean; if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return false; - const isReply = !!getThreadReferences(event).reply; + const isReply = !!getNip10References(event).reply; // @ts-expect-error event[isReplySymbol] = isReply; return isReply; @@ -123,94 +124,8 @@ export function filterTagsByContentRefs(content: string, tags: Tag[], referenced return tags.filter((t) => contentTagRefs.includes(t) === referenced); } -export function interpretThreadTags(event: NostrEvent | DraftNostrEvent) { - const eTags = event.tags.filter(isETag); - const aTags = event.tags.filter(isATag); - - // find the root and reply tags. - let rootETag = eTags.find((t) => t[3] === "root"); - let replyETag = eTags.find((t) => t[3] === "reply"); - - let rootATag = aTags.find((t) => t[3] === "root"); - let replyATag = aTags.find((t) => t[3] === "reply"); - - if (!rootETag || !replyETag) { - // a direct reply does not need a "reply" reference - // https://github.com/nostr-protocol/nips/blob/master/10.md - - // this is not necessarily to spec. but if there is only one id (root or reply) then assign it to both - // this handles the cases where a client only set a "reply" tag and no root - rootETag = replyETag = rootETag || replyETag; - } - if (!rootATag || !replyATag) { - rootATag = replyATag = rootATag || replyATag; - } - - if (!rootETag && !replyETag) { - const contentTagRefs = getContentTagRefs(event.content, eTags); - - // legacy behavior - // https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated - const legacyETags = eTags.filter((t) => { - // ignore it if there is a type - if (t[3]) return false; - if (contentTagRefs.includes(t)) return false; - return true; - }); - - if (legacyETags.length >= 1) { - // first tag is the root - rootETag = legacyETags[0]; - // last tag is reply - replyETag = legacyETags[legacyETags.length - 1] ?? rootETag; - } - } - - return { - root: rootETag || rootATag ? { e: rootETag, a: rootATag } : undefined, - reply: replyETag || replyATag ? { e: replyETag, a: replyATag } : undefined, - } as { - root?: { e: ETag; a: undefined } | { e: undefined; a: ATag } | { e: ETag; a: ATag }; - reply?: { e: ETag; a: undefined } | { e: undefined; a: ATag } | { e: ETag; a: ATag }; - }; -} - -export type ThreadReferences = { - root?: - | { e: EventPointer; a: undefined } - | { e: undefined; a: AddressPointer } - | { e: EventPointer; a: AddressPointer }; - reply?: - | { e: EventPointer; a: undefined } - | { e: undefined; a: AddressPointer } - | { e: EventPointer; a: AddressPointer }; -}; -export const threadRefsSymbol = Symbol("threadRefs"); -export type EventWithThread = (NostrEvent | DraftNostrEvent) & { [threadRefsSymbol]: ThreadReferences }; - -export function getThreadReferences(event: NostrEvent | DraftNostrEvent): ThreadReferences { - // @ts-expect-error - if (Object.hasOwn(event, threadRefsSymbol)) return event[threadRefsSymbol]; - - const e = event as EventWithThread; - const tags = interpretThreadTags(e); - - const threadRef = { - root: tags.root && { - e: tags.root.e && eTagToEventPointer(tags.root.e), - a: tags.root.a && aTagToAddressPointer(tags.root.a), - }, - reply: tags.reply && { - e: tags.reply.e && eTagToEventPointer(tags.reply.e), - a: tags.reply.a && aTagToAddressPointer(tags.reply.a), - }, - } as ThreadReferences; - - // @ts-expect-error - event[threadRefsSymbol] = threadRef; - - return threadRef; -} +/** @deprecated */ +export { getNip10References as getThreadReferences }; export function getEventCoordinate(event: NostrEvent) { const d = event.tags.find(isDTag)?.[1]; diff --git a/src/helpers/nostr/threading.ts b/src/helpers/nostr/threading.ts new file mode 100644 index 000000000..13169d31c --- /dev/null +++ b/src/helpers/nostr/threading.ts @@ -0,0 +1,94 @@ +import { EventTemplate, NostrEvent } from "nostr-tools"; +import { AddressPointer, EventPointer } from "nostr-tools/nip19"; + +import { ATag, ETag, isATag, isETag } from "../../types/nostr-event"; +import { aTagToAddressPointer, eTagToEventPointer, getContentTagRefs } from "./event"; + +export function interpretThreadTags(event: NostrEvent | EventTemplate) { + const eTags = event.tags.filter(isETag); + const aTags = event.tags.filter(isATag); + + // find the root and reply tags. + let rootETag = eTags.find((t) => t[3] === "root"); + let replyETag = eTags.find((t) => t[3] === "reply"); + + let rootATag = aTags.find((t) => t[3] === "root"); + let replyATag = aTags.find((t) => t[3] === "reply"); + + if (!rootETag || !replyETag) { + // a direct reply does not need a "reply" reference + // https://github.com/nostr-protocol/nips/blob/master/10.md + + // this is not necessarily to spec. but if there is only one id (root or reply) then assign it to both + // this handles the cases where a client only set a "reply" tag and no root + rootETag = replyETag = rootETag || replyETag; + } + if (!rootATag || !replyATag) { + rootATag = replyATag = rootATag || replyATag; + } + + if (!rootETag && !replyETag) { + const contentTagRefs = getContentTagRefs(event.content, eTags); + + // legacy behavior + // https://github.com/nostr-protocol/nips/blob/master/10.md#positional-e-tags-deprecated + const legacyETags = eTags.filter((t) => { + // ignore it if there is a type + if (t[3]) return false; + if (contentTagRefs.includes(t)) return false; + return true; + }); + + if (legacyETags.length >= 1) { + // first tag is the root + rootETag = legacyETags[0]; + // last tag is reply + replyETag = legacyETags[legacyETags.length - 1] ?? rootETag; + } + } + + return { + root: rootETag || rootATag ? { e: rootETag, a: rootATag } : undefined, + reply: replyETag || replyATag ? { e: replyETag, a: replyATag } : undefined, + } as { + root?: { e: ETag; a: undefined } | { e: undefined; a: ATag } | { e: ETag; a: ATag }; + reply?: { e: ETag; a: undefined } | { e: undefined; a: ATag } | { e: ETag; a: ATag }; + }; +} + +export type ThreadReferences = { + root?: + | { e: EventPointer; a: undefined } + | { e: undefined; a: AddressPointer } + | { e: EventPointer; a: AddressPointer }; + reply?: + | { e: EventPointer; a: undefined } + | { e: undefined; a: AddressPointer } + | { e: EventPointer; a: AddressPointer }; +}; +export const threadRefsSymbol = Symbol("threadRefs"); +export type EventWithThread = (NostrEvent | EventTemplate) & { [threadRefsSymbol]: ThreadReferences }; + +export function getNip10References(event: NostrEvent | EventTemplate): ThreadReferences { + // @ts-expect-error + if (Object.hasOwn(event, threadRefsSymbol)) return event[threadRefsSymbol]; + + const e = event as EventWithThread; + const tags = interpretThreadTags(e); + + const threadRef = { + root: tags.root && { + e: tags.root.e && eTagToEventPointer(tags.root.e), + a: tags.root.a && aTagToAddressPointer(tags.root.a), + }, + reply: tags.reply && { + e: tags.reply.e && eTagToEventPointer(tags.reply.e), + a: tags.reply.a && aTagToAddressPointer(tags.reply.a), + }, + } as ThreadReferences; + + // @ts-expect-error + event[threadRefsSymbol] = threadRef; + + return threadRef; +} diff --git a/src/helpers/thread.ts b/src/helpers/thread.ts index 3f6ddcddb..9ebcd10f8 100644 --- a/src/helpers/thread.ts +++ b/src/helpers/thread.ts @@ -1,5 +1,5 @@ import { NostrEvent } from "../types/nostr-event"; -import { ThreadReferences, getThreadReferences } from "./nostr/event"; +import { getNip10References, ThreadReferences } from "./nostr/threading"; export function countReplies(replies: ThreadItem[]): number { return replies.reduce((c, item) => c + countReplies(item.replies), 0) + replies.length; @@ -37,7 +37,7 @@ export function buildThread(events: NostrEvent[]) { const replies = new Map(); for (const event of events) { - const refs = getThreadReferences(event); + const refs = getNip10References(event); if (refs.reply?.e) { idToChildren[refs.reply.e.id] = idToChildren[refs.reply.e.id] || []; diff --git a/src/views/articles/article.tsx b/src/views/articles/article.tsx new file mode 100644 index 000000000..ecc0894ac --- /dev/null +++ b/src/views/articles/article.tsx @@ -0,0 +1,72 @@ +import { NostrEvent } from "nostr-tools"; +import { Box, Flex, Heading, Image, Spinner, Text } from "@chakra-ui/react"; +import dayjs from "dayjs"; + +import useParamsAddressPointer from "../../hooks/use-params-address-pointer"; +import useReplaceableEvent from "../../hooks/use-replaceable-event"; +import VerticalPageLayout from "../../components/vertical-page-layout"; +import { + getArticleImage, + getArticlePublishDate, + getArticleSummary, + getArticleTitle, +} from "../../helpers/nostr/long-form"; +import UserLink from "../../components/user/user-link"; +import UserAvatarLink from "../../components/user/user-avatar-link"; +import UserDnsIdentityIcon from "../../components/user/user-dns-identity-icon"; +import MarkdownContent from "../wiki/components/markdown"; +import ArticleMenu from "./components/article-menu"; +import ArticleTags from "./components/article-tags"; +import NoteReactions from "../../components/note/timeline-note/components/note-reactions"; +import NoteZapButton from "../../components/note/note-zap-button"; +import ZapBubbles from "../../components/note/timeline-note/components/zap-bubbles"; + +function ArticlePage({ article }: { article: NostrEvent }) { + const image = getArticleImage(article); + const title = getArticleTitle(article); + const published = getArticlePublishDate(article); + const summary = getArticleSummary(article); + + return ( + + + + {title} + {summary} + + + + +
+ {dayjs.unix(published ?? article.created_at).format("LL")} +
+ +
+ {image && } + + + + + + + + + + + + + + +
+ ); +} + +export default function ArticleView() { + const pointer = useParamsAddressPointer("naddr"); + + const article = useReplaceableEvent(pointer); + + if (!article) return ; + + return ; +} diff --git a/src/views/articles/components/article-card.tsx b/src/views/articles/components/article-card.tsx new file mode 100644 index 000000000..de8c7cab9 --- /dev/null +++ b/src/views/articles/components/article-card.tsx @@ -0,0 +1,64 @@ +import { Box, Card, Flex, Heading, LinkBox, Spacer, Text } from "@chakra-ui/react"; +import { NostrEvent } from "nostr-tools"; +import { Link as RouterLink } from "react-router-dom"; + +import { + getArticleImage, + getArticlePublishDate, + getArticleSummary, + getArticleTitle, +} from "../../../helpers/nostr/long-form"; +import UserAvatar from "../../../components/user/user-avatar"; +import UserName from "../../../components/user/user-name"; +import Timestamp from "../../../components/timestamp"; +import HoverLinkOverlay from "../../../components/hover-link-overlay"; +import useShareableEventAddress from "../../../hooks/use-shareable-event-address"; +import ArticleTags from "./article-tags"; +import ArticleMenu from "./article-menu"; +import ZapBubbles from "../../../components/note/timeline-note/components/zap-bubbles"; + +export default function ArticleCard({ article }: { article: NostrEvent }) { + const image = getArticleImage(article); + const title = getArticleTitle(article); + const published = getArticlePublishDate(article); + const summary = getArticleSummary(article); + + const naddr = useShareableEventAddress(article); + + return ( + + + + + + + + + + {image && ( + + )} + + + {title} + + + {summary} + + + + + + ); +} diff --git a/src/views/articles/components/article-menu.tsx b/src/views/articles/components/article-menu.tsx new file mode 100644 index 000000000..a822f4aa2 --- /dev/null +++ b/src/views/articles/components/article-menu.tsx @@ -0,0 +1,52 @@ +import { useCallback } from "react"; +import { MenuItem } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; +import { NostrEvent } from "nostr-tools"; + +import useShareableEventAddress from "../../../hooks/use-shareable-event-address"; +import { usePublishEvent } from "../../../providers/global/publish-provider"; +import { DotsMenuButton, MenuIconButtonProps } from "../../../components/dots-menu-button"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import ShareLinkMenuItem from "../../../components/common-menu-items/share-link"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; +import DeleteEventMenuItem from "../../../components/common-menu-items/delete-event"; +import Recording02 from "../../../components/icons/recording-02"; +import Translate01 from "../../../components/icons/translate-01"; +import { BroadcastEventIcon } from "../../../components/icons"; +import DebugEventMenuItem from "../../../components/debug-modal/debug-event-menu-item"; + +export default function ArticleMenu({ + article, + ...props +}: { article: NostrEvent } & Omit) { + const publish = usePublishEvent(); + + const address = useShareableEventAddress(article); + + const broadcast = useCallback(async () => { + await publish("Broadcast", article); + }, []); + + return ( + <> + + + + + + + {/* } to={`/tools/transform/${address}?tab=tts`}> + Text to speech + + } to={`/tools/transform/${address}?tab=translation`}> + Translate + */} + + }> + Broadcast + + + + + ); +} diff --git a/src/views/articles/components/article-tags.tsx b/src/views/articles/components/article-tags.tsx new file mode 100644 index 000000000..a9181fd56 --- /dev/null +++ b/src/views/articles/components/article-tags.tsx @@ -0,0 +1,16 @@ +import { Tag } from "@chakra-ui/react"; +import { NostrEvent } from "nostr-tools"; + +export default function ArticleTags({ article }: { article: NostrEvent }) { + return ( + <> + {article.tags + .filter((t) => t[0] === "t" && t[1]) + .map(([_, hashtag]: string[], i) => ( + + #{hashtag} + + ))} + + ); +} diff --git a/src/views/articles/index.tsx b/src/views/articles/index.tsx new file mode 100644 index 000000000..fcd9db497 --- /dev/null +++ b/src/views/articles/index.tsx @@ -0,0 +1,71 @@ +import { useCallback, useMemo } from "react"; +import { Filter, kinds, NostrEvent } from "nostr-tools"; +import { Button, Flex, Heading, Spacer } from "@chakra-ui/react"; +import { getEventUID } from "nostr-idb"; +import { Link as RouterLink } from "react-router-dom"; + +import VerticalPageLayout from "../../components/vertical-page-layout"; +import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider"; +import Plus from "../../components/icons/plus"; +import { useReadRelays } from "../../hooks/use-client-relays"; +import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter"; +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import useSubject from "../../hooks/use-subject"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import IntersectionObserverProvider from "../../providers/local/intersection-observer"; +import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status"; +import ArticleCard from "./components/article-card"; +import PeopleListSelection from "../../components/people-list-selection/people-list-selection"; +import { getArticleTitle } from "../../helpers/nostr/long-form"; + +function ArticlesHomePage() { + const relays = useReadRelays(); + const userMuteFilter = useClientSideMuteFilter(); + + const eventFilter = useCallback( + (event: NostrEvent) => { + if (userMuteFilter(event)) return false; + return true; + }, + [userMuteFilter], + ); + + const { filter, listId } = usePeopleListContext(); + const query = useMemo(() => { + if (!filter) return undefined; + return [{ authors: filter.authors, kinds: [kinds.LongFormArticle] }]; + }, [filter]); + + const timeline = useTimelineLoader(`${listId ?? "global"}-articles`, relays, query, { eventFilter }); + + const articles = useSubject(timeline.timeline).filter((article) => !!getArticleTitle(article)); + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + + + Articles + + + {/* */} + + + + {articles.map((article) => ( + + ))} + + + + ); +} + +export default function ArticlesHomeView() { + return ( + + + + ); +} diff --git a/src/views/other-stuff/apps.ts b/src/views/other-stuff/apps.ts index 62391cfa9..badd81a04 100644 --- a/src/views/other-stuff/apps.ts +++ b/src/views/other-stuff/apps.ts @@ -53,6 +53,7 @@ export const internalApps: App[] = [ { title: "Lists", description: "Browse and create lists", icon: ListsIcon, id: "lists", to: "/lists" }, { title: "Tracks", description: "Browse stemstr tracks", icon: TrackIcon, id: "tracks", to: "/tracks" }, { title: "Videos", description: "Browse flare videos", icon: Film02, id: "videos", to: "/videos" }, + { title: "Articles", description: "Browse articles", icon: Edit04, id: "articles", to: "/articles" }, ]; export const internalTools: App[] = [ diff --git a/src/views/user/articles.tsx b/src/views/user/articles.tsx index d2371bb06..269454d18 100644 --- a/src/views/user/articles.tsx +++ b/src/views/user/articles.tsx @@ -8,7 +8,7 @@ import IntersectionObserverProvider from "../../providers/local/intersection-obs import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status"; import VerticalPageLayout from "../../components/vertical-page-layout"; -import ArticleNote from "../../components/timeline-page/generic-note-timeline/article-note"; +import ArticleCard from "../articles/components/article-card"; export default function UserArticlesTab() { const { pubkey } = useOutletContext() as { pubkey: string }; @@ -26,7 +26,7 @@ export default function UserArticlesTab() { {articles.map((article) => ( - + ))}