watch: added simple comments section
continuous-integration/drone/push Build is passing Details

nextui
Guus van Meerveld 1 month ago
parent 927fca314e
commit 6b5a7d1727

@ -1,40 +0,0 @@
"use client";
import NextLink from "next/link";
import { Avatar } from "@nextui-org/avatar";
import { Link } from "@nextui-org/link";
import { Author } from "@/client/typings/author";
import formatBigNumber from "@/utils/formatBigNumber";
import { channelUrl } from "@/utils/urls";
import { Component } from "@/typings/component";
export const Channel: Component<{
data: Author;
}> = ({ data }) => {
const url = data?.id ? channelUrl(data.id) : undefined;
return (
<Link as={NextLink} href={url}>
<div className="flex flex-row gap-4 items-center">
<Avatar
isBordered
size="lg"
src={data.avatar}
showFallback
name={data.name}
/>
<div className="flex flex-col">
<h1 className="text-lg text-default-foreground">{data.name}</h1>
{data.subscribers && (
<h2 className="text-md tracking-tight text-default-500">
{formatBigNumber(data.subscribers)} subscribers
</h2>
)}
</div>
</div>
</Link>
);
};

@ -0,0 +1,227 @@
import { useQuery } from "@tanstack/react-query";
import NextLink from "next/link";
import { FC, useMemo, useState } from "react";
import {
FiHeart as HeartIcon,
FiThumbsUp as LikeIcon,
FiLock as PinnedIcon,
FiCornerDownRight as ShowRepliesIcon,
FiSlash as SlashIcon,
FiCheck as UploaderIcon
} from "react-icons/fi";
import { Avatar } from "@nextui-org/avatar";
import { Button } from "@nextui-org/button";
import { Chip } from "@nextui-org/chip";
import { Divider } from "@nextui-org/divider";
import { Link } from "@nextui-org/link";
import { CircularProgress } from "@nextui-org/progress";
import { Tooltip } from "@nextui-org/tooltip";
import { useClient } from "@/hooks/useClient";
import { Author } from "@/client/typings/author";
import {
Comment as CommentProps,
Comments as CommentsProps
} from "@/client/typings/comment";
import formatBigNumber from "@/utils/formatBigNumber";
import formatUploadedTime from "@/utils/formatUploadedTime";
import { highlight } from "@/utils/highlight";
import { channelUrl } from "@/utils/urls";
import { HighlightRenderer } from "./HighlightRenderer";
const Comment: FC<{
data: CommentProps;
videoUploader: Author;
videoId: string;
}> = ({ data, videoUploader, videoId }) => {
const message = useMemo(() => highlight(data.message), [data.message]);
const client = useClient();
const [showReplies, setShowReplies] = useState(false);
const {
data: replies,
error: repliesError,
refetch: refetchReplies,
isLoading: isLoadingReplies
} = useQuery({
queryKey: ["replies", videoId, data.repliesToken],
queryFn: () => {
return client.getComments(videoId, data.repliesToken);
},
enabled: showReplies && !!data.repliesToken
});
const userUrl = data.author.id ? channelUrl(data.author.id) : "#";
return (
<div className="flex flex-row gap-4">
<div>
<Link as={NextLink} href={userUrl}>
<Avatar
isBordered
size="lg"
showFallback
src={data.author.avatar}
name={data.author.name}
/>
</Link>
</div>
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-row gap-2">
<Link as={NextLink} href={userUrl}>
<p className="font-semibold text-default-foreground">
{data.author.name}
</p>
</Link>
{data.author.id === videoUploader.id && (
<Chip
className="pl-2"
startContent={<UploaderIcon />}
color="primary"
>
Uploader
</Chip>
)}
{data.pinned && (
<Chip
className="pl-2"
startContent={<PinnedIcon />}
color="primary"
>
Pinned
</Chip>
)}
</div>
<p>
<HighlightRenderer highlighted={message} />
</p>
<div className="flex flex-row gap-4 items-center">
<div className="flex flex-row tracking-tight text-default-500 items-center gap-1">
<LikeIcon />
<p>{formatBigNumber(data.likes)} likes</p>
</div>
<div className="tracking-tight text-default-500">
<p>{formatUploadedTime(data.written)}</p>
</div>
{data.videoUploaderLiked && (
<div className="flex items-center">
<Tooltip content="Uploader liked" showArrow>
<p className="text-danger text-xl">
<HeartIcon />
</p>
</Tooltip>
</div>
)}
{data.videoUploaderReplied && (
<div>
<Avatar
size="sm"
src={videoUploader.avatar}
name={videoUploader.name}
showFallback
/>
</div>
)}
{data.edited && (
<p className="tracking-tight text-default-500">(edited)</p>
)}
{data.repliesToken && (
<Button
startContent={<ShowRepliesIcon />}
variant="light"
onClick={() => setShowReplies((state) => !state)}
>
{showReplies ? "Hide replies" : "Show replies"}
</Button>
)}
</div>
{showReplies && (
<div className="flex flex-col gap-4">
<Comments
data={replies}
isLoading={isLoadingReplies}
error={repliesError}
refetch={refetchReplies}
videoUploader={videoUploader}
videoId={videoId}
/>
</div>
)}
</div>
</div>
);
};
export const Comments: FC<{
data?: CommentsProps;
isLoading: boolean;
error: Error | null;
refetch: () => void;
videoUploader: Author;
videoId: string;
}> = ({ data, isLoading, error, refetch, videoUploader, videoId }) => {
return (
<>
{data && (
<>
<p className="text-xl">
{data.count && formatBigNumber(data.count)} Comments
</p>
<Divider orientation="horizontal" />
<div className="flex flex-col gap-4">
{data.enabled && (
<>
{data.data.map((comment) => (
<Comment
key={comment.id}
data={comment}
videoUploader={videoUploader}
videoId={videoId}
/>
))}
</>
)}
{!data.enabled && (
<div className="flex flex-row gap-2 items-center">
<SlashIcon />
<p>Comments on this video are disabled</p>
</div>
)}
</div>
</>
)}
{!data && isLoading && (
<div className="h-24 w-full justify-center items-center flex">
<CircularProgress aria-label="Loading comments..." />
</div>
)}
{error && (
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Failed to load comments:</p>
{error.toString()}
<div>
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
)}
</>
);
};

@ -1,16 +1,16 @@
import sanitizeHtml from "sanitize-html";
import { Fragment, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
FiChevronUp as CollapseIcon,
FiChevronDown as ExpandIcon
} from "react-icons/fi";
import { Button } from "@nextui-org/button";
import { Link } from "@nextui-org/link";
import formatDuration from "@/utils/formatDuration";
import { highlight, ItemType } from "@/utils/highlight";
import { highlight } from "@/utils/highlight";
import { HighlightRenderer } from "./HighlightRenderer";
import { Component } from "@/typings/component";
@ -46,29 +46,7 @@ export const Description: Component<{ data: string }> = ({ data }) => {
return (
<div>
<h2 className="text-ellipsis overflow-y-hidden">
{description.map((item) => {
switch (item.type) {
case ItemType.Tokens:
return <Fragment key={item.id}>{item.content}</Fragment>;
case ItemType.Link:
return (
<Link key={item.id} href={item.href}>
{item.text ?? item.href}
</Link>
);
case ItemType.Timestamp:
return (
<Link key={item.id} href="">
{formatDuration(item.duration * 1000)}
</Link>
);
case ItemType.Linebreak:
return <br key={item.id} />;
}
})}
<HighlightRenderer highlighted={description} />
</h2>
<Button
startContent={expandedDescription ? <CollapseIcon /> : <ExpandIcon />}

@ -0,0 +1,38 @@
import { FC, Fragment } from "react";
import { Link } from "@nextui-org/link";
import formatDuration from "@/utils/formatDuration";
import { Item, ItemType } from "@/utils/highlight";
export const HighlightRenderer: FC<{ highlighted: Item[] }> = ({
highlighted
}) => {
return (
<>
{highlighted.map((item) => {
switch (item.type) {
case ItemType.Tokens:
return <Fragment key={item.id}>{item.content}</Fragment>;
case ItemType.Link:
return (
<Link key={item.id} href={item.href}>
{item.text ?? item.href}
</Link>
);
case ItemType.Timestamp:
return (
<Link key={item.id} href="">
{formatDuration(item.duration * 1000)}
</Link>
);
case ItemType.Linebreak:
return <br key={item.id} />;
}
})}
</>
);
};

@ -10,10 +10,11 @@ import { useClient } from "@/hooks/useClient";
import formatBigNumber from "@/utils/formatBigNumber";
import { Author } from "@/components/Author";
import { Container } from "@/components/Container";
import { LoadingPage } from "@/components/LoadingPage";
import { Channel } from "./Channel";
import { Comments } from "./Comments";
import { Description } from "./Description";
import { Player } from "./Player";
import { Related } from "./Related";
@ -37,6 +38,19 @@ export const Watch: Component = () => {
enabled: !videoIdIsInvalid
});
const {
data: comments,
isLoading: isLoadingComments,
refetch: refetchComments,
error: commentsError
} = useQuery({
queryKey: ["comments", videoId],
queryFn: () => {
return client.getComments(videoId);
},
enabled: !videoIdIsInvalid
});
if (error) console.log(error);
return (
@ -46,7 +60,7 @@ export const Watch: Component = () => {
<div className="flex flex-col">
<Player />
<div className="flex flex-col xl:flex-row gap-4">
<div className="flex-1 flex flex-col gap-4">
<div className="xl:w-2/3 flex flex-col gap-4">
<div className="flex flex-col">
<h1 className="text-2xl">{data.video.title}</h1>
<div className="flex flex-row gap-4 text-lg tracking-tight text-default-500">
@ -57,7 +71,7 @@ export const Watch: Component = () => {
</div>
</div>
<Channel data={data.video.author} />
<Author data={data.video.author} />
<Description data={data.video.description ?? ""} />
@ -67,17 +81,28 @@ export const Watch: Component = () => {
</div>
{data.keywords.length !== 0 && (
<div className="flex flex-row gap-2 items-center flex-wrap">
<h1>Keywords:</h1>
{data.keywords.map((keyword) => (
<Chip key={keyword}>{keyword}</Chip>
))}
<div className="flex flex-row gap-2">
<p>Keywords:</p>
<div className="flex flex-row gap-2 whitespace-nowrap overflow-x-scroll">
{data.keywords.map((keyword) => (
<Chip key={keyword}>{keyword}</Chip>
))}
</div>
</div>
)}
<h1 className="text-xl">Comments</h1>
<Comments
data={comments}
error={commentsError}
refetch={refetchComments}
isLoading={isLoadingComments}
videoId={data.video.id}
videoUploader={data.video.author}
/>
</div>
<div className="xl:w-1/3 flex justify-center">
<Related data={data.related} />
</div>
<Related data={data.related} />
</div>
</div>
)}

@ -1,3 +1,4 @@
import { Comments } from "@/client/typings/comment";
import { SearchResults } from "@/client/typings/search";
import { SearchOptions } from "@/client/typings/search/options";
import { Suggestions } from "@/client/typings/search/suggestions";
@ -11,6 +12,8 @@ export interface ConnectedAdapter {
getSearch(query: string, options?: SearchOptions): Promise<SearchResults>;
getStream(videoId: string): Promise<Stream>;
getComments(videoId: string, repliesToken?: string): Promise<Comments>;
}
export default interface Adapter {

@ -5,6 +5,7 @@ import ky from "ky";
import Adapter, { ApiType } from "@/client/adapters";
import Transformer from "./transformer";
import Comments, { CommentsModel } from "./typings/comments";
import Search, { SearchModel } from "./typings/search";
import Suggestions, { SuggestionsModel } from "./typings/search/suggestions";
import Stream, { StreamModel } from "./typings/stream";
@ -95,6 +96,30 @@ const getVideo = async (baseUrl: string, videoId: string): Promise<Stream> => {
return data;
};
const getComments = async (
baseUrl: string,
videoId: string,
continuation?: string
): Promise<Comments> => {
const url = new URL(apiPath("comments", videoId), baseUrl);
const searchParams = new URLSearchParams();
searchParams.append("source", "youtube");
if (continuation) searchParams.append("continuation", continuation);
const response = await ky.get(url, {
searchParams
});
const json = await response.json();
const data = CommentsModel.parse(json);
return data;
};
const adapter: Adapter = {
apiType: ApiType.Invidious,
@ -120,6 +145,12 @@ const adapter: Adapter = {
async getStream(videoId) {
return getVideo(url, videoId).then(Transformer.stream);
},
async getComments(videoId, repliesToken) {
return getComments(url, videoId, repliesToken).then(
Transformer.comments
);
}
};
}

@ -1,3 +1,4 @@
import { Comments } from "@/client/typings/comment";
import {
ChannelItem,
Item,
@ -7,7 +8,9 @@ import {
import { Suggestions } from "@/client/typings/search/suggestions";
import { Stream } from "@/client/typings/stream";
import { Video } from "@/client/typings/video";
import { parseSubscriberCount } from "@/utils/parseSubscriberCount";
import InvidiousComments from "./typings/comments";
import InvidiousSearch from "./typings/search";
import InvidiousSuggestions from "./typings/search/suggestions";
import InvidiousStream, {
@ -156,7 +159,8 @@ export default class Transformer {
author: {
id: stream.authorId,
name: stream.author,
avatar: stream.authorThumbnails[0].url
avatar: stream.authorThumbnails[0].url,
subscribers: parseSubscriberCount(stream.subCountText)
},
description: stream.description,
duration: stream.lengthSeconds * 1000,
@ -169,4 +173,28 @@ export default class Transformer {
}
};
}
public static comments(comments: InvidiousComments): Comments {
return {
enabled: true,
count: comments.commentCount,
data: comments.comments.map((comment) => ({
id: comment.commentId,
message: comment.content,
likes: comment.likeCount,
edited: comment.isEdited,
written: new Date(comment.published * 1000),
author: {
name: comment.author,
id: comment.authorId,
handle: comment.authorUrl,
avatar: comment.authorThumbnails[0].url
},
videoUploaderLiked: !!comment.creatorHeart,
videoUploaderReplied: false,
pinned: comment.isPinned,
repliesToken: comment.replies?.continuation
}))
};
}
}

@ -0,0 +1,44 @@
import z from "zod";
import { AuthorThumbnailModel } from "./thumbnail";
export const CommentModel = z.object({
author: z.string(),
authorThumbnails: AuthorThumbnailModel.array(),
authorId: z.string(),
authorUrl: z.string(),
isEdited: z.boolean(),
isPinned: z.boolean(),
content: z.string(),
contentHtml: z.string(),
published: z.number(),
publishedText: z.string(),
likeCount: z.number(),
commentId: z.string(),
authorIsChannelOwner: z.boolean(),
creatorHeart: z
.object({
creatorThumbnail: z.string(),
creatorName: z.string()
})
.optional(),
replies: z
.object({
replyCount: z.number(),
continuation: z.string()
})
.optional()
});
export const CommentsModel = z.object({
commentCount: z.number().optional(),
videoId: z.string(),
comments: CommentModel.array(),
continuation: z.string().optional()
});
type Comments = z.infer<typeof CommentsModel>;
export default Comments;

@ -7,6 +7,7 @@ import Adapter, { ApiType } from "@/client/adapters";
import { Suggestions } from "@/client/typings/search/suggestions";
import Transformer from "./transformer";
import Comments, { CommentsModel } from "./typings/comments";
import Search, { SearchModel } from "./typings/search";
import Stream, { StreamModel } from "./typings/stream";
import Video, { VideoModel } from "./typings/video";
@ -105,6 +106,28 @@ const getStream = async (
return data;
};
const getComments = async (
apiBaseUrl: string,
videoId: string,
nextpage?: string
): Promise<Comments> => {
const searchParams = new URLSearchParams();
let url;
if (nextpage) {
url = new URL(path.join("nextpage", "comments", videoId), apiBaseUrl);
searchParams.append("nextpage", nextpage);
} else url = new URL(path.join("comments", videoId), apiBaseUrl);
const response = await ky.get(url);
const json = await response.json();
const data = CommentsModel.parse(json);
return data;
};
const adapter: Adapter = {
apiType: ApiType.Piped,
@ -146,6 +169,10 @@ const adapter: Adapter = {
async getStream(videoId) {
return getStream(url, videoId).then(Transformer.stream);
},
async getComments(videoId) {
return getComments(url, videoId).then(Transformer.comments);
}
};
}

@ -1,3 +1,4 @@
import { Comments } from "@/client/typings/comment";
import {
ChannelItem,
Item,
@ -11,7 +12,9 @@ import {
parseChannelIdFromUrl,
parseVideoIdFromUrl
} from "@/utils/parseIdFromUrl";
import { parseRelativeTime } from "@/utils/parseRelativeTime";
import PipedComments from "./typings/comments";
import PipedItem from "./typings/item";
import PipedSearch from "./typings/search";
import PipedStream from "./typings/stream";
@ -71,9 +74,7 @@ export default class Transformer {
if (videoId === null) throw new Error("Piped: Missing video id");
const channelId = parseChannelIdFromUrl(data.uploaderUrl);
if (channelId === null) throw new Error("Piped: Missing video channelId");
const channelId = parseChannelIdFromUrl(data.uploaderUrl) ?? undefined;
return {
duration: data.duration * 1000,
@ -102,9 +103,7 @@ export default class Transformer {
}
public static stream(data: PipedStream): Stream {
const channelId = parseChannelIdFromUrl(data.uploaderUrl);
if (channelId === null) throw new Error("Piped: Missing channelId");
const channelId = parseChannelIdFromUrl(data.uploaderUrl) ?? undefined;
return {
category: data.category,
@ -130,4 +129,32 @@ export default class Transformer {
}
};
}
public static comments(data: PipedComments): Comments {
return {
enabled: !data.disabled,
count: data.commentCount,
data: data.comments.map((comment) => ({
id: comment.commentId,
message: comment.commentText,
likes: comment.likeCount,
edited: false,
written: parseRelativeTime(comment.commentedTime).toJSDate(),
author: {
name: comment.author,
id: parseChannelIdFromUrl(comment.commentorUrl) ?? undefined,
avatar: comment.thumbnail,
verified: comment.verified
},
pinned: comment.pinned,
videoUploaderLiked: comment.hearted,
videoUploaderReplied: comment.creatorReplied,
repliesToken: comment.repliesPage ?? undefined
}))
};
}
}

@ -0,0 +1,45 @@
import z from "zod";
export const CommentModel = z.object({
author: z.string().describe("The name of the author of the comment"),
commentId: z.string().describe("The comment ID"),
commentText: z.string().describe("The text of the comment"),
commentedTime: z.string().describe("The time the comment was made"),
commentorUrl: z.string().describe("The URL of the channel of the comment"),
hearted: z.boolean().describe("Whether or not the comment has been hearted"),
likeCount: z.number().describe("The number of likes the comment has"),
pinned: z.boolean().describe("Whether or not the comment is pinned"),
thumbnail: z.string().url().describe("The thumbnail of the comment"),
verified: z
.boolean()
.describe("Whether or not the author of the comment is verified"),
replyCount: z
.number()
.transform((number) => (number < 0 ? 0 : number))
.optional()
.describe("The amount of replies this comment has"),
repliesPage: z
.string()
.nullable()
.describe("The token needed to fetch the replies"),
creatorReplied: z
.boolean()
.describe("Whether the creator has replied to the comment")
});
export const CommentsModel = z.object({
comments: CommentModel.array(), // A list of comments
commentCount: z
.number()
.transform((number) => (number < 0 ? 0 : number))
.optional(),
disabled: z.boolean(), // Whether or not the comments are disabled
nextpage: z
.string()
.nullable()
.describe("A JSON encoded page, which is used for the nextpage endpoint.")
});
type Comments = z.infer<typeof CommentsModel>;
export default Comments;

@ -1,6 +1,7 @@
import Adapter, { ApiType, ConnectedAdapter } from "./adapters";
import InvidiousAdapter from "./adapters/invidious";
import PipedAdapter from "./adapters/piped";
import { Comments } from "./typings/comment";
import { SearchResults } from "./typings/search";
import { SearchOptions } from "./typings/search/options";
import { Suggestions } from "./typings/search/suggestions";
@ -81,4 +82,13 @@ export default class Client {
return await adapter.getStream(videoId);
}
public async getComments(
videoId: string,
repliesToken?: string
): Promise<Comments> {
const adapter = this.getBestAdapter();
return await adapter.getComments(videoId, repliesToken);
}
}

@ -1,6 +1,8 @@
export interface Author {
name: string;
id?: string;
handle?: string;
avatar?: string;
subscribers?: number;
verified?: boolean;
}

@ -0,0 +1,24 @@
import { Author } from "./author";
export interface Comment {
id: string;
message: string;
likes: number;
edited: boolean;
written: Date;
author: Author;
pinned: boolean;
videoUploaderLiked: boolean;
videoUploaderReplied: boolean;
repliesToken?: string;
}
export interface Comments {
enabled: boolean;
count?: number;
data: Comment[];
}

@ -19,12 +19,13 @@ export const Author: FC<{ data: AuthorProps }> = ({ data }) => {
<Avatar
isBordered
name={data.name}
showFallback
size="lg"
src={data.avatar}
alt={data.name}
/>
)}
<div className="flex flex-col gap-2">
<div className="flex flex-col">
<p className="text-lg text-default-600">{data.name}</p>
{data.subscribers && (
<p className="text-default-400 tracking-tight">

@ -1,7 +1,7 @@
import { DateTime } from "luxon";
const formatUploadedTime = (uploaded: Date): string => {
return DateTime.fromJSDate(uploaded).toRelative() ?? "";
return DateTime.fromJSDate(uploaded).toRelative() ?? "Unknown time";
};
export default formatUploadedTime;

@ -64,7 +64,7 @@ export enum ItemType {
Timestamp
}
type Item = Timestamp | Link | Tokens | Linebreak;
export type Item = Timestamp | Link | Tokens | Linebreak;
export interface ItemPattern {
regex: RegExp;

@ -0,0 +1,32 @@
import { DateTime, Duration } from "luxon";
export const parseRelativeTime = (text: string): DateTime => {
const parts = text.split(" ");
const value = parseInt(parts[0]);
const unit = parts[1];
let duration: Duration;
if (["second", "seconds"].includes(unit)) {
duration = Duration.fromObject({ seconds: value });
} else if (["minute", "minutes"].includes(unit)) {
duration = Duration.fromObject({ minutes: value });
} else if (["hour", "hours"].includes(unit)) {
duration = Duration.fromObject({ hours: value });
} else if (["day", "days"].includes(unit)) {
duration = Duration.fromObject({ days: value });
} else if (["week", "weeks"].includes(unit)) {
duration = Duration.fromObject({ weeks: value });
} else if (["month", "months"].includes(unit)) {
duration = Duration.fromObject({ months: value });
} else if (["year", "years"].includes(unit)) {
duration = Duration.fromObject({ years: value });
} else {
throw new Error(`Unknown time unit '${unit}'`);
}
const resultDate = DateTime.now().minus(duration);
return resultDate;
};

@ -0,0 +1,22 @@
const subCountRegex = /([0-9]{1,3}(?:\.[0-9]{1,2})?)([KMB])?/g;
const sizeMap: Record<string, number> = {
K: 1.0e3,
M: 1.0e6,
B: 1.0e9
};
export const parseSubscriberCount = (subCount: string): number => {
const matchIterator = subCount.matchAll(subCountRegex);
const match = matchIterator.next().value;
if (match) {
const countFloat = parseFloat(match[1]);
const size = sizeMap[match[2].toString().toUpperCase()] ?? 1;
return countFloat * size;
}
return 5;
};
Loading…
Cancel
Save