Add basic support for flare video kind

This commit is contained in:
hzrd149
2024-01-06 23:52:46 +00:00
parent 065a90f774
commit 1888caa691
12 changed files with 343 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add basic support for flare video kind

View File

@@ -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: [

View 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}
/>
);
}

View 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 };
}

View File

@@ -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

View File

@@ -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" },
];

View File

@@ -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
View 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>
);
}

View 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>
);
}

View 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" />
)}
</>
);
}

View 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>
);
}

View 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>
);
}