Skip to content

Commit 644dbda

Browse files
committed
fix: honor organization branding in embedded videos
1 parent 34f9a76 commit 644dbda

3 files changed

Lines changed: 224 additions & 17 deletions

File tree

apps/web/__tests__/unit/embed-video-playback-chrome.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919
vi,
2020
} from "vitest";
2121
import { EmbedVideo } from "@/app/embed/[videoId]/_components/EmbedVideo";
22+
import {
23+
getSharePageBranding,
24+
type SharePageBrandingInput,
25+
} from "@/lib/share-branding";
2226

2327
vi.mock("@cap/env", () => ({ NODE_ENV: "test" }));
2428

@@ -107,6 +111,7 @@ const createProps = (
107111
source: EmbedVideoProps["data"]["source"],
108112
): EmbedVideoProps => ({
109113
comments: [],
114+
branding: { type: "cap" },
110115
data: {
111116
id: "video-id" as EmbedVideoProps["data"]["id"],
112117
ownerId: "owner-id" as EmbedVideoProps["data"]["ownerId"],
@@ -226,3 +231,149 @@ describe("EmbedVideo playback chrome", () => {
226231
});
227232
});
228233
});
234+
235+
const organizationIconUrl =
236+
"https://example.com/organization.png" as NonNullable<
237+
SharePageBrandingInput["organizationIconUrl"]
238+
>;
239+
const shareableLinkIconUrl =
240+
"https://example.com/shareable-link.png" as NonNullable<
241+
SharePageBrandingInput["shareableLinkIconUrl"]
242+
>;
243+
244+
const brandingCases: {
245+
name: string;
246+
input: SharePageBrandingInput;
247+
expected: "cap" | "custom" | null;
248+
imageUrl?: string;
249+
}[] = [
250+
{
251+
name: "hidden Pro branding",
252+
input: {
253+
owner: { isPro: true },
254+
orgSettings: { hideShareableLinkCapLogo: true },
255+
},
256+
expected: null,
257+
},
258+
{
259+
name: "custom organization logo with Cap branding hidden",
260+
input: {
261+
owner: { isPro: true },
262+
orgSettings: {
263+
hideShareableLinkCapLogo: true,
264+
shareableLinkUseOrganizationIcon: true,
265+
},
266+
organizationIconUrl,
267+
shareableLinkIconUrl,
268+
organizationName: "Acme",
269+
},
270+
expected: "custom",
271+
imageUrl: organizationIconUrl,
272+
},
273+
{
274+
name: "custom shareable link logo",
275+
input: {
276+
owner: { isPro: true },
277+
organizationIconUrl,
278+
shareableLinkIconUrl,
279+
organizationName: "Acme",
280+
},
281+
expected: "custom",
282+
imageUrl: shareableLinkIconUrl,
283+
},
284+
{
285+
name: "free owner with saved Pro branding preferences",
286+
input: {
287+
owner: { isPro: false },
288+
orgSettings: {
289+
hideShareableLinkCapLogo: true,
290+
shareableLinkUseOrganizationIcon: true,
291+
},
292+
organizationIconUrl,
293+
},
294+
expected: "cap",
295+
},
296+
{
297+
name: "default Pro branding",
298+
input: { owner: { isPro: true } },
299+
expected: "cap",
300+
},
301+
{
302+
name: "hidden branding with a missing organization logo",
303+
input: {
304+
owner: { isPro: true },
305+
orgSettings: {
306+
hideShareableLinkCapLogo: true,
307+
shareableLinkUseOrganizationIcon: true,
308+
},
309+
shareableLinkIconUrl,
310+
},
311+
expected: null,
312+
},
313+
];
314+
315+
describe.each([
316+
{ type: "desktopMP4" } as const,
317+
{ type: "MediaConvert" } as const,
318+
])("EmbedVideo $type organization branding", (source) => {
319+
beforeAll(() => {
320+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
321+
});
322+
afterAll(() => {
323+
delete actEnvironment.IS_REACT_ACT_ENVIRONMENT;
324+
});
325+
326+
it.each(brandingCases)(
327+
"honors $name before and after playback",
328+
async ({ input, expected, imageUrl }) => {
329+
const container = document.createElement("div");
330+
const root = createRoot(container);
331+
const props = {
332+
...createProps(source),
333+
branding: getSharePageBranding(input),
334+
};
335+
await act(async () => {
336+
root.render(createElement(EmbedVideo, props));
337+
});
338+
339+
const expectBranding = () => {
340+
expect(container.textContent).toContain("Test video");
341+
expect(
342+
Boolean(container.querySelector('[aria-label="Powered by Cap"]')),
343+
).toBe(expected === "cap");
344+
expect(Boolean(container.querySelector("[data-cap-logo]"))).toBe(
345+
expected === "cap",
346+
);
347+
const logo = container.querySelector("img");
348+
if (expected === "custom") {
349+
expect(logo?.getAttribute("src")).toBe(imageUrl);
350+
expect(logo?.getAttribute("alt")).toBe("Acme logo");
351+
expect(logo?.closest("a, button")).toBeNull();
352+
} else {
353+
expect(logo).toBeNull();
354+
}
355+
};
356+
expectBranding();
357+
const video = container.querySelector("video");
358+
for (const event of ["pause", "ended"]) {
359+
await act(async () => {
360+
video?.dispatchEvent(new Event("play"));
361+
});
362+
expectChromeHidden(container);
363+
expect(container.querySelector("img")).toBeNull();
364+
await act(async () => {
365+
video?.dispatchEvent(new Event(event));
366+
});
367+
expectBranding();
368+
}
369+
await act(async () => {
370+
root.render(createElement(EmbedVideo, { ...props, minimal: true }));
371+
});
372+
expectChromeHidden(container);
373+
expect(container.querySelector("img")).toBeNull();
374+
await act(async () => {
375+
root.unmount();
376+
});
377+
},
378+
);
379+
});

apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Avatar, Logo } from "@cap/ui";
77
import type { ViewerSettings } from "@cap/web-backend";
88
import { AnimatePresence, motion } from "framer-motion";
99
import { useTranscript } from "hooks/use-transcript";
10+
import Image from "next/image";
1011
import {
1112
forwardRef,
1213
useEffect,
@@ -27,6 +28,7 @@ import {
2728
parseVTT,
2829
type TranscriptEntry,
2930
} from "@/app/s/[videoId]/_components/utils/transcript-utils";
31+
import type { SharePageBranding } from "@/lib/share-branding";
3032
import { usePlayerJsReceiver } from "./use-player-js-receiver";
3133

3234
declare global {
@@ -53,6 +55,7 @@ export const EmbedVideo = forwardRef<
5355
data: Omit<typeof videos.$inferSelect, "password"> & {
5456
hasActiveUpload: boolean | undefined;
5557
};
58+
branding: SharePageBranding | null;
5659
user: typeof userSelectProps | null;
5760
comments: CommentWithAuthor[];
5861
chapters?: { title: string; start: number }[];
@@ -68,6 +71,7 @@ export const EmbedVideo = forwardRef<
6871
(
6972
{
7073
data,
74+
branding,
7175
user: _user,
7276
comments: _comments,
7377
chapters = [],
@@ -339,23 +343,42 @@ export const EmbedVideo = forwardRef<
339343
</div>
340344
</div>
341345
</motion.div>
342-
<motion.button
343-
initial={{ opacity: 0, y: 10 }}
344-
animate={{ opacity: 1, y: 0 }}
345-
exit={{ opacity: 0, y: 10 }}
346-
transition={{ duration: 0.3, delay: 0.1 }}
347-
onClick={(e) => {
348-
e.stopPropagation();
349-
window.open("https://cap.so", "_blank");
350-
}}
351-
className="hidden z-10 gap-2 items-center px-3 py-2 text-sm rounded-full border backdrop-blur-sm transition-colors duration-200 sm:flex border-white/10 w-fit text-white/80 hover:text-white bg-black/50"
352-
aria-label="Powered by Cap"
353-
>
354-
<span className="text-xs md:text-sm text-white/80">
355-
Powered by
356-
</span>
357-
<Logo className="w-auto h-4" white={true} />
358-
</motion.button>
346+
{branding?.type === "custom" ? (
347+
<motion.div
348+
initial={{ opacity: 0, y: 10 }}
349+
animate={{ opacity: 1, y: 0 }}
350+
exit={{ opacity: 0, y: 10 }}
351+
transition={{ duration: 0.3, delay: 0.1 }}
352+
className="w-fit rounded-lg border border-white/10 bg-black/50 px-3 py-2 backdrop-blur-sm"
353+
>
354+
<Image
355+
src={branding.imageUrl}
356+
alt={`${branding.name} logo`}
357+
width={160}
358+
height={32}
359+
unoptimized
360+
className="h-8 w-auto max-w-40 object-contain"
361+
/>
362+
</motion.div>
363+
) : branding?.type === "cap" ? (
364+
<motion.button
365+
initial={{ opacity: 0, y: 10 }}
366+
animate={{ opacity: 1, y: 0 }}
367+
exit={{ opacity: 0, y: 10 }}
368+
transition={{ duration: 0.3, delay: 0.1 }}
369+
onClick={(e) => {
370+
e.stopPropagation();
371+
window.open("https://cap.so", "_blank");
372+
}}
373+
className="hidden z-10 gap-2 items-center px-3 py-2 text-sm rounded-full border backdrop-blur-sm transition-colors duration-200 sm:flex border-white/10 w-fit text-white/80 hover:text-white bg-black/50"
374+
aria-label="Powered by Cap"
375+
>
376+
<span className="text-xs md:text-sm text-white/80">
377+
Powered by
378+
</span>
379+
<Logo className="w-auto h-4" white={true} />
380+
</motion.button>
381+
) : null}
359382
</div>
360383
)}
361384
</AnimatePresence>

apps/web/app/embed/[videoId]/page.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { buildEnv } from "@cap/env";
1515
import { Logo } from "@cap/ui";
1616
import { userIsPro } from "@cap/utils";
1717
import {
18+
ImageUploads,
1819
provideOptionalAuth,
1920
resolveEffectiveVideoRules,
2021
Videos,
@@ -27,6 +28,7 @@ import type { Metadata } from "next";
2728
import Link from "next/link";
2829
import { notFound } from "next/navigation";
2930
import * as EffectRuntime from "@/lib/server";
31+
import { getSharePageBranding } from "@/lib/share-branding";
3032
import { buildShareVideoMetadata } from "@/lib/share-video-metadata";
3133
import { isVideoOverShareableLinkLimit } from "@/lib/shareable-link-quota";
3234
import { transcribeVideo } from "@/lib/transcribe";
@@ -146,6 +148,9 @@ export default async function EmbedVideoPage(
146148
organizationId: sharedVideos.organizationId,
147149
},
148150
orgSettings: organizations.settings,
151+
organizationName: organizations.name,
152+
organizationIconUrl: organizations.iconUrl,
153+
shareableLinkIconUrl: organizations.shareableLinkIconUrl,
149154
hasActiveUpload:
150155
sql`${videoUploads.videoId} IS NOT NULL AND ${videos.isScreenshot} = false`.mapWith(
151156
Boolean,
@@ -204,6 +209,9 @@ async function EmbedContent({
204209
sharedOrganization: { organizationId: Organisation.OrganisationId } | null;
205210
hasActiveUpload: boolean | undefined;
206211
orgSettings?: (typeof organizations.$inferSelect)["settings"] | null;
212+
organizationName: (typeof organizations.$inferSelect)["name"] | null;
213+
organizationIconUrl: (typeof organizations.$inferSelect)["iconUrl"];
214+
shareableLinkIconUrl: (typeof organizations.$inferSelect)["shareableLinkIconUrl"];
207215
};
208216
autoplay: boolean;
209217
startTime: number | null;
@@ -347,9 +355,34 @@ async function EmbedContent({
347355
.where(eq(users.id, video.ownerId))
348356
.limit(1);
349357

358+
const branding = await Effect.gen(function* () {
359+
const brandingInput = {
360+
owner: { isPro: ownerIsProUser },
361+
orgSettings: video.orgSettings,
362+
organizationName: video.organizationName,
363+
};
364+
const icon = video.orgSettings?.shareableLinkUseOrganizationIcon
365+
? video.organizationIconUrl
366+
: video.shareableLinkIconUrl;
367+
368+
if (!ownerIsProUser || !icon || minimal) {
369+
return getSharePageBranding(brandingInput);
370+
}
371+
372+
const imageUploads = yield* ImageUploads;
373+
const imageUrl = yield* imageUploads.resolveImageUrl(icon);
374+
375+
return getSharePageBranding({
376+
...brandingInput,
377+
organizationIconUrl: imageUrl,
378+
shareableLinkIconUrl: imageUrl,
379+
});
380+
}).pipe(EffectRuntime.runPromise);
381+
350382
return (
351383
<EmbedVideo
352384
data={video}
385+
branding={branding}
353386
user={user}
354387
comments={commentsQuery}
355388
chapters={

0 commit comments

Comments
 (0)