Add support for Olas media posts

This commit is contained in:
hzrd149 2024-12-10 12:58:48 -06:00
parent 3fc9c64cd4
commit 2f1d50af5e
29 changed files with 950 additions and 227 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for NIP-22 comments on media posts

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for Olas media posts

View File

@ -28,9 +28,9 @@
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/language": "^6.10.6",
"@codemirror/view": "^6.35.2",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@codemirror/view": "^6.35.3",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@getalby/bitcoin-connect": "^3.6.3",
"@getalby/bitcoin-connect-react": "^3.6.3",
"@noble/ciphers": "^1.1.3",
@ -45,6 +45,7 @@
"applesauce-channel": "next",
"applesauce-content": "next",
"applesauce-core": "next",
"applesauce-factory": "next",
"applesauce-lists": "next",
"applesauce-net": "next",
"applesauce-react": "next",
@ -83,6 +84,7 @@
"nostr-idb": "^2.2.0",
"nostr-tools": "^2.10.4",
"nostr-wasm": "^0.1.0",
"nuka-carousel": "^8.1.1",
"prettier": "^3.4.2",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
@ -99,7 +101,7 @@
"react-router-dom": "^6.28.0",
"react-simplemde-editor": "^5.2.0",
"react-singleton-hook": "^4.0.1",
"react-use": "^17.5.1",
"react-use": "^17.6.0",
"react-virtualized-auto-sizer": "^1.0.24",
"react-window": "^1.8.10",
"remark-gfm": "^4.0.0",
@ -132,7 +134,7 @@
"@types/lodash.throttle": "^4.1.9",
"@types/ngeohash": "^0.6.8",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.2",
"@types/react-dom": "^18.3.3",
"@types/react-window": "^1.8.8",
"@types/three": "^0.160.0",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",

428
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,8 @@ const DVMFeedView = lazy(() => import("./views/discovery/dvm-feed/feed"));
const BlindspotHomeView = lazy(() => import("./views/discovery/blindspot"));
const BlindspotFeedView = lazy(() => import("./views/discovery/blindspot/feed"));
const RelayDiscoveryView = lazy(() => import("./views/discovery/relays/index"));
const MediaFeedView = lazy(() => import("./views/media/index"));
const MediaPostView = lazy(() => import("./views/media/media-post"));
import SettingsView from "./views/settings";
import NostrLinkView from "./views/link";
import ProfileView from "./views/profile";
@ -106,6 +108,7 @@ import ArticlesHomeView from "./views/articles";
import ArticleView from "./views/articles/article";
import WalletView from "./views/wallet";
import SupportView from "./views/support";
import UserMediaPostsTab from "./views/user/media-posts";
const TracksView = lazy(() => import("./views/tracks"));
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const UserVideosTab = lazy(() => import("./views/user/videos"));
@ -258,6 +261,7 @@ const router = createHashRouter([
{ path: "about", element: <UserAboutTab /> },
{ path: "notes", element: <UserNotesTab /> },
{ path: "articles", element: <UserArticlesTab /> },
{ path: "media", element: <UserMediaPostsTab /> },
{ path: "streams", element: <UserStreamsTab /> },
{ path: "tracks", element: <UserTracksTab /> },
{ path: "videos", element: <UserVideosTab /> },
@ -365,6 +369,13 @@ const router = createHashRouter([
},
],
},
{
path: "media",
children: [
{ path: "", element: <MediaFeedView /> },
{ path: ":pointer", element: <MediaPostView /> },
],
},
{
path: "wiki",
children: [

View File

@ -1,4 +1,4 @@
import { lazy } from "react";
import { lazy, VideoHTMLAttributes } from "react";
import styled from "@emotion/styled";
import { isStreamURL, isVideoURL } from "../../../helpers/url";
@ -15,7 +15,7 @@ const StyledVideo = styled.video`
z-index: 1;
`;
function TrustVideo({ src }: { src: string }) {
export function TrustVideo({ src, ...props }: { src: string } & VideoHTMLAttributes<HTMLVideoElement>) {
const { blurImages } = useAppSettings();
const { onClick, handleEvent, style } = useElementTrustBlur();
@ -26,6 +26,7 @@ function TrustVideo({ src }: { src: string }) {
style={blurImages ? style : undefined}
onClick={blurImages ? onClick : undefined}
onPlay={blurImages ? handleEvent : undefined}
{...props}
/>
);
}

View File

@ -8,6 +8,7 @@ import Timestamp from "../../timestamp";
import ReactionIcon from "../../event-reactions/reaction-icon";
import { NoteLink } from "../../note/note-link";
import { nip25 } from "nostr-tools";
import DebugEventButton from "../../debug-modal/debug-event-button";
export default function EmbeddedReaction({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const pointer = nip25.getReactedEventPointer(event);
@ -24,6 +25,7 @@ export default function EmbeddedReaction({ event, ...props }: Omit<CardProps, "c
{pointer && <NoteLink noteId={pointer.id} />}
<Spacer />
<Timestamp timestamp={event.created_at} />
<DebugEventButton event={event} variant="ghost" size="xs" />
</Flex>
</Card>
</TrustProvider>

View File

@ -10,6 +10,7 @@ export default function ReactionGroupButton({
if (count <= 1) {
return <IconButton icon={<ReactionIcon emoji={emoji} url={url} />} aria-label="Reaction" {...props} />;
}
return (
<Button leftIcon={<ReactionIcon emoji={emoji} url={url} />} title={emoji} {...props}>
{count > 1 && count}

View File

@ -0,0 +1,62 @@
import { Box, Button, ButtonGroup, Card, CardBody, CardFooter, CardHeader } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import UserAvatarLink from "../user/user-avatar-link";
import UserLink from "../user/user-link";
import UserDnsIdentity from "../user/user-dns-identity";
import DebugEventButton from "../debug-modal/debug-event-button";
import { TrustProvider } from "../../providers/local/trust-provider";
import EventReactionButtons from "../event-reactions/event-reactions";
import AddReactionButton from "../note/timeline-note/components/add-reaction-button";
import RepostButton from "../note/timeline-note/components/repost-button";
import QuoteEventButton from "../note/quote-event-button";
import MediaPostSlides from "./media-slides";
import MediaPostContents from "./media-post-content";
import { getSharableEventAddress } from "../../services/event-relay-hint";
import { ThreadIcon } from "../icons";
import EventZapIconButton from "../zap/event-zap-icon-button";
import Timestamp from "../timestamp";
export default function MediaPost({ post }: { post: NostrEvent }) {
const nevent = getSharableEventAddress(post);
return (
<TrustProvider event={post}>
<Card maxW="2xl" mx="auto">
<CardHeader display="flex" alignItems="center" gap="2" p="2">
<UserAvatarLink pubkey={post.pubkey} />
<Box>
<UserLink pubkey={post.pubkey} fontWeight="bold" /> <Timestamp timestamp={post.created_at} />
<br />
<UserDnsIdentity pubkey={post.pubkey} />
</Box>
<Button as={RouterLink} to={`/media/${nevent}`} leftIcon={<ThreadIcon boxSize={5} />} ml="auto">
Comments
</Button>
</CardHeader>
<CardBody p="0" position="relative" display="flex" flexDirection="column" gap="2" minH="md">
<MediaPostSlides post={post} />
{post.content.length > 0 && <MediaPostContents post={post} px="2" />}
</CardBody>
<CardFooter p="2" display="flex" gap="2">
<ButtonGroup size="sm" variant="ghost">
<EventZapIconButton event={post} aria-label="Zap post" />
<AddReactionButton event={post} />
<EventReactionButtons event={post} max={4} />
</ButtonGroup>
<ButtonGroup size="sm" variant="ghost" ml="auto">
<RepostButton event={post} />
<QuoteEventButton event={post} />
<DebugEventButton event={post} variant="ghost" ml="auto" size="sm" alignSelf="flex-start" />
</ButtonGroup>
</CardFooter>
</Card>
</TrustProvider>
);
}

View File

@ -0,0 +1,28 @@
import { Box, BoxProps } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { useRenderedContent } from "applesauce-react/hooks";
import { emojis, nostrMentions, links, hashtags } from "applesauce-content/text";
import { components } from "../content";
import { renderGenericUrl } from "../content/links";
import { nipDefinitions } from "../content/transform/nip-notation";
const transformers = [links, nostrMentions, emojis, hashtags, nipDefinitions];
const linkRenderers = [renderGenericUrl];
const MediaPostContentSymbol = Symbol.for("media-post-content");
export default function MediaPostContents({ post, ...props }: { post: NostrEvent } & Omit<BoxProps, "children">) {
const content = useRenderedContent(post, components, {
linkRenderers,
transformers,
cacheKey: MediaPostContentSymbol,
});
return (
<Box whiteSpace="pre-wrap" dir="auto" {...props}>
{content}
</Box>
);
}

View File

@ -0,0 +1,141 @@
import { Box, Flex, FlexProps, IconButton, Spacer } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { getMediaAttachments, MediaAttachment } from "applesauce-core/helpers/media-attachment";
import { Carousel, useCarousel } from "nuka-carousel";
import styled from "@emotion/styled";
import { TrustImage, TrustVideo } from "../content/links";
import { isImageURL, isVideoURL } from "applesauce-core/helpers";
import { ChevronLeftIcon, ChevronRightIcon } from "../icons";
import ZapBubbles from "../note/timeline-note/components/zap-bubbles";
function CustomArrows() {
const { currentPage, totalPages, wrapMode, goBack, goForward } = useCarousel();
const allowWrap = wrapMode === "wrap";
const enablePrevNavButton = allowWrap || currentPage > 0;
const enableNextNavButton = allowWrap || currentPage < totalPages - 1;
return (
<Flex justifyContent="space-between" position="absolute" top="50%" right="0" left="0">
<IconButton
icon={<ChevronLeftIcon boxSize={8} />}
onClick={goBack}
aria-label="previous image"
variant="ghost"
h="24"
w="12"
isDisabled={!enablePrevNavButton}
>
PREV
</IconButton>
<IconButton
icon={<ChevronRightIcon boxSize={8} />}
onClick={goForward}
aria-label="next image"
variant="ghost"
h="24"
w="12"
isDisabled={!enableNextNavButton}
>
NEXT
</IconButton>
</Flex>
);
}
function cls(...classes: string[]) {
return classes.filter(Boolean).join(" ");
}
function PageIndicators() {
const { totalPages, currentPage, goToPage } = useCarousel();
const className = (index: number) =>
cls("nuka-page-indicator", currentPage === index ? "nuka-page-indicator-active" : "");
return (
<div className="nuka-page-container" data-testid="pageIndicatorContainer">
{[...Array(totalPages)].map((_, index) => (
<button key={index} onClick={() => goToPage(index)} className={className(index)}>
<span className="nuka-hidden">{index + 1}</span>
</button>
))}
</div>
);
}
function MediaAttachmentSlide({ media }: { media: MediaAttachment }) {
if (media.type?.startsWith("video/") || isVideoURL(media.url)) {
return <TrustVideo src={media.url} poster={media.image} aria-description={media.alt} />;
} else if (media.type?.startsWith("image/") || isImageURL(media.url)) {
return <TrustImage src={media.url} alt={media.alt} maxH="full" />;
}
return (
<Box aspectRatio={1} minW="lg">
Unknown media type {media.type ?? "Unknown"}
</Box>
);
}
const CustomCarousel = styled(Carousel)`
& {
height: 100%;
overflow: hidden;
}
.nuka-slide-container {
height: 100%;
overflow: hidden;
}
.nuka-overflow {
overflow-x: scroll;
overflow-y: hidden;
height: 100%;
}
.nuka-wrapper {
height: 100%;
}
`;
export default function MediaPostSlides({
post,
showZaps = true,
...props
}: { post: NostrEvent; showZaps?: boolean } & Omit<FlexProps, "children">) {
const attachments = getMediaAttachments(post);
if (attachments.length === 1)
return (
<Flex gap="2" direction="column" {...props}>
<Flex justifyContent="center" overflow="hidden" flexGrow={1} alignItems="flex-start">
<MediaAttachmentSlide media={attachments[0]} />
</Flex>
{showZaps && <ZapBubbles event={post} px="2" />}
</Flex>
);
return (
<Flex gap="2" direction="column" {...props}>
<CustomCarousel
scrollDistance="screen"
showDots
arrows={<CustomArrows />}
showArrows
dots={
<Flex gap="2" justifyContent="space-between" alignItems="center" px="2">
{showZaps && <ZapBubbles event={post} />}
<Spacer />
<PageIndicators />
</Flex>
}
>
{attachments.map((media) => (
<MediaAttachmentSlide key={media.sha256 || media.url} media={media} />
))}
</CustomCarousel>
</Flex>
);
}

View File

@ -55,6 +55,7 @@ export default function NoteZapButton({ event, allowComment, showEventPreview, .
icon={<LightningIcon verticalAlign="sub" />}
aria-label="Zap Note"
title="Zap Note"
colorScheme={hasZapped ? "primary" : undefined}
{...props}
onClick={onOpen}
isDisabled={!canZap}

View File

@ -1,6 +1,6 @@
import { ReactNode, memo } from "react";
import { kinds } from "nostr-tools";
import { Box, Text } from "@chakra-ui/react";
import { Box } from "@chakra-ui/react";
import { ErrorBoundary } from "../../error-boundary";
import ReplyNote from "./reply-note";
@ -16,6 +16,8 @@ import { TimelineNote } from "../../note/timeline-note";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import ArticleCard from "../../../views/articles/components/article-card";
import EmbeddedUnknown from "../../embed-event/event-types/embedded-unknown";
import { MEDIA_POST_KIND } from "../../../helpers/nostr/media";
import MediaPost from "../../media-post/media-post-card";
function TimelineItem({ event, visible, minHeight }: { event: NostrEvent; visible: boolean; minHeight?: number }) {
const ref = useEventIntersectionRef(event);
@ -44,6 +46,9 @@ function TimelineItem({ event, visible, minHeight }: { event: NostrEvent; visibl
case kinds.LongFormArticle:
content = <ArticleCard article={event} />;
break;
case MEDIA_POST_KIND:
content = <MediaPost post={event} />;
break;
default:
content = <EmbeddedUnknown event={event} />;
break;

View File

@ -0,0 +1,43 @@
import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
import { getZapSender } from "applesauce-core/helpers";
import useCurrentAccount from "../../hooks/use-current-account";
import useEventZaps from "../../hooks/use-event-zaps";
import eventZapsService from "../../services/event-zaps";
import { NostrEvent } from "../../types/nostr-event";
import { LightningIcon } from "../icons";
import ZapModal from "../event-zap-modal";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
import { getEventUID } from "../../helpers/nostr/event";
import { useReadRelays } from "../../hooks/use-client-relays";
export default function EventZapIconButton({
event,
...props
}: { event: NostrEvent } & Omit<IconButtonProps, "icon" | "onClick">) {
const account = useCurrentAccount();
const { metadata } = useUserLNURLMetadata(event.pubkey);
const zaps = useEventZaps(getEventUID(event)) ?? [];
const { isOpen, onOpen, onClose } = useDisclosure();
const readRelays = useReadRelays();
const onZapped = () => {
onClose();
eventZapsService.requestZaps(getEventUID(event), readRelays, true);
};
const canZap = !!metadata?.allowsNostr || event.tags.some((t) => t[0] === "zap");
return (
<>
<IconButton
icon={<LightningIcon verticalAlign="sub" color="yellow.400" />}
{...props}
onClick={onOpen}
isDisabled={!canZap}
/>
{isOpen && <ZapModal isOpen={isOpen} pubkey={event.pubkey} event={event} onClose={onClose} onZapped={onZapped} />}
</>
);
}

View File

@ -1,4 +1,5 @@
import { safeRelayUrls } from "applesauce-core/helpers";
import { getCoordinateFromAddressPointer, safeRelayUrls } from "applesauce-core/helpers";
import { EventFactoryClient } from "applesauce-factory";
import { kinds } from "nostr-tools";
export const DEFAULT_SEARCH_RELAYS = safeRelayUrls([
@ -47,10 +48,14 @@ export const NOSTR_CONNECT_PERMISSIONS = [
];
export const NEVER_ATTACH_CLIENT_TAG = [kinds.EncryptedDirectMessage];
export const NIP_89_CLIENT_TAG = [
"client",
"noStrudel",
"31990:266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5:1686066542546",
];
export const NIP_89_CLIENT_APP: EventFactoryClient = {
name: "noStrudel",
address: {
kind: kinds.Handlerinformation,
pubkey: "266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5",
identifier: "1686066542546",
},
};
export const SUPPORT_PUBKEY = "713978c3094081b34fcf2f5491733b0c22728cd3b7a6946519d40f5f08598af8";

View File

@ -0,0 +1 @@
export const MEDIA_POST_KIND = 20;

View File

@ -155,6 +155,7 @@ export function ensureTagContentMentions(draft: EventTemplate) {
return updated;
}
/** @deprecated use includeContentHashtags from applesauce-factory instead */
export function createHashtagTags(draft: EventTemplate) {
const updatedDraft: EventTemplate = { ...draft, tags: Array.from(draft.tags) };
@ -214,6 +215,7 @@ export function addPubkeyRelayHints(draft: EventTemplate) {
};
}
/** @deprecated use event factory instead */
export function finalizeNote(draft: EventTemplate) {
let updated: EventTemplate = { ...draft, tags: Array.from(draft.tags) };
updated.content = correctContentMentions(updated.content);

View File

@ -0,0 +1,11 @@
import { useObservable } from "applesauce-react/hooks";
import { FactoryProvider } from "applesauce-react/providers";
import eventFactoryService from "../../services/event-factory";
import { PropsWithChildren } from "react";
export default function EventFactoryProvider({ children }: PropsWithChildren) {
const factory = useObservable(eventFactoryService.subject);
return <FactoryProvider factory={factory ?? undefined}>{children}</FactoryProvider>;
}

View File

@ -1,6 +1,6 @@
import React, { useMemo } from "react";
import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
import { QueryStoreProvider } from "applesauce-react";
import { QueryStoreProvider } from "applesauce-react/providers";
import { SigningProvider } from "./signing-provider";
import buildTheme from "../../theme";
@ -12,6 +12,7 @@ import DMTimelineProvider from "./dms-provider";
import PublishProvider from "./publish-provider";
import WebOfTrustProvider from "./web-of-trust-provider";
import { queryStore } from "../../services/event-store";
import EventFactoryProvider from "./event-factory-provider";
function ThemeProviders({ children }: { children: React.ReactNode }) {
const { theme: themeName, primaryColor, maxPageWidth } = useAppSettings();
@ -33,17 +34,19 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
<QueryStoreProvider store={queryStore}>
<ThemeProviders>
<SigningProvider>
<PublishProvider>
<NotificationsProvider>
<DMTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<WebOfTrustProvider>{children}</WebOfTrustProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</DMTimelineProvider>
</NotificationsProvider>
</PublishProvider>
<EventFactoryProvider>
<PublishProvider>
<NotificationsProvider>
<DMTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<WebOfTrustProvider>{children}</WebOfTrustProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</DMTimelineProvider>
</NotificationsProvider>
</PublishProvider>
</EventFactoryProvider>
</SigningProvider>
</ThemeProviders>
</QueryStoreProvider>

View File

@ -1,6 +1,7 @@
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
import { useToast } from "@chakra-ui/react";
import { EventTemplate, NostrEvent, UnsignedEvent, getEventHash, kinds } from "nostr-tools";
import { EventTemplate, NostrEvent, UnsignedEvent, kinds } from "nostr-tools";
import { includeClientTag } from "applesauce-factory/operations";
import { useSigningContext } from "./signing-provider";
import { DraftNostrEvent } from "../../types/nostr-event";
@ -9,11 +10,10 @@ import clientRelaysService from "../../services/client-relays";
import RelaySet from "../../classes/relay-set";
import { cloneEvent, getAllRelayHints, isReplaceable } from "../../helpers/nostr/event";
import replaceableEventsService from "../../services/replaceable-events";
import eventReactionsService from "../../services/event-reactions";
import { localRelay } from "../../services/local-relay";
import deleteEventService from "../../services/delete-events";
import localSettings from "../../services/local-settings";
import { NEVER_ATTACH_CLIENT_TAG, NIP_89_CLIENT_TAG } from "../../const";
import { NEVER_ATTACH_CLIENT_TAG, NIP_89_CLIENT_APP } from "../../const";
import { eventStore } from "../../services/event-store";
import { addPubkeyRelayHints } from "../../helpers/nostr/post";
import useCurrentAccount from "../../hooks/use-current-account";
@ -70,19 +70,24 @@ export default function PublishProvider({ children }: PropsWithChildren) {
const outBoxes = useUserOutbox(account?.pubkey);
const finalizeDraft = useCallback<PublishContextType["finalizeDraft"]>(
(event: EventTemplate | NostrEvent) => {
async (event: EventTemplate | NostrEvent) => {
let draft = cloneEvent(event.kind, event);
// add pubkey relay hints
draft = addPubkeyRelayHints(draft);
// add client tag
if (localSettings.addClientTag.value && !NEVER_ATTACH_CLIENT_TAG.includes(draft.kind)) {
draft.tags = [...draft.tags.filter((t) => t[0] !== "client"), NIP_89_CLIENT_TAG];
if (
localSettings.addClientTag.value &&
!NEVER_ATTACH_CLIENT_TAG.includes(draft.kind) &&
!draft.tags.some((t) => t[0] === "client")
) {
// TODO: this should be removed when all events are created using the event factory
draft = await includeClientTag(NIP_89_CLIENT_APP.name, NIP_89_CLIENT_APP.address)(draft, {});
}
// request signature
return signerFinalize(draft);
return await signerFinalize(draft);
},
[signerFinalize],
);

View File

@ -0,0 +1,32 @@
import { BehaviorSubject, Observable } from "rxjs";
import { EventFactory } from "applesauce-factory";
import { Account } from "../classes/accounts/account";
import { getEventRelayHints } from "./event-relay-hint";
import { NIP_89_CLIENT_APP } from "../const";
import accountService from "./account";
class EventFactoryService {
subject = new BehaviorSubject<EventFactory | null>(null);
get factory() {
return this.subject.value;
}
constructor(account: Observable<Account | null>) {
account.subscribe((current) => {
if (!current) this.subject.next(null);
else
this.subject.next(
new EventFactory({
signer: current.signer,
getRelayHint: (event) => getEventRelayHints(event, 1)[0],
client: NIP_89_CLIENT_APP,
}),
);
});
}
}
const eventFactoryService = new EventFactoryService(accountService.current);
export default eventFactoryService;

50
src/views/media/index.tsx Normal file
View File

@ -0,0 +1,50 @@
import { useCallback } from "react";
import { Flex } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import { useReadRelays } from "../../hooks/use-client-relays";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { MEDIA_POST_KIND } from "../../helpers/nostr/media";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import TimelinePage from "../../components/timeline-page";
function MediaFeedPage() {
const muteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
(event: NostrEvent) => {
if (muteFilter(event)) return false;
return true;
},
[muteFilter],
);
const relays = useReadRelays();
const { listId, filter } = usePeopleListContext();
const { loader, timeline } = useTimelineLoader(
`${listId}-media-feed`,
relays,
filter ? { ...filter, kinds: [MEDIA_POST_KIND] } : undefined,
{
eventFilter,
},
);
const header = (
<Flex gap="2" wrap="wrap" alignItems="center">
<PeopleListSelection />
</Flex>
);
return <TimelinePage loader={loader} timeline={timeline} header={header} pt="2" pb="12" px="2" />;
}
export default function MediaFeedView() {
return (
<PeopleListProvider>
<MediaFeedPage />
</PeopleListProvider>
);
}

View File

@ -0,0 +1,44 @@
import { Box, ButtonGroup } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { COMMENT_KIND } from "applesauce-core/helpers";
import { useStoreQuery } from "applesauce-react/hooks";
import { CommentsQuery } from "applesauce-core/queries";
import { useReadRelays } from "../../hooks/use-client-relays";
import UserLink from "../../components/user/user-link";
import DebugEventButton from "../../components/debug-modal/debug-event-button";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TextNoteContents from "../../components/note/timeline-note/text-note-contents";
import Timestamp from "../../components/timestamp";
function Comment({ comment }: { comment: NostrEvent }) {
return (
<Box>
<ButtonGroup float="right" variant="ghost" size="sm" alignItems="center">
<Timestamp timestamp={comment.created_at} />
<DebugEventButton event={comment} />
</ButtonGroup>
<Box float="left" mr="2">
<UserLink pubkey={comment.pubkey} fontWeight="bold" />
</Box>
<TextNoteContents event={comment} />
</Box>
);
}
export function MediaPostComments({ post }: { post: NostrEvent }) {
const readRelays = useReadRelays();
const { loader } = useTimelineLoader(`${post.id}-comments`, readRelays, { kinds: [COMMENT_KIND], "#E": [post.id] });
const comments = useStoreQuery(CommentsQuery, [post]);
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<IntersectionObserverProvider callback={callback}>
{comments?.map((comment) => <Comment key={comment.id} comment={comment} />)}
</IntersectionObserverProvider>
);
}

View File

@ -0,0 +1,67 @@
import { useRef } from "react";
import { Box, ComponentWithAs, Flex, FlexProps, IconButton, useToast } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { NostrEvent } from "nostr-tools";
import { useEventFactory } from "applesauce-react/hooks";
import { usePublishEvent } from "../../providers/global/publish-provider";
import { useContextEmojis } from "../../providers/global/emoji-provider";
import { MagicInput, RefType } from "../../components/magic-textarea";
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../hooks/use-textarea-upload-file";
import { useWriteRelays } from "../../hooks/use-client-relays";
import MessageSquare01 from "../../components/icons/message-square-01";
export default function MediaPostCommentForm({
post,
...props
}: { post: NostrEvent } & Omit<FlexProps, "children" | "as" | "onSubmit">) {
const toast = useToast();
const publish = usePublishEvent();
const emojis = useContextEmojis();
const factory = useEventFactory();
const relays = useWriteRelays();
const { setValue, handleSubmit, formState, reset, getValues, watch } = useForm({
defaultValues: { content: "" },
});
const sendMessage = handleSubmit(async (values) => {
try {
if (!factory) throw new Error("Missing factory");
let draft = await factory.comment(post, values.content);
const pub = await publish("Comment", draft, relays);
if (pub) reset();
} catch (error) {
if (error instanceof Error) toast({ description: error.message, status: "error" });
}
});
const textAreaRef = useRef<RefType | null>(null);
const insertText = useTextAreaInsertTextWithForm(textAreaRef, getValues, setValue);
const { onPaste } = useTextAreaUploadFile(insertText);
watch("content");
return (
<>
<Flex as="form" onSubmit={sendMessage} gap="2" {...props}>
<MagicInput
instanceRef={(inst) => (textAreaRef.current = inst)}
placeholder="Comment"
autoComplete="off"
isRequired
value={getValues().content}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
// @ts-expect-error
onPaste={onPaste}
/>
<IconButton
colorScheme="primary"
type="submit"
isLoading={formState.isSubmitting}
icon={<MessageSquare01 />}
aria-label="Comment"
/>
</Flex>
</>
);
}

View File

@ -0,0 +1,120 @@
import { Box, ButtonGroup, Flex, Heading, Spinner } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { COMMENT_KIND } from "applesauce-core/helpers";
import { useStoreQuery } from "applesauce-react/hooks";
import { CommentsQuery } from "applesauce-core/queries";
import { useReadRelays } from "../../hooks/use-client-relays";
import useParamsEventPointer from "../../hooks/use-params-event-pointer";
import useSingleEvent from "../../hooks/use-single-event";
import UserAvatarLink from "../../components/user/user-avatar-link";
import UserLink from "../../components/user/user-link";
import UserDnsIdentity from "../../components/user/user-dns-identity";
import MediaPostSlides from "../../components/media-post/media-slides";
import MediaPostContents from "../../components/media-post/media-post-content";
import { TrustProvider } from "../../providers/local/trust-provider";
import DebugEventButton from "../../components/debug-modal/debug-event-button";
import RepostButton from "../../components/note/timeline-note/components/repost-button";
import QuoteEventButton from "../../components/note/quote-event-button";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import EventZapIconButton from "../../components/zap/event-zap-icon-button";
import AddReactionButton from "../../components/note/timeline-note/components/add-reaction-button";
import EventReactionButtons from "../../components/event-reactions/event-reactions";
import { MediaPostComments } from "./media-comments";
import MediaPostCommentForm from "./media-post-comment-form";
function Header({ post }: { post: NostrEvent }) {
return (
<Flex gap="2">
<UserAvatarLink pubkey={post.pubkey} />
<Flex direction="column">
<UserLink pubkey={post.pubkey} fontWeight="bold" />
<UserDnsIdentity pubkey={post.pubkey} />
</Flex>
<ButtonGroup ml="auto">
<RepostButton event={post} />
<QuoteEventButton event={post} />
<DebugEventButton event={post} />
</ButtonGroup>
</Flex>
);
}
function Actions({ post }: { post: NostrEvent }) {
return (
<ButtonGroup size="md" variant="ghost">
<EventZapIconButton event={post} aria-label="Zap post" />
<AddReactionButton event={post} />
<EventReactionButtons event={post} />
</ButtonGroup>
);
}
function HorizontalLayout({ post }: { post: NostrEvent }) {
return (
<Flex direction="column" pt="2" pb="4" gap="2" px="2" w="auto" h="100vh" overflow="hidden">
<Header post={post} />
<Flex direction="row" gap="2" overflow="hidden" h="full">
<Flex overflow="hidden" w="full" h="full" direction="column" gap="2">
<MediaPostSlides post={post} maxH="full" overflow="hidden" />
<Actions post={post} />
</Flex>
<Flex direction="column" w="md" overflowY="auto" flexShrink={0}>
<MediaPostContents post={post} />
<Heading size="sm" mt="2">
Comments:
</Heading>
<MediaPostCommentForm post={post} mb="2" />
<MediaPostComments post={post} />
</Flex>
</Flex>
</Flex>
);
}
function VerticalLayout({ post }: { post: NostrEvent }) {
return (
<Flex direction="column" pt="2" pb="12" gap="2" px="2" w="full">
<Header post={post} />
<MediaPostSlides post={post} h="full" overflow="hidden" />
<MediaPostContents post={post} />
<Actions post={post} />
<Heading size="sm" my="2">
Comments:
</Heading>
<MediaPostCommentForm post={post} mb="2" />
<MediaPostComments post={post} />
</Flex>
);
}
function MediaPostPage({ post }: { post: NostrEvent }) {
const Layout = useBreakpointValue({ base: VerticalLayout, lg: HorizontalLayout }) || VerticalLayout;
return (
<TrustProvider trust>
<Layout post={post} />
</TrustProvider>
);
}
export default function MediaPostView() {
const pointer = useParamsEventPointer("pointer");
const readRelays = useReadRelays(pointer.relays);
const post = useSingleEvent(pointer.id, readRelays);
if (post) return <MediaPostPage post={post} />;
else return <Spinner />;
}

View File

@ -23,6 +23,7 @@ import MessageQuestionSquare from "../../components/icons/message-question-squar
import UploadCloud01 from "../../components/icons/upload-cloud-01";
import Edit04 from "../../components/icons/edit-04";
import Users03 from "../../components/icons/users-03";
import Camera01 from "../../components/icons/camera-01";
export const internalApps: App[] = [
{
@ -32,6 +33,13 @@ export const internalApps: App[] = [
id: "streams",
to: "/streams",
},
{
title: "Media",
description: "Browser media posts",
icon: Camera01,
id: "media",
to: "/media",
},
{
title: "Communities",
description: "Create and manage communities",

View File

@ -22,6 +22,8 @@ import { useUserOutbox } from "../../../hooks/use-user-mailboxes";
import { useReadRelays } from "../../../hooks/use-client-relays";
import AlertTriangle from "../../../components/icons/alert-triangle";
import MessageSquare02 from "../../../components/icons/message-square-02";
import Camera01 from "../../../components/icons/camera-01";
import { MEDIA_POST_KIND } from "../../../helpers/nostr/media";
type KnownKind = {
kind: number;
@ -75,6 +77,13 @@ const KnownKinds: KnownKind[] = [
link: (_, p) => `/u/${npubEncode(p)}/articles`,
},
{
kind: MEDIA_POST_KIND,
name: "Media",
icon: Camera01,
link: (_, p) => `/u/${npubEncode(p)}/media`,
},
{
kind: kinds.EncryptedDirectMessage,
name: "Legacy DMs",

View File

@ -45,6 +45,7 @@ const tabs = [
{ label: "Notes", path: "notes" },
{ label: "Articles", path: "articles" },
{ label: "Streams", path: "streams" },
{ label: "Media", path: "media" },
{ label: "Zaps", path: "zaps" },
{ label: "Lists", path: "lists" },
{ label: "Following", path: "following" },

View File

@ -0,0 +1,18 @@
import { useOutletContext } from "react-router-dom";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import TimelinePage from "../../components/timeline-page";
import { MEDIA_POST_KIND } from "../../helpers/nostr/media";
export default function UserMediaPostsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const { loader, timeline } = useTimelineLoader(pubkey + "-media-posts", readRelays, {
authors: [pubkey],
kinds: [MEDIA_POST_KIND],
});
return <TimelinePage loader={loader} timeline={timeline} pt="2" pb="12" px="2" />;
}