feat: shorts

This commit is contained in:
2024-05-27 14:08:38 +01:00
parent 4ed2242655
commit 39f6df907d
12 changed files with 220 additions and 90 deletions

View File

@ -12,6 +12,7 @@ export const CARD = 37_777 as EventKind;
export const MUTED = 10_000 as EventKind; export const MUTED = 10_000 as EventKind;
export const VIDEO_KIND = 34_235 as EventKind; export const VIDEO_KIND = 34_235 as EventKind;
export const SHORTS_KIND = 34_236 as EventKind;
export const MINUTE = 60; export const MINUTE = 60;
export const HOUR = 60 * MINUTE; export const HOUR = 60 * MINUTE;

View File

@ -11,7 +11,7 @@ import { Emoji as EmojiComponent } from "../emoji";
import { Profile } from "../profile"; import { Profile } from "../profile";
import { Text } from "../text"; import { Text } from "../text";
import { useMute } from "../mute-button"; import { useMute } from "../mute-button";
import { SendZaps, SendZapsDialog } from "../send-zap"; import { SendZaps } from "../send-zap";
import { CollapsibleEvent } from "../collapsible"; import { CollapsibleEvent } from "../collapsible";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
@ -174,12 +174,14 @@ export function ChatMessage({
opacity: showZapDialog || isHovering ? 1 : 0, opacity: showZapDialog || isHovering ? 1 : 0,
pointerEvents: showZapDialog || isHovering ? "auto" : "none", pointerEvents: showZapDialog || isHovering ? "auto" : "none",
}}> }}>
{zapTarget && <IconButton {zapTarget && (
<IconButton
iconName="zap" iconName="zap"
iconSize={14} iconSize={14}
className="p-2 rounded-full bg-layer-2 aspect-square" className="p-2 rounded-full bg-layer-2 aspect-square"
onClick={() => setZapping(true)} onClick={() => setZapping(true)}
/>} />
)}
<IconButton <IconButton
onClick={pickEmoji} onClick={pickEmoji}
iconName="face" iconName="face"
@ -196,7 +198,8 @@ export function ChatMessage({
)} )}
</div> </div>
)} )}
{zapping && zapTarget && <Modal id="send-zaps" onClose={() => setZapping(false)}> {zapping && zapTarget && (
<Modal id="send-zaps" onClose={() => setZapping(false)}>
<SendZaps <SendZaps
lnurl={zapTarget} lnurl={zapTarget}
eTag={ev.id} eTag={ev.id}
@ -204,7 +207,8 @@ export function ChatMessage({
targetName={profile?.name || ev.pubkey} targetName={profile?.name || ev.pubkey}
onFinish={() => setZapping(false)} onFinish={() => setZapping(false)}
/> />
</Modal>} </Modal>
)}
</div> </div>
{showEmojiPicker && ( {showEmojiPicker && (
<Suspense> <Suspense>

View File

@ -6,8 +6,19 @@ import { useLogin } from "@/hooks/login";
import AsyncButton from "./async-button"; import AsyncButton from "./async-button";
import { useContext } from "react"; import { useContext } from "react";
import { ZapEvent } from "./send-zap"; import { ZapEvent } from "./send-zap";
import classNames from "classnames";
export default function EventReactions({ ev, replyKind }: { ev: TaggedNostrEvent; replyKind?: EventKind }) { export default function EventReactions({
ev,
replyKind,
vertical,
className,
}: {
ev: TaggedNostrEvent;
replyKind?: EventKind;
vertical?: boolean;
className?: string;
}) {
const link = NostrLink.fromEvent(ev)!; const link = NostrLink.fromEvent(ev)!;
const login = useLogin(); const login = useLogin();
const system = useContext(SnortContext); const system = useContext(SnortContext);
@ -36,49 +47,61 @@ export default function EventReactions({ ev, replyKind }: { ev: TaggedNostrEvent
const pub = login?.publisher(); const pub = login?.publisher();
const totalZaps = grouped.zaps.reduce((acc, v) => acc + v.amount, 0); const totalZaps = grouped.zaps.reduce((acc, v) => acc + v.amount, 0);
const iconClass = "flex gap-2 items-center tabular-nums cursor-pointer select-none hover:text-primary transition"; const iconClass = classNames(
"flex gap-1 items-center tabular-nums cursor-pointer select-none hover:text-primary transition",
{
"flex-col": vertical,
},
);
return ( return (
<div className="flex flex-wrap gap-4 mt-2 items-center text-neutral-500"> <div
className={classNames(className, "grid items-center text-neutral-500", {
"flex-col gap-8": vertical,
"gap-4": !vertical,
})}>
{replyKind && ( {replyKind && (
<AsyncButton <AsyncButton
className={iconClass}
onClick={async () => { onClick={async () => {
if (pub) { if (pub) {
const evReact = await pub.react(ev); const evReact = await pub.react(ev);
await system.BroadcastEvent(evReact); await system.BroadcastEvent(evReact);
} }
}}> }}>
<div className={iconClass}>
<Icon name="message-circle" /> <Icon name="message-circle" />
{grouped.replies.length > 0 ? grouped.replies.length : undefined} {grouped.replies.length > 0 ? grouped.replies.length : <>&nbsp;</>}
</div>
</AsyncButton> </AsyncButton>
)} )}
<ZapEvent ev={ev}> <ZapEvent ev={ev}>
<div className={iconClass}> <div className={iconClass}>
{reactedIcon("zap", "zap-filled", "text-zap", grouped.zaps, EventKind.ZapReceipt)} {reactedIcon("zap", "zap-filled", "text-zap", grouped.zaps, EventKind.ZapReceipt)}
{totalZaps > 0 ? formatSats(totalZaps) : undefined} {totalZaps > 0 ? formatSats(totalZaps) : <>&nbsp;</>}
</div> </div>
</ZapEvent> </ZapEvent>
<AsyncButton <AsyncButton
className={iconClass}
onClick={async () => { onClick={async () => {
if (pub) { if (pub) {
const evReact = await pub.react(ev); const evReact = await pub.react(ev);
await system.BroadcastEvent(evReact); await system.BroadcastEvent(evReact);
} }
}}> }}>
<div className={iconClass}>
{reactedIcon("heart", "heart-solid", "text-red-500", grouped.reactions.positive, EventKind.Reaction)} {reactedIcon("heart", "heart-solid", "text-red-500", grouped.reactions.positive, EventKind.Reaction)}
{grouped.reactions.positive.length > 0 ? grouped.reactions.positive.length : undefined} {grouped.reactions.positive.length > 0 ? grouped.reactions.positive.length : <>&nbsp;</>}
</div>
</AsyncButton> </AsyncButton>
<AsyncButton <AsyncButton
className={iconClass}
onClick={async () => { onClick={async () => {
if (pub) { if (pub) {
const evReact = await pub.repost(ev); const evReact = await pub.repost(ev);
await system.BroadcastEvent(evReact); await system.BroadcastEvent(evReact);
} }
}}> }}>
<div className={iconClass}>
<Icon name="repost" /> <Icon name="repost" />
{grouped.reposts.length > 0 ? grouped.reposts.length : undefined} {grouped.reposts.length > 0 ? grouped.reposts.length : <>&nbsp;</>}
</div>
</AsyncButton> </AsyncButton>
</div> </div>
); );

View File

@ -0,0 +1,74 @@
import { FollowButton } from "./follow-button";
import { Profile, getName } from "./profile";
import { SendZapsDialog } from "./send-zap";
import { ShareMenu } from "./share-menu";
import { useVideoPlayerContext } from "./video/context";
import { getHost } from "@/utils";
import { useUserProfile } from "@snort/system-react";
import { NostrLink } from "@snort/system";
import { StreamSummary } from "./stream/summary";
import classNames from "classnames";
import { FormattedMessage } from "react-intl";
import { WriteMessage } from "./chat/write-message";
import VideoComments from "./video/comments";
export function VideoInfo({
showComments,
showShare,
showZap,
}: {
showComments?: boolean;
showShare?: boolean;
showZap?: boolean;
}) {
const ctx = useVideoPlayerContext();
const ev = ctx.event;
const link = NostrLink.fromEvent(ev);
const host = getHost(ctx.event);
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
return (
<div
className={classNames("row-start-2 col-start-1 max-xl:px-4 flex flex-col gap-4", {
"mx-auto w-[40dvw]": ctx.widePlayer,
})}>
<div className="font-medium text-xl">{ctx.video?.title}</div>
<div className="flex justify-between">
{/* PROFILE SECTION */}
<div className="flex gap-2 items-center">
<Profile pubkey={host} />
<FollowButton pubkey={host} />
</div>
{/* ACTIONS */}
<div className="flex gap-2">
{ev && (
<>
{(showShare ?? true) && <ShareMenu ev={ev} />}
{(showZap ?? true) && zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={link.tagKey}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
</div>
{ctx.video?.summary && <StreamSummary text={ctx.video.summary} />}
{(showComments ?? true) && (
<>
<h3>
<FormattedMessage defaultMessage="Comments" />
</h3>
<div>
<WriteMessage link={link} emojiPacks={[]} kind={1} />
</div>
<VideoComments link={link} />
</>
)}
</div>
);
}

View File

@ -1,8 +1,10 @@
import { VideoInfo } from "@/service/video/info"; import { VideoInfo } from "@/service/video/info";
import { TaggedNostrEvent } from "@snort/system";
import { ReactNode, createContext, useContext, useEffect, useState } from "react"; import { ReactNode, createContext, useContext, useEffect, useState } from "react";
interface VideoPlayerContext { interface VideoPlayerContext {
video?: VideoInfo; video: VideoInfo;
event: TaggedNostrEvent;
widePlayer: boolean; widePlayer: boolean;
update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => void; update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => void;
} }
@ -10,15 +12,17 @@ interface VideoPlayerContext {
const VPContext = createContext<VideoPlayerContext>({ const VPContext = createContext<VideoPlayerContext>({
widePlayer: false, widePlayer: false,
update: () => {}, update: () => {},
}); } as unknown as VideoPlayerContext);
export function useVideoPlayerContext() { export function useVideoPlayerContext() {
return useContext(VPContext); return useContext(VPContext);
} }
export function VideoPlayerContextProvider({ info, children }: { info: VideoInfo; children?: ReactNode }) { export function VideoPlayerContextProvider({ event, children }: { event: TaggedNostrEvent; children?: ReactNode }) {
const info = VideoInfo.parse(event);
const [state, setState] = useState<VideoPlayerContext>({ const [state, setState] = useState<VideoPlayerContext>({
video: info, video: info,
event,
widePlayer: localStorage.getItem("wide-player") === "true", widePlayer: localStorage.getItem("wide-player") === "true",
update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => { update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => {
setState(fn); setState(fn);

View File

@ -15,7 +15,15 @@ import useImgProxy from "@/hooks/img-proxy";
import { useMediaQuery } from "usehooks-ts"; import { useMediaQuery } from "usehooks-ts";
import { useVideoPlayerContext } from "./context"; import { useVideoPlayerContext } from "./context";
export default function VideoPlayer() { export default function VideoPlayer({
showPip,
showWideMode,
loop,
}: {
showPip?: boolean;
showWideMode?: boolean;
loop?: boolean;
}) {
const isDesktop = useMediaQuery("(min-width: 1280px)"); const isDesktop = useMediaQuery("(min-width: 1280px)");
const ctx = useVideoPlayerContext(); const ctx = useVideoPlayerContext();
@ -23,10 +31,11 @@ export default function VideoPlayer() {
return ( return (
<MediaController className="min-w-0 w-full" mediaStreamType="on-demand"> <MediaController className="min-w-0 w-full" mediaStreamType="on-demand">
<video <video
className="max-h-[80dvh] aspect-video" className="max-h-[80dvh]"
slot="media" slot="media"
autoPlay={true} autoPlay={true}
controls={false} controls={false}
loop={loop}
poster={proxy(ctx.video?.bestPoster()?.url ?? "")}> poster={proxy(ctx.video?.bestPoster()?.url ?? "")}>
{ctx.video?.sources().map(a => <source src={a.url} type={a.mimeType} />)} {ctx.video?.sources().map(a => <source src={a.url} type={a.mimeType} />)}
</video> </video>
@ -37,9 +46,9 @@ export default function VideoPlayer() {
<MediaTimeDisplay showDuration></MediaTimeDisplay> <MediaTimeDisplay showDuration></MediaTimeDisplay>
<MediaMuteButton /> <MediaMuteButton />
<MediaVolumeRange /> <MediaVolumeRange />
<MediaPipButton /> {(showPip ?? true) && <MediaPipButton />}
<MediaFullscreenButton /> <MediaFullscreenButton />
{isDesktop && ( {isDesktop && (showWideMode ?? true) && (
<MediaPlayerSizeButtonReact <MediaPlayerSizeButtonReact
onClick={() => onClick={() =>
ctx.update(c => ({ ctx.update(c => ({

View File

@ -736,6 +736,9 @@
"kAEQyV": { "kAEQyV": {
"defaultMessage": "OK" "defaultMessage": "OK"
}, },
"kGjqZ4": {
"defaultMessage": "Latest Shorts"
},
"kc5EOy": { "kc5EOy": {
"defaultMessage": "Username is too long" "defaultMessage": "Username is too long"
}, },

View File

@ -1,4 +1,4 @@
import { VIDEO_KIND } from "@/const"; import { SHORTS_KIND, VIDEO_KIND } from "@/const";
import { useStreamLink } from "@/hooks/stream-link"; import { useStreamLink } from "@/hooks/stream-link";
import { getEventFromLocationState } from "@/utils"; import { getEventFromLocationState } from "@/utils";
import { NostrPrefix, EventKind } from "@snort/system"; import { NostrPrefix, EventKind } from "@snort/system";
@ -9,6 +9,7 @@ import { EventEmbed as NostrEventElement } from "@/element/event-embed";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useLayout } from "./layout/context"; import { useLayout } from "./layout/context";
import classNames from "classnames"; import classNames from "classnames";
import { ShortPage } from "./short";
export function LinkHandler() { export function LinkHandler() {
const location = useLocation(); const location = useLocation();
@ -32,6 +33,8 @@ export function LinkHandler() {
); );
} else if (link.kind === VIDEO_KIND) { } else if (link.kind === VIDEO_KIND) {
return <VideoPage link={link} evPreload={evPreload} />; return <VideoPage link={link} evPreload={evPreload} />;
} else if (link.kind === SHORTS_KIND) {
return <ShortPage link={link} evPreload={evPreload} />;
} else { } else {
return ( return (
<> <>

25
src/pages/short.tsx Normal file
View File

@ -0,0 +1,25 @@
import EventReactions from "@/element/event-reactions";
import { VideoInfo } from "@/element/video-info";
import { VideoPlayerContextProvider } from "@/element/video/context";
import VideoPlayer from "@/element/video/player";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
export function ShortPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
const ev = useCurrentStreamFeed(link, true, evPreload);
if (!ev) return;
return (
<VideoPlayerContextProvider event={ev}>
<div className="max-xl:py-2 max-xl:w-full xl:w-[550px] mx-auto">
<div className="relative">
<VideoPlayer showPip={false} showWideMode={false} loop={true} />
<div className="absolute bottom-0 -right-14">
<EventReactions ev={ev} vertical={true} replyKind={1} className="text-white" />
</div>
</div>
<VideoInfo showComments={false} showZap={false} />
</div>
</VideoPlayerContextProvider>
);
}

View File

@ -1,3 +1,34 @@
import { SHORTS_KIND } from "@/const";
import VideoGrid from "@/element/video-grid";
import { VideoTile } from "@/element/video/video-tile";
import { findTag } from "@/utils";
import { RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
export function ShortsPage() { export function ShortsPage() {
return <>Coming soon...</>; const rb = new RequestBuilder("shorts");
rb.withFilter().kinds([SHORTS_KIND]);
const videos = useRequestBuilder(rb);
const sorted = videos.sort((a, b) => {
const pubA = findTag(a, "published_at");
const pubB = findTag(b, "published_at");
return Number(pubA) > Number(pubB) ? -1 : 1;
});
return (
<div className="p-4">
<h2>
<FormattedMessage defaultMessage="Latest Shorts" />
</h2>
<br />
<VideoGrid>
{sorted.map(a => (
<VideoTile ev={a} key={a.id} style="grid" />
))}
</VideoGrid>
</div>
);
} }

View File

@ -1,32 +1,23 @@
import { WriteMessage } from "@/element/chat/write-message";
import { FollowButton } from "@/element/follow-button";
import { Profile, getName } from "@/element/profile";
import { SendZapsDialog } from "@/element/send-zap";
import { ShareMenu } from "@/element/share-menu";
import { StreamSummary } from "@/element/stream/summary";
import VideoComments from "@/element/video/comments";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed"; import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { getHost, findTag } from "@/utils"; import { getHost, findTag } from "@/utils";
import { NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system"; import { NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder, useUserProfile } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useMemo } from "react"; import { useMemo } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { StreamTile } from "@/element/stream/stream-tile"; import { StreamTile } from "@/element/stream/stream-tile";
import { VIDEO_KIND } from "@/const"; import { VIDEO_KIND } from "@/const";
import { VideoInfo } from "@/service/video/info";
import { VideoPlayerContextProvider, useVideoPlayerContext } from "@/element/video/context"; import { VideoPlayerContextProvider, useVideoPlayerContext } from "@/element/video/context";
import VideoPlayer from "@/element/video/player"; import VideoPlayer from "@/element/video/player";
import { VideoInfo } from "@/element/video-info";
export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) { export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
const ev = useCurrentStreamFeed(link, true, evPreload); const ev = useCurrentStreamFeed(link, true, evPreload);
if (!ev) return; if (!ev) return;
const video = VideoInfo.parse(ev);
return ( return (
<VideoPlayerContextProvider info={video}> <VideoPlayerContextProvider event={ev}>
<VideoPageInner ev={ev} /> <VideoPageInner ev={ev} />
</VideoPlayerContextProvider> </VideoPlayerContextProvider>
); );
@ -37,9 +28,6 @@ function VideoPageInner({ ev }: { ev: TaggedNostrEvent }) {
const ctx = useVideoPlayerContext(); const ctx = useVideoPlayerContext();
const link = NostrLink.fromEvent(ev); const link = NostrLink.fromEvent(ev);
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
return ( return (
<div <div
className={classNames("xl:p-4 grow xl:grid xl:gap-2 xl:grid-cols-[auto_450px]", { className={classNames("xl:p-4 grow xl:grid xl:gap-2 xl:grid-cols-[auto_450px]", {
@ -52,43 +40,7 @@ function VideoPageInner({ ev }: { ev: TaggedNostrEvent }) {
<VideoPlayer /> <VideoPlayer />
</div> </div>
{/* VIDEO INFO & COMMENTS */} {/* VIDEO INFO & COMMENTS */}
<div <VideoInfo showComments={true} />
className={classNames("row-start-2 col-start-1 max-xl:px-4 flex flex-col gap-4", {
"mx-auto w-[40dvw]": ctx.widePlayer,
})}>
<div className="font-medium text-xl">{ctx.video?.title}</div>
<div className="flex justify-between">
{/* PROFILE SECTION */}
<div className="flex gap-2 items-center">
<Profile pubkey={host} />
<FollowButton pubkey={host} />
</div>
{/* ACTIONS */}
<div className="flex gap-2">
{ev && (
<>
<ShareMenu ev={ev} />
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={link.tagKey}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
</div>
{ctx.video?.summary && <StreamSummary text={ctx.video.summary} />}
<h3>
<FormattedMessage defaultMessage="Comments" />
</h3>
<div>
<WriteMessage link={link} emojiPacks={[]} kind={1} />
</div>
<VideoComments link={link} />
</div>
<div <div
className={classNames("p-2 col-start-2", { className={classNames("p-2 col-start-2", {
"row-start-1 row-span-3": !ctx.widePlayer, "row-start-1 row-span-3": !ctx.widePlayer,

View File

@ -242,6 +242,7 @@
"jvo0vs": "Save", "jvo0vs": "Save",
"k21gTS": "e.g. about me", "k21gTS": "e.g. about me",
"kAEQyV": "OK", "kAEQyV": "OK",
"kGjqZ4": "Latest Shorts",
"kc5EOy": "Username is too long", "kc5EOy": "Username is too long",
"khJ51Q": "Stream Earnings", "khJ51Q": "Stream Earnings",
"kp0NPF": "Planned", "kp0NPF": "Planned",