Skip to content

Commit 702a95f

Browse files
feat: youtube banner
1 parent b07c1b8 commit 702a95f

File tree

9 files changed

+218
-1
lines changed

9 files changed

+218
-1
lines changed

src/sidebar/components/HypothesisApp.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { useEffect, useMemo } from 'preact/hooks';
55
import type { SidebarSettings } from '../../types/config';
66
import { serviceConfig } from '../config/service-config';
77
import { isThirdPartyService } from '../helpers/is-third-party-service';
8-
import { shouldAutoDisplayTutorial } from '../helpers/session';
8+
import {
9+
shouldAutoDisplayTutorial,
10+
shouldShowYoutubeDisclaimer,
11+
} from '../helpers/session';
912
import { applyTheme } from '../helpers/theme';
1013
import { withServices } from '../service-context';
1114
import type { AuthService } from '../services/auth';
@@ -22,6 +25,7 @@ import SidebarView from './SidebarView';
2225
import StreamView from './StreamView';
2326
import ToastMessages from './ToastMessages';
2427
import TopBar from './TopBar';
28+
import YouTubeDisclaimerBanner from './YouTubeDisclaimerBanner';
2529
import SearchPanel from './search/SearchPanel';
2630

2731
export type HypothesisAppProps = {
@@ -164,6 +168,9 @@ function HypothesisApp({
164168
isSidebar={isSidebar}
165169
/>
166170
)}
171+
{!isModalRoute && shouldShowYoutubeDisclaimer(settings, profile) && (
172+
<YouTubeDisclaimerBanner />
173+
)}
167174
<div className="container">
168175
<ToastMessages />
169176
<HelpPanel />
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Button } from '@hypothesis/frontend-shared';
2+
import classnames from 'classnames';
3+
4+
import { withServices } from '../service-context';
5+
import type { SessionService } from '../services/session';
6+
7+
/**
8+
* Banner shown when the assignment includes embedded YouTube videos (LMS config
9+
* `youtubeAssignment: true`). Informs the user about YouTube's data practices
10+
* and offers a "Dismiss" button to hide it permanently (persisted in profile).
11+
*/
12+
function YouTubeDisclaimerBanner({ session }: { session: SessionService }) {
13+
const handleDismiss = () => {
14+
session.dismissYoutubeDisclaimer();
15+
};
16+
17+
return (
18+
<div
19+
className={classnames(
20+
'flex flex-col gap-3 p-4 bg-grey-1 border-b border-grey-3 text-color-text',
21+
'text-sm',
22+
)}
23+
data-testid="youtube-disclaimer-banner"
24+
>
25+
<p className="m-0">
26+
This activity includes embedded YouTube videos. Hypothesis does not
27+
control whether YouTube displays ads or collects limited technical data
28+
such as IP address or device information when videos are viewed. Your
29+
institution may choose to disable the YouTube integration at any time.
30+
</p>
31+
<div>
32+
<Button
33+
onClick={handleDismiss}
34+
variant="primary"
35+
data-testid="youtube-disclaimer-dismiss"
36+
>
37+
Dismiss
38+
</Button>
39+
</div>
40+
</div>
41+
);
42+
}
43+
44+
export default withServices(YouTubeDisclaimerBanner, ['session']);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { mount } from '@hypothesis/frontend-testing';
2+
3+
import { ServiceContext } from '../../service-context';
4+
import YouTubeDisclaimerBanner from '../YouTubeDisclaimerBanner';
5+
6+
describe('YouTubeDisclaimerBanner', () => {
7+
let fakeSessionService;
8+
9+
function createComponent() {
10+
const injector = {
11+
get: sinon.stub().withArgs('session').returns(fakeSessionService),
12+
};
13+
return mount(
14+
<ServiceContext.Provider value={injector}>
15+
<YouTubeDisclaimerBanner />
16+
</ServiceContext.Provider>,
17+
);
18+
}
19+
20+
beforeEach(() => {
21+
fakeSessionService = { dismissYoutubeDisclaimer: sinon.stub().resolves() };
22+
});
23+
24+
it('displays the YouTube disclaimer text', () => {
25+
const wrapper = createComponent();
26+
const text = wrapper.text();
27+
assert.include(text, 'This activity includes embedded YouTube videos');
28+
assert.include(text, 'Hypothesis does not control whether YouTube');
29+
assert.include(text, 'Your institution may choose to disable');
30+
});
31+
32+
it('shows a Dismiss button', () => {
33+
const wrapper = createComponent();
34+
const dismissButton = wrapper.find(
35+
'Button[data-testid="youtube-disclaimer-dismiss"]',
36+
);
37+
assert.equal(dismissButton.length, 1);
38+
assert.include(dismissButton.text(), 'Dismiss');
39+
});
40+
41+
it('calls session.dismissYoutubeDisclaimer when Dismiss is clicked', () => {
42+
const wrapper = createComponent();
43+
const dismissButton = wrapper.find(
44+
'Button[data-testid="youtube-disclaimer-dismiss"]',
45+
);
46+
dismissButton.props().onClick();
47+
assert.calledOnce(fakeSessionService.dismissYoutubeDisclaimer);
48+
});
49+
});

src/sidebar/helpers/session.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,22 @@ export function shouldAutoDisplayTutorial(
4242

4343
return true;
4444
}
45+
46+
/**
47+
* Return true if the YouTube disclaimer banner should be shown.
48+
* Show when the assignment is a YouTube assignment (from LMS config) and the
49+
* user has not yet dismissed the disclaimer (H backend sends show_youtube_gdpr_banner: true).
50+
*
51+
* @param settings - Sidebar settings (includes youtubeAssignment from embedder)
52+
* @param profile - User profile from the API
53+
* @return Whether to show the YouTube disclaimer banner
54+
*/
55+
export function shouldShowYoutubeDisclaimer(
56+
settings: SidebarSettings,
57+
profile: Profile,
58+
): boolean {
59+
if (settings.youtubeAssignment !== true) {
60+
return false;
61+
}
62+
return profile.preferences?.show_youtube_gdpr_banner === true;
63+
}

src/sidebar/helpers/test/session-test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,50 @@ describe('sidebar/helpers/session', () => {
8181
});
8282
});
8383
});
84+
85+
describe('shouldShowYoutubeDisclaimer', () => {
86+
[
87+
{
88+
description:
89+
'youtubeAssignment is true and H sends show_youtube_gdpr_banner true',
90+
settings: { youtubeAssignment: true },
91+
profile: { preferences: { show_youtube_gdpr_banner: true } },
92+
expected: true,
93+
},
94+
{
95+
description:
96+
'youtubeAssignment is true and preference is undefined (no banner)',
97+
settings: { youtubeAssignment: true },
98+
profile: {},
99+
expected: false,
100+
},
101+
{
102+
description:
103+
'youtubeAssignment is true but user has dismissed (H omits key)',
104+
settings: { youtubeAssignment: true },
105+
profile: { preferences: {} },
106+
expected: false,
107+
},
108+
{
109+
description: 'youtubeAssignment is false',
110+
settings: { youtubeAssignment: false },
111+
profile: { preferences: { show_youtube_gdpr_banner: true } },
112+
expected: false,
113+
},
114+
{
115+
description: 'youtubeAssignment is undefined',
116+
settings: {},
117+
profile: { preferences: { show_youtube_gdpr_banner: true } },
118+
expected: false,
119+
},
120+
].forEach(fixture => {
121+
it(`returns ${fixture.expected} when ${fixture.description}`, () => {
122+
const result = sessionUtil.shouldShowYoutubeDisclaimer(
123+
fixture.settings,
124+
fixture.profile,
125+
);
126+
assert.equal(result, fixture.expected);
127+
});
128+
});
129+
});
84130
});

src/sidebar/services/session.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@ export class SessionService {
9595
this.update(updatedProfile);
9696
}
9797

98+
/**
99+
* Store the preference server-side that the user dismissed the YouTube
100+
* disclaimer banner and then update the local profile data.
101+
* Uses show_youtube_gdpr_banner: false (H backend contract).
102+
*/
103+
async dismissYoutubeDisclaimer() {
104+
const updatedProfile = await this._api.profile.update(
105+
{},
106+
{ preferences: { show_youtube_gdpr_banner: false } },
107+
);
108+
this.update(updatedProfile);
109+
}
110+
98111
/**
99112
* Persist the user's keyboard shortcut overrides server-side.
100113
*/

src/sidebar/services/test/session-test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,37 @@ describe('SessionService', () => {
289289
});
290290
});
291291

292+
describe('#dismissYoutubeDisclaimer', () => {
293+
beforeEach(() => {
294+
fakeApi.profile.update.returns(
295+
Promise.resolve({
296+
preferences: {},
297+
}),
298+
);
299+
});
300+
301+
it('calls H backend with show_youtube_gdpr_banner: false to persist dismiss', () => {
302+
const session = createService();
303+
session.dismissYoutubeDisclaimer();
304+
assert.calledWith(
305+
fakeApi.profile.update,
306+
{},
307+
{
308+
preferences: { show_youtube_gdpr_banner: false },
309+
},
310+
);
311+
});
312+
313+
it('updates the session with the response from the API', () => {
314+
const session = createService();
315+
const updatedProfile = { preferences: {} };
316+
fakeApi.profile.update.resolves(updatedProfile);
317+
return session.dismissYoutubeDisclaimer().then(() => {
318+
assert.calledWith(fakeStore.updateProfile, updatedProfile);
319+
});
320+
});
321+
});
322+
292323
describe('#reload', () => {
293324
beforeEach(() => {
294325
// Load the initial profile data, as the client will do on startup.

src/types/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,8 @@ export type Profile = {
368368
preferences: {
369369
show_sidebar_tutorial?: boolean;
370370
shortcuts_preferences?: ShortcutsPreferences;
371+
/** When true, user has not dismissed the YouTube GDPR banner (from H backend). */
372+
show_youtube_gdpr_banner?: boolean;
371373
};
372374
features: Record<string, boolean>;
373375
user_info?: UserInfo;

src/types/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,10 @@ export type ConfigFromEmbedder = ConfigFromHost & {
333333

334334
/** Configuration for menu items etc. related to LMS instructor dashboard */
335335
dashboard?: DashboardConfig;
336+
337+
/**
338+
* When true, indicates the assignment content is a YouTube video.
339+
* Set by the LMS when the document URL is a YouTube URL and YouTube is enabled.
340+
*/
341+
youtubeAssignment?: boolean;
336342
};

0 commit comments

Comments
 (0)