mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-25 11:13:30 +02:00
Add basic support for flare video kind
This commit is contained in:
5
.changeset/empty-dots-sip.md
Normal file
5
.changeset/empty-dots-sip.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add basic support for flare video kind
|
17
src/app.tsx
17
src/app.tsx
@@ -81,7 +81,10 @@ import OtherStuffView from "./views/other-stuff";
|
||||
import { RouteProviders } from "./providers/route";
|
||||
import LaunchpadView from "./views/launchpad";
|
||||
import TracksView from "./views/tracks";
|
||||
import VideosView from "./views/videos";
|
||||
import VideoDetailsView from "./views/videos/video";
|
||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||
const UserVideosTab = lazy(() => import("./views/user/videos"));
|
||||
|
||||
const ToolsHomeView = lazy(() => import("./views/tools"));
|
||||
const WotTestView = lazy(() => import("./views/tools/wot-test"));
|
||||
@@ -224,6 +227,7 @@ const router = createHashRouter([
|
||||
{ path: "articles", element: <UserArticlesTab /> },
|
||||
{ path: "streams", element: <UserStreamsTab /> },
|
||||
{ path: "tracks", element: <UserTracksTab /> },
|
||||
{ path: "videos", element: <UserVideosTab /> },
|
||||
{ path: "zaps", element: <UserZapsTab /> },
|
||||
{ path: "likes", element: <UserReactionsTab /> },
|
||||
{ path: "lists", element: <UserListsTab /> },
|
||||
@@ -260,6 +264,19 @@ const router = createHashRouter([
|
||||
{ path: "", element: <NotificationsView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "videos",
|
||||
children: [
|
||||
{
|
||||
path: ":naddr",
|
||||
element: <VideoDetailsView />,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: <VideosView />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "dvm",
|
||||
children: [
|
||||
|
28
src/components/event-reactions/simple-dislike-button.tsx
Normal file
28
src/components/event-reactions/simple-dislike-button.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useEventReactions from "../../hooks/use-event-reactions";
|
||||
import { groupReactions } from "../../helpers/nostr/reactions";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import ReactionGroupButton from "./reaction-group-button";
|
||||
import { useAddReaction } from "./common-hooks";
|
||||
import { ButtonProps } from "@chakra-ui/react";
|
||||
|
||||
export default function SimpleDislikeButton({ event, ...props }: Omit<ButtonProps, "children"> & { event: NostrEvent }) {
|
||||
const account = useCurrentAccount();
|
||||
const reactions = useEventReactions(event.id) ?? [];
|
||||
const grouped = useMemo(() => groupReactions(reactions), [reactions]);
|
||||
|
||||
const addReaction = useAddReaction(event, grouped);
|
||||
const group = grouped.find((g) => g.emoji === "-");
|
||||
|
||||
return (
|
||||
<ReactionGroupButton
|
||||
emoji="-"
|
||||
count={group?.pubkeys.length ?? 0}
|
||||
onClick={() => addReaction("-")}
|
||||
colorScheme={account && group?.pubkeys.includes(account?.pubkey) ? "primary" : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
34
src/helpers/nostr/flare.ts
Normal file
34
src/helpers/nostr/flare.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
|
||||
export const FLARE_VIDEO_KIND = 34235;
|
||||
|
||||
export function getVideoTitle(video: NostrEvent) {
|
||||
const title = video.tags.find((t) => t[0] === "title")?.[1];
|
||||
if (!title) throw new Error("missing title");
|
||||
return title;
|
||||
}
|
||||
export function getVideoUrl(video: NostrEvent) {
|
||||
const url = video.tags.find((t) => t[0] === "url")?.[1];
|
||||
if (!url) throw new Error("missing url");
|
||||
return url;
|
||||
}
|
||||
export function getVideoSummary(video: NostrEvent) {
|
||||
return video.tags.find((t) => t[0] === "summary")?.[1];
|
||||
}
|
||||
export function getVideoSize(video: NostrEvent) {
|
||||
const str = video.tags.find((t) => t[0] === "size")?.[1];
|
||||
return str ? parseInt(str) || undefined : undefined;
|
||||
}
|
||||
export function getVideoDuration(video: NostrEvent) {
|
||||
const str = video.tags.find((t) => t[0] === "duration")?.[1];
|
||||
return str ? parseInt(str) || undefined : undefined;
|
||||
}
|
||||
export function getVideoPublishDate(video: NostrEvent) {
|
||||
const str = video.tags.find((t) => t[0] === "published_at")?.[1];
|
||||
return str ? parseInt(str) || undefined : undefined;
|
||||
}
|
||||
export function getVideoImages(video: NostrEvent) {
|
||||
const thumb = video.tags.find((t) => t[0] === "thumb")?.[1];
|
||||
const image = video.tags.find((t) => t[0] === "image")?.[1];
|
||||
return { thumb, image };
|
||||
}
|
@@ -7,6 +7,7 @@ import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../helpers/nostr/lists";
|
||||
import { ErrorBoundary } from "../../components/error-boundary";
|
||||
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
|
||||
import { TORRENT_KIND } from "../../helpers/nostr/torrents";
|
||||
import { FLARE_VIDEO_KIND } from "../../helpers/nostr/flare";
|
||||
|
||||
function NostrLinkPage() {
|
||||
const { link } = useParams() as { link?: string };
|
||||
@@ -37,6 +38,7 @@ function NostrLinkPage() {
|
||||
if (decoded.data.kind === PEOPLE_LIST_KIND) return <Navigate to={`/lists/${cleanLink}`} replace />;
|
||||
if (decoded.data.kind === Kind.BadgeDefinition) return <Navigate to={`/badges/${cleanLink}`} replace />;
|
||||
if (decoded.data.kind === COMMUNITY_DEFINITION_KIND) return <Navigate to={`/c/${cleanLink}`} replace />;
|
||||
if (decoded.data.kind === FLARE_VIDEO_KIND) return <Navigate to={`/videos/${cleanLink}`} replace />;
|
||||
if (decoded.data.kind === Kind.ChannelCreation) return <Navigate to={`/channels/${cleanLink}`} replace />;
|
||||
if (decoded.data.kind === Kind.Text) return <Navigate to={`/n/${cleanLink}`} replace />;
|
||||
// if there is no kind redirect to the thread view
|
||||
|
@@ -15,6 +15,7 @@ import {
|
||||
import { App } from "./component/app-card";
|
||||
import ShieldOff from "../../components/icons/shield-off";
|
||||
import Users01 from "../../components/icons/users-01";
|
||||
import Film02 from "../../components/icons/film-02";
|
||||
|
||||
export const internalApps: App[] = [
|
||||
{
|
||||
@@ -37,6 +38,7 @@ export const internalApps: App[] = [
|
||||
{ title: "Emojis", description: "Create custom emoji packs", icon: EmojiPacksIcon, id: "emojis", to: "/emojis" },
|
||||
{ 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: "Things", icon: ThingsIcon, id: "things", to: "/things" },
|
||||
];
|
||||
|
||||
|
@@ -60,6 +60,7 @@ const tabs = [
|
||||
{ label: "Relays", path: "relays" },
|
||||
{ label: "Goals", path: "goals" },
|
||||
{ label: "Tracks", path: "tracks" },
|
||||
{ label: "Videos", path: "videos" },
|
||||
{ label: "Emojis", path: "emojis" },
|
||||
{ label: "Torrents", path: "torrents" },
|
||||
{ label: "Reports", path: "reports" },
|
||||
|
37
src/views/user/videos.tsx
Normal file
37
src/views/user/videos.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { SimpleGrid } from "@chakra-ui/react";
|
||||
|
||||
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { getEventUID } from "../../helpers/nostr/events";
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import { FLARE_VIDEO_KIND } from "../../helpers/nostr/flare";
|
||||
import VideoCard from "../videos/components/video-card";
|
||||
|
||||
export default function UserVideosTab() {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const readRelays = useAdditionalRelayContext();
|
||||
|
||||
const timeline = useTimelineLoader(pubkey + "-videos", readRelays, {
|
||||
authors: [pubkey],
|
||||
kinds: [FLARE_VIDEO_KIND],
|
||||
});
|
||||
const videos = useSubject(timeline.timeline);
|
||||
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<VerticalPageLayout>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="4">
|
||||
{videos.map((video) => (
|
||||
<VideoCard key={getEventUID(video)} video={video} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VerticalPageLayout>
|
||||
</IntersectionObserverProvider>
|
||||
);
|
||||
}
|
44
src/views/videos/components/video-card.tsx
Normal file
44
src/views/videos/components/video-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useRef } from "react";
|
||||
import { Box, Card, CardBody, CardHeader, CardProps, Heading, Image, LinkBox, Text } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer";
|
||||
import { getVideoDuration, getVideoImages, getVideoSummary, getVideoTitle } from "../../../helpers/nostr/flare";
|
||||
import { getEventUID } from "../../../helpers/nostr/events";
|
||||
import HoverLinkOverlay from "../../../components/hover-link-overlay";
|
||||
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||
|
||||
export default function VideoCard({ video, ...props }: Omit<CardProps, "children"> & { video: NostrEvent }) {
|
||||
const title = getVideoTitle(video);
|
||||
const { thumb } = getVideoImages(video);
|
||||
const duration = getVideoDuration(video);
|
||||
const summary = getVideoSummary(video);
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(video));
|
||||
|
||||
return (
|
||||
<Card as={LinkBox} {...props}>
|
||||
<Box
|
||||
backgroundImage={thumb}
|
||||
aspectRatio={16 / 9}
|
||||
backgroundPosition="center"
|
||||
backgroundRepeat="no-repeat"
|
||||
backgroundSize="cover"
|
||||
/>
|
||||
<CardHeader p="2">
|
||||
<HoverLinkOverlay as={RouterLink} to={`/videos/${getSharableEventAddress(video)}`}>
|
||||
<Heading size="sm" isTruncated>
|
||||
{title}
|
||||
</Heading>
|
||||
</HoverLinkOverlay>
|
||||
</CardHeader>
|
||||
<CardBody px="2" pb="2" pt="0">
|
||||
<Text noOfLines={2} fontSize="sm">
|
||||
{summary}
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
33
src/views/videos/components/video-menu.tsx
Normal file
33
src/views/videos/components/video-menu.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { MenuItem, useDisclosure } from "@chakra-ui/react";
|
||||
|
||||
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app";
|
||||
import CopyShareLinkMenuItem from "../../../components/common-menu-items/copy-share-link";
|
||||
import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code";
|
||||
import MuteUserMenuItem from "../../../components/common-menu-items/mute-user";
|
||||
import { CodeIcon } from "../../../components/icons";
|
||||
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
|
||||
export default function VideoMenu({ video, ...props }: { video: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
|
||||
const debugModal = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomMenuIconButton {...props}>
|
||||
<OpenInAppMenuItem event={video} />
|
||||
<CopyShareLinkMenuItem event={video} />
|
||||
<CopyEmbedCodeMenuItem event={video} />
|
||||
<MuteUserMenuItem event={video} />
|
||||
|
||||
<MenuItem onClick={debugModal.onOpen} icon={<CodeIcon />}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
</CustomMenuIconButton>
|
||||
|
||||
{debugModal.isOpen && (
|
||||
<NoteDebugModal event={video} isOpen={debugModal.isOpen} onClose={debugModal.onClose} size="6xl" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
61
src/views/videos/index.tsx
Normal file
61
src/views/videos/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Button, Flex, SimpleGrid } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
|
||||
import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/local/relay-selection-provider";
|
||||
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
|
||||
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import Upload01 from "../../components/icons/upload-01";
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
import { FLARE_VIDEO_KIND } from "../../helpers/nostr/flare";
|
||||
import VideoCard from "./components/video-card";
|
||||
import { getEventUID } from "../../helpers/nostr/events";
|
||||
import { ErrorBoundary } from "../../components/error-boundary";
|
||||
|
||||
function VideosPage() {
|
||||
const { listId, filter } = usePeopleListContext();
|
||||
const { relays } = useRelaySelectionContext();
|
||||
|
||||
const timeline = useTimelineLoader(`${listId}-videos`, relays, filter && { kinds: [FLARE_VIDEO_KIND], ...filter });
|
||||
|
||||
const videos = useSubject(timeline.timeline);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2">
|
||||
<PeopleListSelection />
|
||||
<RelaySelectionButton />
|
||||
<Button as={RouterLink} colorScheme="primary" ml="auto" leftIcon={<Upload01 />} to="/things/upload">
|
||||
New Thing
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="2">
|
||||
{videos.map((video) => (
|
||||
<ErrorBoundary>
|
||||
<VideoCard key={getEventUID(video)} video={video} />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</IntersectionObserverProvider>
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideosView() {
|
||||
return (
|
||||
<PeopleListProvider initList="global">
|
||||
<RelaySelectionProvider>
|
||||
<VideosPage />
|
||||
</RelaySelectionProvider>
|
||||
</PeopleListProvider>
|
||||
);
|
||||
}
|
79
src/views/videos/video.tsx
Normal file
79
src/views/videos/video.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Box, ButtonGroup, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react";
|
||||
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import {
|
||||
getVideoDuration,
|
||||
getVideoImages,
|
||||
getVideoSummary,
|
||||
getVideoTitle,
|
||||
getVideoUrl,
|
||||
} from "../../helpers/nostr/flare";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useParamsAddressPointer from "../../hooks/use-params-address-pointer";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
import UserAvatarLink from "../../components/user-avatar-link";
|
||||
import UserLink from "../../components/user-link";
|
||||
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
||||
import { UserFollowButton } from "../../components/user-follow-button";
|
||||
import VideoMenu from "./components/video-menu";
|
||||
import NoteZapButton from "../../components/note/note-zap-button";
|
||||
import SimpleLikeButton from "../../components/event-reactions/simple-like-button";
|
||||
import SimpleDislikeButton from "../../components/event-reactions/simple-dislike-button";
|
||||
import { ErrorBoundary } from "../../components/error-boundary";
|
||||
import QuoteRepostButton from "../../components/note/components/quote-repost-button";
|
||||
import RepostButton from "../../components/note/components/repost-button";
|
||||
|
||||
function VideoDetailsPage({ video }: { video: NostrEvent }) {
|
||||
const title = getVideoTitle(video);
|
||||
const { thumb, image } = getVideoImages(video);
|
||||
const duration = getVideoDuration(video);
|
||||
const summary = getVideoSummary(video);
|
||||
const url = getVideoUrl(video);
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="4">
|
||||
<Flex direction="column" gap="2" flexGrow={1}>
|
||||
<Box as="video" src={url} w="full" controls poster={image || thumb} />
|
||||
<Flex gap="2" overflow="hidden">
|
||||
<Heading size="md" my="2" isTruncated>
|
||||
{title}
|
||||
</Heading>
|
||||
<ButtonGroup ml="auto" size="sm" variant="ghost">
|
||||
<NoteZapButton event={video} />
|
||||
<SimpleLikeButton event={video} />
|
||||
<SimpleDislikeButton event={video} />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={video.pubkey} size="sm" />
|
||||
<UserLink pubkey={video.pubkey} fontSize="lg" tab="videos" />
|
||||
<UserDnsIdentityIcon pubkey={video.pubkey} onlyIcon />
|
||||
<UserFollowButton pubkey={video.pubkey} size="sm" />
|
||||
<ButtonGroup ml="auto" size="sm" variant="ghost">
|
||||
<QuoteRepostButton event={video} />
|
||||
</ButtonGroup>
|
||||
<VideoMenu video={video} aria-label="More options" size="sm" />
|
||||
</Flex>
|
||||
<Text mt="2" whiteSpace="pre-line">
|
||||
{summary}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex gap="2" direction="column" w="sm" flexShrink={0}></Flex>
|
||||
</Flex>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoDetailsView() {
|
||||
const pointer = useParamsAddressPointer("naddr");
|
||||
const video = useReplaceableEvent(pointer);
|
||||
|
||||
if (!video) return <Spinner />;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<VideoDetailsPage video={video} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user