add articles tab to users view

This commit is contained in:
hzrd149 2023-09-08 16:06:17 -05:00
parent 81e86c9550
commit 076b89e1b6
14 changed files with 143 additions and 15 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add articles tab to user view

View File

@ -54,6 +54,7 @@ import MutedByView from "./views/user/muted-by";
import BadgesView from "./views/badges";
import BadgesBrowseView from "./views/badges/browse";
import BadgeDetailsView from "./views/badges/badge-details";
import UserArticlesTab from "./views/user/articles";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
@ -134,6 +135,7 @@ const router = createHashRouter([
{ path: "", element: <UserAboutTab /> },
{ path: "about", element: <UserAboutTab /> },
{ path: "notes", element: <UserNotesTab /> },
{ path: "articles", element: <UserArticlesTab /> },
{ path: "streams", element: <UserStreamsTab /> },
{ path: "zaps", element: <UserZapsTab /> },
{ path: "likes", element: <UserReactionsTab /> },

View File

@ -0,0 +1,54 @@
import { useRef } from "react";
import { Card, CardProps, Flex, Image, LinkBox, LinkOverlay, Tag, Text } from "@chakra-ui/react";
import dayjs from "dayjs";
import {
getArticleImage,
getArticlePublishDate,
getArticleSummary,
getArticleTitle,
} from "../../../helpers/nostr/long-form";
import { NostrEvent } from "../../../types/nostr-event";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getEventUID } from "../../../helpers/nostr/events";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
export default function EmbeddedArticle({ article, ...props }: Omit<CardProps, "children"> & { article: NostrEvent }) {
const title = getArticleTitle(article);
const image = getArticleImage(article);
const summary = getArticleSummary(article);
const naddr = getSharableEventAddress(article);
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(article));
return (
<Card as={LinkBox} ref={ref} p="2" flexDirection="row" {...props}>
<Flex gap="2" direction="column" flex={1}>
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={article.pubkey} size="sm" />
<LinkOverlay href={naddr ? buildAppSelectUrl(naddr, false) : undefined} isExternal fontWeight="bold">
{title}
</LinkOverlay>
<Text>by:</Text>
<UserLink pubkey={article.pubkey} />
<Text>| {dayjs.unix(getArticlePublishDate(article) ?? article.created_at).fromNow()}</Text>
</Flex>
<Text flex={1}>{summary}</Text>
<Flex gap="2" alignItems="center">
{article.tags
.filter((t) => t[0] === "t")
.map(([_, hashtag]) => (
<Tag>{hashtag}</Tag>
))}
</Flex>
</Flex>
{image && <Image src={image} alt={title} maxW="2in" maxH="2in" float="right" borderRadius="md" />}
</Card>
);
}

View File

@ -17,6 +17,7 @@ import EmbeddedGoal, { EmbeddedGoalOptions } from "./event-types/embedded-goal";
import EmbeddedUnknown from "./event-types/embedded-unknown";
import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../helpers/nostr/lists";
import EmbeddedList from "./event-types/embedded-list";
import EmbeddedArticle from "./event-types/embedded-article";
export type EmbedProps = {
goalProps?: EmbeddedGoalOptions;
@ -35,6 +36,8 @@ export function EmbedEvent({ event, goalProps }: { event: NostrEvent } & EmbedPr
case PEOPLE_LIST_KIND:
case NOTE_LIST_KIND:
return <EmbeddedList list={event} />;
case Kind.Article:
return <EmbeddedArticle article={event} />;
}
return <EmbeddedUnknown event={event} />;

View File

@ -8,7 +8,6 @@ import {
GoalIcon,
ListIcon,
LiveStreamIcon,
MapIcon,
NotificationIcon,
RelayIcon,
SearchIcon,
@ -60,9 +59,6 @@ export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean
<Button onClick={() => navigate("/emojis")} leftIcon={<EmojiIcon />} justifyContent="flex-start">
Emojis
</Button>
<Button onClick={() => navigate("/map")} leftIcon={<MapIcon />} justifyContent="flex-start">
Map
</Button>
<Button onClick={() => navigate("/tools")} leftIcon={<ToolsIcon />} justifyContent="flex-start">
Tools
</Button>

View File

@ -2,6 +2,7 @@ import {
AvatarGroup,
Box,
Divider,
Flex,
Heading,
Modal,
ModalBody,
@ -10,6 +11,7 @@ import {
ModalHeader,
ModalOverlay,
ModalProps,
SimpleGrid,
} from "@chakra-ui/react";
import { useMemo } from "react";
@ -17,6 +19,7 @@ import { NostrEvent } from "../types/nostr-event";
import { groupReactions } from "../helpers/nostr/reactions";
import { ReactionIcon } from "./event-reactions";
import { UserAvatarLink } from "./user-avatar-link";
import { UserLink } from "./user-link";
export type ReactionDetailsModalProps = Omit<ModalProps, "children"> & {
reactions: NostrEvent[];
@ -26,22 +29,30 @@ export default function ReactionDetailsModal({ reactions, onClose, ...props }: R
const groups = useMemo(() => groupReactions(reactions), [reactions]);
return (
<Modal onClose={onClose} {...props}>
<Modal onClose={onClose} size="2xl" {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" pb="0">
Reactions
</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" gap="2" px="4" pt="0" flexWrap="wrap">
<ModalBody display="flex" gap="2" px="4" pt="0" flexDirection="column">
{groups.map((group) => (
<Box key={group.emoji}>
<ReactionIcon emoji={group.emoji} url={group.url} />
<AvatarGroup size="sm" flexWrap="wrap">
<Flex gap="2" py="2" alignItems="center">
<Box fontSize="lg" borderWidth={1} w="8" h="8" borderRadius="md" p="1">
<ReactionIcon emoji={group.emoji} url={group.url} />
</Box>
<Divider />
</Flex>
<SimpleGrid columns={{ base: 2, sm: 3, md: 4 }} spacing="1">
{group.pubkeys.map((pubkey) => (
<UserAvatarLink key={pubkey} pubkey={pubkey} />
<Flex gap="2" key={pubkey} alignItems="center" overflow="hidden">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} isTruncated />
</Flex>
))}
</AvatarGroup>
</SimpleGrid>
</Box>
))}
</ModalBody>

View File

@ -11,6 +11,7 @@ import StreamNote from "./stream-note";
import { ErrorBoundary } from "../../error-boundary";
import RelayCard from "../../../views/relays/components/relay-card";
import { safeRelayUrl } from "../../../helpers/url";
import EmbeddedArticle from "../../embed-event/event-types/embedded-article";
const RenderEvent = React.memo(({ event }: { event: NostrEvent }) => {
switch (event.kind) {
@ -18,6 +19,8 @@ const RenderEvent = React.memo(({ event }: { event: NostrEvent }) => {
return <Note event={event} showReplyButton />;
case Kind.Repost:
return <RepostNote event={event} />;
case Kind.Article:
return <EmbeddedArticle article={event} />;
case STREAM_KIND:
return <StreamNote event={event} />;
case 2:

View File

@ -1,3 +1,3 @@
export function buildAppSelectUrl(identifier: string) {
return `https://nostrapp.link/#${identifier}?select=true`;
export function buildAppSelectUrl(identifier: string, select = true) {
return `https://nostrapp.link/#${identifier}` + (select ? "?select=true" : "");
}

View File

@ -0,0 +1,15 @@
import { NostrEvent } from "../../types/nostr-event";
export function getArticleTitle(event: NostrEvent) {
return event.tags.find((t) => t[0] === "title")?.[1];
}
export function getArticleSummary(event: NostrEvent) {
return event.tags.find((t) => t[0] === "summary")?.[1];
}
export function getArticleImage(event: NostrEvent) {
return event.tags.find((t) => t[0] === "image")?.[1];
}
export function getArticlePublishDate(event: NostrEvent) {
const timestamp = event.tags.find((t) => t[0] === "published_at")?.[1];
return timestamp ? parseInt(timestamp) : undefined;
}

View File

@ -26,7 +26,7 @@ function HomePage() {
const { relays } = useRelaySelectionContext();
const { listId, filter } = usePeopleListContext();
const kinds = [Kind.Text, Kind.Repost, 2];
const kinds = [Kind.Text, Kind.Repost, Kind.Article, 2];
const query = useMemo<NostrRequestFilter>(() => {
if (filter === undefined) return { kinds };
return { ...filter, kinds };

View File

@ -1,6 +1,6 @@
import { Button, Flex, Heading, Image, Link } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { ExternalLinkIcon, ToolsIcon } from "../../components/icons";
import { ExternalLinkIcon, MapIcon, ToolsIcon } from "../../components/icons";
export default function ToolsHomeView() {
return (
@ -12,6 +12,9 @@ export default function ToolsHomeView() {
<Button as={RouterLink} to="/tools/network">
Contact network
</Button>
<Button as={RouterLink} to="/map" leftIcon={<MapIcon />}>
Map
</Button>
<Button
as={Link}
href="https://w3.do/"

View File

@ -0,0 +1,35 @@
import { useOutletContext } from "react-router-dom";
import { Flex } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import EmbeddedArticle from "../../components/embed-event/event-types/embedded-article";
export default function UserArticlesTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const timeline = useTimelineLoader(pubkey + "-articles", readRelays, {
authors: [pubkey],
kinds: [Kind.Article],
});
const articles = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<Flex gap="2" pt="2" pb="10" px={["2", "2", 0]} direction="column">
{articles.map((article) => (
<EmbeddedArticle article={article} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
}

View File

@ -44,6 +44,7 @@ import { ErrorBoundary } from "../../components/error-boundary";
const tabs = [
{ label: "About", path: "about" },
{ label: "Notes", path: "notes" },
{ label: "Articles", path: "articles" },
{ label: "Streams", path: "streams" },
{ label: "Zaps", path: "zaps" },
{ label: "Lists", path: "lists" },

View File

@ -32,7 +32,7 @@ export default function UserNotesTab() {
readRelays,
{
authors: [pubkey],
kinds: [Kind.Text, Kind.Repost, STREAM_KIND, 2],
kinds: [Kind.Text, Kind.Repost, Kind.Article, STREAM_KIND, 2],
},
{ eventFilter },
);