feat: shorts

This commit is contained in:
kieran 2024-05-27 14:08:38 +01:00
parent 4ed2242655
commit 39f6df907d
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
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 VIDEO_KIND = 34_235 as EventKind;
export const SHORTS_KIND = 34_236 as EventKind;
export const MINUTE = 60;
export const HOUR = 60 * MINUTE;

View File

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

View File

@ -6,8 +6,19 @@ import { useLogin } from "@/hooks/login";
import AsyncButton from "./async-button";
import { useContext } from "react";
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 login = useLogin();
const system = useContext(SnortContext);
@ -36,49 +47,61 @@ export default function EventReactions({ ev, replyKind }: { ev: TaggedNostrEvent
const pub = login?.publisher();
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 (
<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 && (
<AsyncButton
className={iconClass}
onClick={async () => {
if (pub) {
const evReact = await pub.react(ev);
await system.BroadcastEvent(evReact);
}
}}>
<Icon name="message-circle" />
{grouped.replies.length > 0 ? grouped.replies.length : undefined}
<div className={iconClass}>
<Icon name="message-circle" />
{grouped.replies.length > 0 ? grouped.replies.length : <>&nbsp;</>}
</div>
</AsyncButton>
)}
<ZapEvent ev={ev}>
<div className={iconClass}>
{reactedIcon("zap", "zap-filled", "text-zap", grouped.zaps, EventKind.ZapReceipt)}
{totalZaps > 0 ? formatSats(totalZaps) : undefined}
{totalZaps > 0 ? formatSats(totalZaps) : <>&nbsp;</>}
</div>
</ZapEvent>
<AsyncButton
className={iconClass}
onClick={async () => {
if (pub) {
const evReact = await pub.react(ev);
await system.BroadcastEvent(evReact);
}
}}>
{reactedIcon("heart", "heart-solid", "text-red-500", grouped.reactions.positive, EventKind.Reaction)}
{grouped.reactions.positive.length > 0 ? grouped.reactions.positive.length : undefined}
<div className={iconClass}>
{reactedIcon("heart", "heart-solid", "text-red-500", grouped.reactions.positive, EventKind.Reaction)}
{grouped.reactions.positive.length > 0 ? grouped.reactions.positive.length : <>&nbsp;</>}
</div>
</AsyncButton>
<AsyncButton
className={iconClass}
onClick={async () => {
if (pub) {
const evReact = await pub.repost(ev);
await system.BroadcastEvent(evReact);
}
}}>
<Icon name="repost" />
{grouped.reposts.length > 0 ? grouped.reposts.length : undefined}
<div className={iconClass}>
<Icon name="repost" />
{grouped.reposts.length > 0 ? grouped.reposts.length : <>&nbsp;</>}
</div>
</AsyncButton>
</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 { TaggedNostrEvent } from "@snort/system";
import { ReactNode, createContext, useContext, useEffect, useState } from "react";
interface VideoPlayerContext {
video?: VideoInfo;
video: VideoInfo;
event: TaggedNostrEvent;
widePlayer: boolean;
update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => void;
}
@ -10,15 +12,17 @@ interface VideoPlayerContext {
const VPContext = createContext<VideoPlayerContext>({
widePlayer: false,
update: () => {},
});
} as unknown as VideoPlayerContext);
export function useVideoPlayerContext() {
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>({
video: info,
event,
widePlayer: localStorage.getItem("wide-player") === "true",
update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => {
setState(fn);

View File

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

View File

@ -736,6 +736,9 @@
"kAEQyV": {
"defaultMessage": "OK"
},
"kGjqZ4": {
"defaultMessage": "Latest Shorts"
},
"kc5EOy": {
"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 { getEventFromLocationState } from "@/utils";
import { NostrPrefix, EventKind } from "@snort/system";
@ -9,6 +9,7 @@ import { EventEmbed as NostrEventElement } from "@/element/event-embed";
import { FormattedMessage } from "react-intl";
import { useLayout } from "./layout/context";
import classNames from "classnames";
import { ShortPage } from "./short";
export function LinkHandler() {
const location = useLocation();
@ -32,6 +33,8 @@ export function LinkHandler() {
);
} else if (link.kind === VIDEO_KIND) {
return <VideoPage link={link} evPreload={evPreload} />;
} else if (link.kind === SHORTS_KIND) {
return <ShortPage link={link} evPreload={evPreload} />;
} else {
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() {
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 { getHost, findTag } from "@/utils";
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 { useMemo } from "react";
import classNames from "classnames";
import { StreamTile } from "@/element/stream/stream-tile";
import { VIDEO_KIND } from "@/const";
import { VideoInfo } from "@/service/video/info";
import { VideoPlayerContextProvider, useVideoPlayerContext } from "@/element/video/context";
import VideoPlayer from "@/element/video/player";
import { VideoInfo } from "@/element/video-info";
export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
const ev = useCurrentStreamFeed(link, true, evPreload);
if (!ev) return;
const video = VideoInfo.parse(ev);
return (
<VideoPlayerContextProvider info={video}>
<VideoPlayerContextProvider event={ev}>
<VideoPageInner ev={ev} />
</VideoPlayerContextProvider>
);
@ -37,9 +28,6 @@ function VideoPageInner({ ev }: { ev: TaggedNostrEvent }) {
const ctx = useVideoPlayerContext();
const link = NostrLink.fromEvent(ev);
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
return (
<div
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 />
</div>
{/* VIDEO INFO & COMMENTS */}
<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 && (
<>
<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>
<VideoInfo showComments={true} />
<div
className={classNames("p-2 col-start-2", {
"row-start-1 row-span-3": !ctx.widePlayer,

View File

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