mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-21 14:09:17 +02:00
add simple article view
add support for wiki links in text notes
This commit is contained in:
5
.changeset/sweet-spiders-tickle.md
Normal file
5
.changeset/sweet-spiders-tickle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for wiki links in text notes
|
5
.changeset/tender-hornets-rest.md
Normal file
5
.changeset/tender-hornets-rest.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add simple article view
|
@@ -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: <CommunitiesExploreView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "articles",
|
||||
children: [
|
||||
{ path: "", element: <ArticlesHomeView /> },
|
||||
{ path: ":naddr", element: <ArticleView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "c/:community",
|
||||
children: [
|
||||
|
@@ -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<CardProps, "children"> & { 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 (
|
||||
<Card as={LinkBox} size="sm" onClick={open} cursor="pointer" {...props}>
|
||||
<Card as={LinkBox} size="sm" {...props}>
|
||||
{image && (
|
||||
<Box
|
||||
backgroundImage={image}
|
||||
@@ -50,15 +44,14 @@ export default function EmbeddedArticle({ article, ...props }: Omit<CardProps, "
|
||||
<UserLink pubkey={article.pubkey} fontWeight="bold" isTruncated />
|
||||
<Timestamp timestamp={getArticlePublishDate(article) ?? article.created_at} />
|
||||
</Flex>
|
||||
<Heading size="md">{title}</Heading>
|
||||
<Heading size="md">
|
||||
<HoverLinkOverlay as={RouterLink} to={`/articles/${naddr}`}>
|
||||
{title}
|
||||
</HoverLinkOverlay>
|
||||
</Heading>
|
||||
<Text mb="2">{summary}</Text>
|
||||
{article.tags
|
||||
.filter((t) => t[0] === "t" && t[1])
|
||||
.map(([_, hashtag]: string[], i) => (
|
||||
<Tag key={hashtag + i} mr="2" mb="2">
|
||||
#{hashtag}
|
||||
</Tag>
|
||||
))}
|
||||
|
||||
<ArticleTags article={article} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
24
src/components/external-embeds/types/wiki.tsx
Normal file
24
src/components/external-embeds/types/wiki.tsx
Normal file
@@ -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 <WikiLink topic={topic}>{label}</WikiLink>;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error("Failed to embed link", match[0], e.message);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -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 (
|
||||
<Box ref={ref}>
|
||||
<EmbeddedArticle article={article} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ArticleNote);
|
@@ -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 = <RepostEvent event={event} />;
|
||||
break;
|
||||
case kinds.LongFormArticle:
|
||||
content = <ArticleNote article={event} />;
|
||||
break;
|
||||
case STREAM_KIND:
|
||||
content = <StreamNote event={event} />;
|
||||
break;
|
||||
|
@@ -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) {
|
||||
|
@@ -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];
|
||||
|
94
src/helpers/nostr/threading.ts
Normal file
94
src/helpers/nostr/threading.ts
Normal file
@@ -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;
|
||||
}
|
@@ -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<string, ThreadItem>();
|
||||
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] || [];
|
||||
|
72
src/views/articles/article.tsx
Normal file
72
src/views/articles/article.tsx
Normal file
@@ -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 (
|
||||
<VerticalPageLayout pt={{ base: "2", lg: "8" }} pb="12">
|
||||
<Box mx="auto" maxW="4xl" w="full" mb="2">
|
||||
<ArticleMenu article={article} aria-label="More Options" float="right" />
|
||||
<Heading size="xl">{title}</Heading>
|
||||
<Text>{summary}</Text>
|
||||
<Box py="2">
|
||||
<UserAvatarLink pubkey={article.pubkey} float="left" mr="3" mb="2" />
|
||||
<UserLink pubkey={article.pubkey} fontWeight="bold" fontSize="xl" mr="2" tab="articles" />
|
||||
<UserDnsIdentityIcon pubkey={article.pubkey} />
|
||||
<br />
|
||||
<Text>{dayjs.unix(published ?? article.created_at).format("LL")}</Text>
|
||||
</Box>
|
||||
<ArticleTags article={article} />
|
||||
</Box>
|
||||
{image && <Image src={image} maxW="6xl" w="full" mx="auto" maxH="60vh" />}
|
||||
<Box mx="auto" maxW="4xl" w="full">
|
||||
<ZapBubbles event={article} />
|
||||
<Flex gap="2">
|
||||
<NoteZapButton event={article} size="sm" variant="ghost" showEventPreview={false} />
|
||||
<NoteReactions event={article} size="sm" variant="ghost" />
|
||||
</Flex>
|
||||
<Box fontSize="lg">
|
||||
<MarkdownContent event={article} />
|
||||
</Box>
|
||||
<Flex gap="2">
|
||||
<NoteZapButton event={article} size="sm" variant="ghost" showEventPreview={false} />
|
||||
<NoteReactions event={article} size="sm" variant="ghost" />
|
||||
</Flex>
|
||||
</Box>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArticleView() {
|
||||
const pointer = useParamsAddressPointer("naddr");
|
||||
|
||||
const article = useReplaceableEvent(pointer);
|
||||
|
||||
if (!article) return <Spinner />;
|
||||
|
||||
return <ArticlePage article={article} />;
|
||||
}
|
64
src/views/articles/components/article-card.tsx
Normal file
64
src/views/articles/components/article-card.tsx
Normal file
@@ -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 (
|
||||
<Card as={LinkBox} display="block" p="2" position="relative" variant="ghost">
|
||||
<Flex gap="2" alignItems="center" mb="2">
|
||||
<UserAvatar pubkey={article.pubkey} size="sm" />
|
||||
<UserName pubkey={article.pubkey} />
|
||||
<Timestamp timestamp={published ?? article.created_at} />
|
||||
<Spacer />
|
||||
<ArticleMenu aria-label="More Options" article={article} variant="ghost" size="sm" zIndex={10} />
|
||||
</Flex>
|
||||
|
||||
{image && (
|
||||
<Box
|
||||
aspectRatio={16 / 9}
|
||||
backgroundImage={image}
|
||||
backgroundPosition="center"
|
||||
backgroundRepeat="no-repeat"
|
||||
backgroundSize="cover"
|
||||
float={{ base: undefined, lg: "right" }}
|
||||
mx={{ base: "auto", lg: 2 }}
|
||||
mb={{ base: "2", lg: undefined }}
|
||||
minH="10rem"
|
||||
maxH="15rem"
|
||||
/>
|
||||
)}
|
||||
<Heading size="md">
|
||||
<HoverLinkOverlay as={RouterLink} to={`/articles/${naddr}`}>
|
||||
{title}
|
||||
</HoverLinkOverlay>
|
||||
</Heading>
|
||||
<Text>{summary}</Text>
|
||||
|
||||
<ArticleTags article={article} />
|
||||
|
||||
<ZapBubbles event={article} mt="2" mr="2" />
|
||||
</Card>
|
||||
);
|
||||
}
|
52
src/views/articles/components/article-menu.tsx
Normal file
52
src/views/articles/components/article-menu.tsx
Normal file
@@ -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<MenuIconButtonProps, "children">) {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const address = useShareableEventAddress(article);
|
||||
|
||||
const broadcast = useCallback(async () => {
|
||||
await publish("Broadcast", article);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DotsMenuButton {...props}>
|
||||
<OpenInAppMenuItem event={article} />
|
||||
<ShareLinkMenuItem event={article} />
|
||||
<CopyEmbedCodeMenuItem event={article} />
|
||||
<DeleteEventMenuItem event={article} />
|
||||
|
||||
{/* <MenuItem as={RouterLink} icon={<Recording02 />} to={`/tools/transform/${address}?tab=tts`}>
|
||||
Text to speech
|
||||
</MenuItem>
|
||||
<MenuItem as={RouterLink} icon={<Translate01 />} to={`/tools/transform/${address}?tab=translation`}>
|
||||
Translate
|
||||
</MenuItem> */}
|
||||
|
||||
<MenuItem onClick={broadcast} icon={<BroadcastEventIcon />}>
|
||||
Broadcast
|
||||
</MenuItem>
|
||||
<DebugEventMenuItem event={article} />
|
||||
</DotsMenuButton>
|
||||
</>
|
||||
);
|
||||
}
|
16
src/views/articles/components/article-tags.tsx
Normal file
16
src/views/articles/components/article-tags.tsx
Normal file
@@ -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) => (
|
||||
<Tag key={hashtag + i} mr="2" mt="2" whiteSpace="pre" flexShrink={0}>
|
||||
#{hashtag}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
71
src/views/articles/index.tsx
Normal file
71
src/views/articles/index.tsx
Normal file
@@ -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<Filter[] | undefined>(() => {
|
||||
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 (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2">
|
||||
<Heading>Articles</Heading>
|
||||
<PeopleListSelection />
|
||||
<Spacer />
|
||||
{/* <Button as={RouterLink} to="/articles/new" colorScheme="primary" leftIcon={<Plus boxSize={6} />}>
|
||||
New
|
||||
</Button> */}
|
||||
</Flex>
|
||||
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
{articles.map((article) => (
|
||||
<ArticleCard key={getEventUID(article)} article={article} />
|
||||
))}
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</IntersectionObserverProvider>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArticlesHomeView() {
|
||||
return (
|
||||
<PeopleListProvider>
|
||||
<ArticlesHomePage />
|
||||
</PeopleListProvider>
|
||||
);
|
||||
}
|
@@ -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[] = [
|
||||
|
@@ -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() {
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<VerticalPageLayout>
|
||||
{articles.map((article) => (
|
||||
<ArticleNote key={article.id} article={article} />
|
||||
<ArticleCard key={article.id} article={article} />
|
||||
))}
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</VerticalPageLayout>
|
||||
|
Reference in New Issue
Block a user