From caf8f903d28f7df74d93ce1c36ffb58b9ba9301d Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 29 Oct 2025 17:17:51 +0800 Subject: [PATCH 1/4] ensure more limits don't show when self hosting --- apps/web/app/s/[videoId]/Share.tsx | 11 +- .../s/[videoId]/_components/ImageViewer.tsx | 112 ------------------ .../s/[videoId]/_components/ShareHeader.tsx | 29 ++--- .../s/[videoId]/_components/ShareVideo.tsx | 15 ++- .../app/s/[videoId]/_components/Sidebar.tsx | 20 ++-- .../app/s/[videoId]/_components/Toolbar.tsx | 10 +- .../[videoId]/_components/tabs/Transcript.tsx | 13 +- apps/web/app/s/[videoId]/page.tsx | 40 ++++--- apps/web/app/s/[videoId]/types.ts | 19 +++ packages/web-backend/src/Auth.ts | 3 - 10 files changed, 78 insertions(+), 194 deletions(-) delete mode 100644 apps/web/app/s/[videoId]/_components/ImageViewer.tsx create mode 100644 apps/web/app/s/[videoId]/types.ts diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 3d0e4e71d7..83f93d56a6 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -22,6 +22,7 @@ import { ShareVideo } from "./_components/ShareVideo"; import { Sidebar } from "./_components/Sidebar"; import SummaryChapters from "./_components/SummaryChapters"; import { Toolbar } from "./_components/Toolbar"; +import type { VideoData } from "./types"; type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; @@ -34,16 +35,8 @@ export type CommentType = typeof commentsSchema.$inferSelect & { sending?: boolean; }; -type VideoWithOrganizationInfo = typeof videos.$inferSelect & { - organizationMembers?: string[]; - organizationId?: string; - sharedOrganizations?: { id: string; name: string }[]; - hasPassword?: boolean; - orgSettings?: OrganizationSettings | null; -}; - interface ShareProps { - data: VideoWithOrganizationInfo; + data: VideoData; comments: MaybePromise; views: MaybePromise; customDomain: string | null; diff --git a/apps/web/app/s/[videoId]/_components/ImageViewer.tsx b/apps/web/app/s/[videoId]/_components/ImageViewer.tsx deleted file mode 100644 index d4d6ad8caf..0000000000 --- a/apps/web/app/s/[videoId]/_components/ImageViewer.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import type { userSelectProps } from "@cap/database/auth/session"; -import type { comments as commentsSchema, videos } from "@cap/database/schema"; -import { LogoSpinner } from "@cap/ui"; -import { MessageSquare } from "lucide-react"; -import React, { useEffect, useState } from "react"; -import { Tooltip } from "react-tooltip"; -import { ShareHeader } from "./ShareHeader"; -import { Toolbar } from "./Toolbar"; - -// million-ignore -export const ImageViewer = ({ - data, - comments, - imageSrc, -}: { - data: typeof videos.$inferSelect; - comments: (typeof commentsSchema.$inferSelect)[]; - imageSrc: string; -}) => { - const [overlayVisible, setOverlayVisible] = useState(false); - const [imageLoaded, setImageLoaded] = useState(false); - - useEffect(() => { - const img = new Image(); - img.src = imageSrc; - img.onload = () => setImageLoaded(true); - }, [imageSrc]); - - return ( -
-
- -
setOverlayVisible(true)} - onMouseLeave={() => setOverlayVisible(false)} - > -
- {!imageLoaded && ( -
- -
- )} - Image setImageLoaded(true)} - /> - {comments.length > 0 && imageLoaded && ( -
- )} - {imageLoaded && ( -
-
- {comments.map((comment) => ( - -
- - {comment.type === "text" ? ( - - ) : ( - comment.content - )} - -
- -
- ))} -
-
- )} -
-
-
- -
-
-
- ); -}; diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 7666184f5e..f3992bd2ec 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -1,11 +1,9 @@ "use client"; -import type { userSelectProps } from "@cap/database/auth/session"; import type { videos } from "@cap/database/schema"; import { buildEnv, NODE_ENV } from "@cap/env"; import { Button } from "@cap/ui"; -import { userIsPro } from "@cap/utils"; -import type { ImageUpload } from "@cap/web-domain"; +import { type ImageUpload, User } from "@cap/web-domain"; import { faChevronDown, faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Check, Copy, Globe2 } from "lucide-react"; @@ -21,6 +19,7 @@ import { useCurrentUser } from "@/app/Layout/AuthContext"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { UpgradeModal } from "@/components/UpgradeModal"; import { usePublicEnv } from "@/utils/public-env"; +import type { VideoData } from "../types"; export const ShareHeader = ({ data, @@ -30,11 +29,7 @@ export const ShareHeader = ({ sharedSpaces = [], spacesData = null, }: { - data: typeof videos.$inferSelect & { - ownerName?: string | null; - ownerImage?: ImageUpload.ImageUrl | null; - ownerIsPro?: boolean; - }; + data: VideoData; customDomain?: string | null; domainVerified?: boolean; sharedOrganizations?: { id: string; name: string }[]; @@ -65,7 +60,7 @@ export const ShareHeader = ({ const contextSharedSpaces = contextData?.sharedSpaces || null; const effectiveSharedSpaces = contextSharedSpaces || sharedSpaces; - const isOwner = user && user.id === data.ownerId; + const isOwner = user && user.id === data.owner.id; const { webUrl } = usePublicEnv(); @@ -132,8 +127,6 @@ export const ShareHeader = ({ } }; - const isVideoOwnerPro: boolean = data.ownerIsPro ?? false; - const handleSharingUpdated = () => { refresh(); }; @@ -182,9 +175,11 @@ export const ShareHeader = ({ } }; + const userIsOwnerAndNotPro = user?.id === data.owner.id && !data.owner.isPro; + return ( <> - {isOwner && !isVideoOwnerPro && ( + {userIsOwnerAndNotPro && (

Shareable links are limited to 5 mins on the free plan. @@ -238,16 +233,16 @@ export const ShareHeader = ({

- {data.ownerName && ( + {data.name && ( )}
-

{data.ownerName}

+

{data.name}

{moment(data.createdAt).fromNow()}

@@ -285,7 +280,7 @@ export const ShareHeader = ({ )}
- {!isVideoOwnerPro && ( + {userIsOwnerAndNotPro && (
- {!data.ownerIsPro && ( + {!data.owner.isPro && (
void; @@ -91,10 +86,9 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>( ) => { const user = useCurrentUser(); - const isOwnerOrMember: boolean = Boolean( - user?.id === data.ownerId || - (data.organizationId && - data.organizationMembers?.includes(user?.id ?? "")), + const isOwnerOrMember = Boolean( + user?.id === data.owner.id || + (user && data.organizationMembers?.includes(user.id)), ); const defaultTab = !( @@ -106,7 +100,7 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>( : !( videoSettings?.disableTranscript ?? data.orgSettings?.disableTranscript - ) + ) ? "transcript" : "activity"; @@ -182,7 +176,7 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>( /> ); case "transcript": - return ; + return ; case "settings": return ; default: diff --git a/apps/web/app/s/[videoId]/_components/Toolbar.tsx b/apps/web/app/s/[videoId]/_components/Toolbar.tsx index 05b7080a7f..8f333c56fb 100644 --- a/apps/web/app/s/[videoId]/_components/Toolbar.tsx +++ b/apps/web/app/s/[videoId]/_components/Toolbar.tsx @@ -1,22 +1,18 @@ -import type { userSelectProps } from "@cap/database/auth/session"; -import type { videos } from "@cap/database/schema"; import { Button } from "@cap/ui"; -import { Comment, User } from "@cap/web-domain"; +import { Comment } from "@cap/web-domain"; import { AnimatePresence, motion } from "motion/react"; import { startTransition, useEffect, useState } from "react"; import { newComment } from "@/actions/videos/new-comment"; -import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; import { useCurrentUser } from "@/app/Layout/AuthContext"; import type { CommentType } from "../Share"; +import type { VideoData } from "../types"; import { AuthOverlay } from "./AuthOverlay"; const MotionButton = motion.create(Button); // million-ignore interface ToolbarProps { - data: typeof videos.$inferSelect & { - orgSettings?: OrganizationSettings | null; - }; + data: VideoData; onOptimisticComment?: (comment: CommentType) => void; onCommentSuccess?: (comment: CommentType) => void; disableReactions?: boolean; diff --git a/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx b/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx index 64cc7ae3de..5d5d0d7210 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx @@ -7,9 +7,11 @@ import { useInvalidateTranscript, useTranscript } from "hooks/use-transcript"; import { Check, Copy, Download, Edit3, MessageSquare, X } from "lucide-react"; import { useEffect, useState } from "react"; import { editTranscriptEntry } from "@/actions/videos/edit-transcript"; +import { useCurrentUser } from "@/app/Layout/AuthContext"; +import type { VideoData } from "../../types"; interface TranscriptProps { - data: typeof videos.$inferSelect; + data: VideoData; onSeek?: (time: number) => void; user?: { id: string } | null; } @@ -117,11 +119,8 @@ const parseVTT = (vttContent: string): TranscriptEntry[] => { return sortedEntries; }; -export const Transcript: React.FC = ({ - data, - user, - onSeek, -}) => { +export const Transcript: React.FC = ({ data, onSeek }) => { + const user = useCurrentUser(); const [transcriptData, setTranscriptData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [selectedEntry, setSelectedEntry] = useState(null); @@ -379,7 +378,7 @@ export const Transcript: React.FC = ({ }, 2000); }; - const canEdit = user?.id === data.ownerId; + const canEdit = user?.id === data.owner.id; if (isLoading) { return ( diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 13dcfce303..2b28b08ebf 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -14,6 +14,7 @@ import { import type { VideoMetadata } from "@cap/database/types"; import { buildEnv } from "@cap/env"; import { Logo } from "@cap/ui"; +import { userIsPro } from "@cap/utils"; import { Database, ImageUploads, @@ -276,7 +277,6 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { .select({ id: videos.id, name: videos.name, - ownerId: videos.ownerId, ownerName: users.name, ownerImageUrlOrKey: users.image, orgId: videos.orgId, @@ -306,17 +306,14 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { organizationId: sharedVideos.organizationId, }, orgSettings: organizations.settings, - ownerIsPro: - sql`${users.stripeSubscriptionStatus} IN ('active','trialing','complete','paid') OR ${users.thirdPartyStripeSubscriptionId} IS NOT NULL`.mapWith( - Boolean, - ), hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( Boolean, ), + owner: users, }) .from(videos) .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .leftJoin(users, eq(videos.ownerId, users.id)) + .innerJoin(users, eq(videos.ownerId, users.id)) .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) .leftJoin(organizations, eq(videos.orgId, organizations.id)) .where(eq(videos.id, videoId)), @@ -367,8 +364,9 @@ async function AuthorizedContent({ }: { video: Omit< InferSelectModel, - "folderId" | "password" | "settings" + "folderId" | "password" | "settings" | "ownerId" > & { + owner: InferSelectModel; sharedOrganization: { organizationId: Organisation.OrganisationId } | null; hasPassword: boolean; ownerIsPro?: boolean; @@ -383,7 +381,7 @@ async function AuthorizedContent({ const user = await getCurrentUser(); const videoId = video.id; - if (user && video && user.id !== video.ownerId) { + if (user && video && user.id !== video.owner.id) { try { await createNotification({ type: "view", @@ -425,7 +423,7 @@ async function AuthorizedContent({ stripeSubscriptionStatus: users.stripeSubscriptionStatus, }) .from(users) - .where(eq(users.id, video.ownerId)) + .where(eq(users.id, video.owner.id)) .limit(1); if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) { @@ -470,7 +468,7 @@ async function AuthorizedContent({ video.transcriptionStatus !== "PROCESSING" ) { console.log("[ShareVideoPage] Starting transcription for video:", videoId); - await transcribeVideo(videoId, video.ownerId, aiGenerationEnabled); + await transcribeVideo(videoId, video.owner.id, aiGenerationEnabled); const updatedVideoQuery = await db() .select({ @@ -548,7 +546,7 @@ async function AuthorizedContent({ aiGenerationEnabled ) { try { - generateAiMetadata(videoId, video.ownerId).catch((error) => { + generateAiMetadata(videoId, video.owner.id).catch((error) => { console.error( `[ShareVideoPage] Error generating AI metadata for video ${videoId}:`, error, @@ -586,7 +584,7 @@ async function AuthorizedContent({ org && org.customDomain && org.domainVerified !== null && - user.id === video.ownerId + user.id === video.owner.id ) { return { customDomain: org.customDomain, domainVerified: true }; } @@ -725,13 +723,19 @@ async function AuthorizedContent({ return { ...video, - ownerImage: video.ownerImageUrlOrKey - ? yield* imageUploads.resolveImageUrl(video.ownerImageUrlOrKey) - : null, - organizationMembers: membersList.map((member) => member.userId), - organizationId: video.sharedOrganization?.organizationId ?? undefined, + owner: { + id: video.owner.id, + name: video.owner.name, + isPro: userIsPro(video.owner), + image: video.ownerImageUrlOrKey + ? yield* imageUploads.resolveImageUrl(video.ownerImageUrlOrKey) + : null, + }, + organization: { + organizationMembers: membersList.map((member) => member.userId), + organizationId: video.sharedOrganization?.organizationId ?? undefined, + }, sharedOrganizations: sharedOrganizations, - ownerIsPro: video.ownerIsPro ?? false, password: null, folderId: null, orgSettings: video.orgSettings || null, diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts new file mode 100644 index 0000000000..6bd2536f80 --- /dev/null +++ b/apps/web/app/s/[videoId]/types.ts @@ -0,0 +1,19 @@ +import type { videos } from "@cap/database/schema"; +import type { ImageUpload, Organisation, User } from "@cap/web-domain"; +import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; + +export type VideoData = Omit & { + owner: VideoOwner; + organizationMembers?: User.UserId[]; + organizationId?: Organisation.OrganisationId; + sharedOrganizations?: { id: string; name: string }[]; + hasPassword?: boolean; + orgSettings?: OrganizationSettings | null; +}; + +export type VideoOwner = { + id: User.UserId; + isPro: boolean; + name?: string | null; + image?: ImageUpload.ImageUrl | null; +}; diff --git a/packages/web-backend/src/Auth.ts b/packages/web-backend/src/Auth.ts index fed3808c2a..08cebb6cfa 100644 --- a/packages/web-backend/src/Auth.ts +++ b/packages/web-backend/src/Auth.ts @@ -105,9 +105,6 @@ export const provideOptionalAuth = ( Effect.gen(function* () { const user = yield* getCurrentUser; - if (Option.isSome(user)) - yield* Effect.log(`Providing auth for user ${user.value.id}`); - return yield* user.pipe( Option.match({ onNone: () => app, From 094d511352944aa79fb3ec982a830b8aa677457e Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 29 Oct 2025 17:25:27 +0800 Subject: [PATCH 2/4] formatting --- apps/web/app/s/[videoId]/_components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/s/[videoId]/_components/Sidebar.tsx b/apps/web/app/s/[videoId]/_components/Sidebar.tsx index 29b8efe8f5..9625f33d5b 100644 --- a/apps/web/app/s/[videoId]/_components/Sidebar.tsx +++ b/apps/web/app/s/[videoId]/_components/Sidebar.tsx @@ -100,7 +100,7 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>( : !( videoSettings?.disableTranscript ?? data.orgSettings?.disableTranscript - ) + ) ? "transcript" : "activity"; From cc740859562c5e51796eddf05b9e14bf8e04ccfa Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 29 Oct 2025 17:26:56 +0800 Subject: [PATCH 3/4] don't transcribe if no deepgram --- apps/web/actions/videos/get-status.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 93d2daacd4..b5f8dddb74 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -11,6 +11,7 @@ import * as EffectRuntime from "@/lib/server"; import { isAiGenerationEnabled } from "@/utils/flags"; import { transcribeVideo } from "../../lib/transcribe"; import { generateAiMetadata } from "./generate-ai-metadata"; +import { serverEnv } from "@cap/env"; const MAX_AI_PROCESSING_TIME = 10 * 60 * 1000; @@ -45,7 +46,7 @@ export async function getVideoStatus( const metadata: VideoMetadata = (video.metadata as VideoMetadata) || {}; - if (!video.transcriptionStatus) { + if (!video.transcriptionStatus && serverEnv().DEEPGRAM_API_KEY) { console.log( `[Get Status] Transcription not started for video ${videoId}, triggering transcription`, ); From 7c78f5efadd0c9d8d3911532e49e3325235a02c3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 29 Oct 2025 17:28:55 +0800 Subject: [PATCH 4/4] format --- apps/web/actions/videos/get-status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index b5f8dddb74..0124d11904 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -3,6 +3,7 @@ import { db } from "@cap/database"; import { users, videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; +import { serverEnv } from "@cap/env"; import { provideOptionalAuth, VideosPolicy } from "@cap/web-backend"; import { Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; @@ -11,7 +12,6 @@ import * as EffectRuntime from "@/lib/server"; import { isAiGenerationEnabled } from "@/utils/flags"; import { transcribeVideo } from "../../lib/transcribe"; import { generateAiMetadata } from "./generate-ai-metadata"; -import { serverEnv } from "@cap/env"; const MAX_AI_PROCESSING_TIME = 10 * 60 * 1000;