MM-64483 YouTube Preview UI Update (#31288)

* fix youtube thumbnails

* fix lint issues, update snapshots

* Update _videos.scss

* Update _videos.scss

* revert change to 'YouTubeVideo' to 'YoutubeVideo'

* switch back to Youtube instead of YouTube

* accessibility fixes -add focus state

* fix lint issues

* Update youtube_video.test.tsx.snap

* remove logic for delaying different aspect ratios. always use 16:9

* Update _videos.scss

* fix rounded corners

* use css variables for radius

* restore attributes from original, remove bottom margin from player to prevent vertical shift

* fix issue with margin and nested 'video-div' elements

* update class to 'video-playing'

* fix linter issues

* address copilot review feedback and remove unused css

* Update youtube_video.test.tsx

* fixed failing test

* lint issue fix

* Update youtube_video.test.tsx

* review feedback changes

* Update _videos.scss

* update test and snapshot

* fix lint issues

* add scaling button on hover state back in

* feat: Make YouTube video component strings translatable

- Add useIntl import and formatMessage usage
- Create YouTubePrefix component for internationalized prefix
- Update YouTubeThumbnail component to use translatable strings
- Add translation IDs:
  - youtube_video.play.aria_label
  - youtube_video.thumbnail.alt_text  
  - youtube_video.play_button.aria_label
  - youtube_video.type

Addresses reviewer feedback to make hardcoded strings translatable
using React Intl's useIntl hook.

Co-authored-by: Matthew Birtch <matthewbirtch@users.noreply.github.com>

* fix lint issues

* Update en.json

* Update youtube_video.test.tsx.snap

* remove youtube prefix

* fix lint issues

* Update youtube_video.test.tsx.snap

* fix lint issue

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Matthew Birtch <matthewbirtch@users.noreply.github.com>
Этот коммит содержится в:
Matthew Birtch
2025-07-09 10:33:47 -04:00
коммит произвёл GitHub
родитель 47a7e62b44
Коммит b42f4fbffd
6 изменённых файлов: 307 добавлений и 142 удалений

Просмотреть файл

@@ -37,7 +37,7 @@ describe('YouTube Video', () => {
cy.get('.play-button', {timeout: TIMEOUTS.TEN_SEC}).click();
// * Video should be loaded in the iframe
cy.get('.video-div > iframe').should('exist');
cy.get('.video-playing iframe').should('exist');
// # Collapse video
cy.get('.post__embed-visibility').click();
@@ -52,7 +52,7 @@ describe('YouTube Video', () => {
cy.get('.play-button').should('exist');
// * Video should not be played in the iframe
cy.get('.video-div > iframe').should('not.exist');
cy.get('.video-playing iframe').should('not.exist');
});
});
});

Просмотреть файл

@@ -63,41 +63,51 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
</h4>
<div
className="video-div embed-responsive-item"
onClick={[Function]}
>
<div
className="embed-responsive video-div__placeholder"
<YouTubeThumbnail
onError={[Function]}
play={[Function]}
thumbnailUrl="https://img.youtube.com/vi/xqCoNej8Zxo/maxresdefault.jpg"
useMaxResThumbnail={true}
videoTitle="Youtube title"
>
<div
aria-label="Play Youtube title on YouTube"
className="video-thumbnail__container"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
<Connect(Component)
src="linkForThumbnail"
src="https://img.youtube.com/vi/xqCoNej8Zxo/maxresdefault.jpg"
>
<Memo(ExternalImage)
dispatch={[Function]}
enableSVGs={false}
hasImageProxy={false}
src="linkForThumbnail"
src="https://img.youtube.com/vi/xqCoNej8Zxo/maxresdefault.jpg"
>
<img
alt="youtube video thumbnail"
alt="Thumbnail for Youtube title on YouTube"
className="video-thumbnail"
src="linkForThumbnail"
onError={[Function]}
src="https://img.youtube.com/vi/xqCoNej8Zxo/maxresdefault.jpg"
/>
</Memo(ExternalImage)>
</Connect(Component)>
<div
className="block"
aria-label="Play video"
className="play-button"
role="presentation"
>
<span
className="play-button"
>
<span />
</span>
<i
aria-hidden="true"
className="icon-play"
/>
</div>
</div>
</div>
</YouTubeThumbnail>
</div>
</div>
</div>
@@ -129,19 +139,21 @@ exports[`YoutubeVideo should match snapshot for playing state 1`] = `
</h4>
<div
className="video-div embed-responsive-item"
onClick={[Function]}
>
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
frameBorder="0"
height="360px"
referrerPolicy="no-referrer"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
src="https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1"
title="Youtube title"
width="480px"
/>
<div
className="video-playing"
>
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
frameBorder="0"
height="100%"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
src="https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&rel=0&fs=1&enablejsapi=1"
title="Youtube title"
width="100%"
/>
</div>
</div>
</div>
</div>
@@ -171,19 +183,22 @@ exports[`YoutubeVideo should match snapshot for playing state and \`youtubeRefer
</h4>
<div
className="video-div embed-responsive-item"
onClick={[Function]}
>
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
frameBorder="0"
height="360px"
referrerPolicy="strict-origin-when-cross-origin"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
src="https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1"
title="Youtube title"
width="480px"
/>
<div
className="video-playing"
>
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
frameBorder="0"
height="100%"
referrerPolicy="origin"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
src="https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&rel=0&fs=1&enablejsapi=1"
title="Youtube title"
width="100%"
/>
</div>
</div>
</div>
</div>

Просмотреть файл

@@ -7,8 +7,6 @@ import {Provider} from 'react-redux';
import type {DeepPartial} from '@mattermost/types/utilities';
import ExternalImage from 'components/external_image';
import mockStore from 'tests/test_store';
import type {GlobalState} from 'types/store';
@@ -56,8 +54,10 @@ describe('YoutubeVideo', () => {
</Provider>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find(ExternalImage).prop('src')).toEqual('linkForThumbnail');
expect(wrapper.find('a').text()).toEqual('Youtube title');
// Verify that useMaxResThumbnail is true by default
expect((wrapper.find('YoutubeVideo').instance() as YoutubeVideo).state.useMaxResThumbnail).toBe(true);
expect(wrapper.find('h4').text()).toEqual('YouTube - Youtube title');
});
test('should match snapshot for playing state', () => {
@@ -75,6 +75,12 @@ describe('YoutubeVideo', () => {
);
wrapper.setState({playing: true});
expect(wrapper).toMatchSnapshot();
// Verify that the iframe has a referrerPolicy attribute (set to 'origin') when youtubeReferrerPolicy is true.
expect(wrapper.find('.video-playing iframe').prop('referrerPolicy')).toEqual('origin');
// Verify that the iframe src includes the new parameters
expect(wrapper.find('.video-playing iframe').prop('src')).toEqual('https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&rel=0&fs=1&enablejsapi=1');
});
test('should use url if secure_url is not present', () => {
@@ -89,6 +95,26 @@ describe('YoutubeVideo', () => {
};
const wrapper = shallow(<YoutubeVideo {...props}/>);
expect(wrapper.find(ExternalImage).prop('src')).toEqual('linkUrl');
// Verify that useMaxResThumbnail is true by default
expect(wrapper.state('useMaxResThumbnail')).toBe(true);
});
describe('thumbnail fallback', () => {
it('should fallback to hqdefault.jpg on image error', () => {
const wrapper = shallow(<YoutubeVideo {...baseProps}/>);
// Simulate an image error by calling handleImageError.
(wrapper.instance() as YoutubeVideo).handleImageError();
// Verify that useMaxResThumbnail is now false (will use hqdefault.jpg).
expect(wrapper.state('useMaxResThumbnail')).toBe(false);
});
});
it('should initialize with useMaxResThumbnail set to true', () => {
const wrapper = shallow(<YoutubeVideo {...baseProps}/>);
// Verify that the component initializes with useMaxResThumbnail = true
expect(wrapper.state('useMaxResThumbnail')).toBe(true);
});
});

Просмотреть файл

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {OpenGraphMetadata} from '@mattermost/types/posts';
@@ -20,24 +21,113 @@ type Props = {
type State = {
playing: boolean;
useMaxResThumbnail: boolean;
prevLink: string;
}
type YouTubeThumbnailProps = {
play: () => void;
videoTitle: string;
onError: () => void;
thumbnailUrl: string;
useMaxResThumbnail: boolean;
};
function YouTubeThumbnail({play, videoTitle, onError, thumbnailUrl}: YouTubeThumbnailProps) {
const {formatMessage} = useIntl();
return (
<div
className='video-thumbnail__container'
onClick={play}
role='button'
aria-label={formatMessage({
id: 'youtube_video.play.aria_label',
defaultMessage: 'Play {videoTitle} on YouTube',
}, {
videoTitle,
})}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
play();
}
}}
>
<ExternalImage src={thumbnailUrl}>
{(src) => (
<img
className='video-thumbnail'
src={src}
alt={formatMessage({
id: 'youtube_video.thumbnail.alt_text',
defaultMessage: 'Thumbnail for {videoTitle} on YouTube',
}, {
videoTitle,
})}
onError={onError}
/>
)}
</ExternalImage>
<div
className='play-button'
role='presentation'
aria-label={formatMessage({
id: 'youtube_video.play_button.aria_label',
defaultMessage: 'Play video',
})}
>
<i
className='icon-play'
aria-hidden='true'
/>
</div>
</div>
);
}
export default class YoutubeVideo extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
playing: false,
useMaxResThumbnail: true,
prevLink: props.link,
};
}
static getDerivedStateFromProps(props: Props, state: State): State | null {
static getDerivedStateFromProps(props: Props, state: State): Partial<State> | null {
const nextState: Partial<State> = {};
if (!props.show && state.playing) {
return {playing: false};
nextState.playing = false;
}
return null;
if (props.link !== state.prevLink) {
nextState.useMaxResThumbnail = true;
nextState.prevLink = props.link;
}
return Object.keys(nextState).length > 0 ? nextState : null;
}
getMaxResUrl(link: string) {
const videoId = getVideoId(link);
return `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
}
getHQUrl(link: string) {
const videoId = getVideoId(link);
return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
}
handleImageError = () => {
this.setState({
useMaxResThumbnail: false,
});
};
play = () => {
this.setState({playing: true});
};
@@ -48,11 +138,12 @@ export default class YoutubeVideo extends React.PureComponent<Props, State> {
render() {
const {metadata, link} = this.props;
const videoId = getVideoId(link);
const videoTitle = metadata?.title || 'unknown';
const time = handleYoutubeTime(link);
const thumbnailUrl = this.state.useMaxResThumbnail ? this.getMaxResUrl(link) : this.getHQUrl(link);
const header = (
<h4>
<span className='video-type'>{'YouTube - '}</span>
@@ -71,51 +162,37 @@ export default class YoutubeVideo extends React.PureComponent<Props, State> {
if (this.state.playing) {
content = (
<iframe
src={'https://www.youtube.com/embed/' + videoId + '?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1' + time}
width='480px'
height='360px'
frameBorder='0'
allowFullScreen={true}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
referrerPolicy={this.props.youtubeReferrerPolicy ? 'strict-origin-when-cross-origin' : 'no-referrer'}
title={videoTitle}
sandbox='allow-scripts allow-same-origin allow-popups allow-presentation'
/>
<div className='video-playing'>
<iframe
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0&fs=1&enablejsapi=1${time}`}
title={videoTitle}
width='100%'
height='100%'
frameBorder='0'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
allowFullScreen={true}
referrerPolicy={this.props.youtubeReferrerPolicy ? 'origin' : undefined}
sandbox='allow-scripts allow-same-origin allow-popups allow-presentation'
/>
</div>
);
} else {
const image = metadata?.images[0];
content = (
<div className='embed-responsive video-div__placeholder'>
<div className='video-thumbnail__container'>
<ExternalImage src={image?.secure_url || image?.url || ''}>
{(safeUrl) => (
<img
src={safeUrl}
alt='youtube video thumbnail'
className='video-thumbnail'
/>
)}
</ExternalImage>
<div className='block'>
<span className='play-button'><span/></span>
</div>
</div>
</div>
<YouTubeThumbnail
play={this.play}
videoTitle={videoTitle}
onError={this.handleImageError}
thumbnailUrl={thumbnailUrl}
useMaxResThumbnail={this.state.useMaxResThumbnail}
/>
);
}
return (
<div
className='post__embed-container'
>
<div className='post__embed-container'>
<div>
{header}
<div
className='video-div embed-responsive-item'
onClick={this.play}
>
<div className='video-div embed-responsive-item'>
{content}
</div>
</div>
@@ -123,7 +200,7 @@ export default class YoutubeVideo extends React.PureComponent<Props, State> {
);
}
public static isYoutubeLink(link: string): boolean {
static isYoutubeLink(link: string): boolean {
return Boolean(link.trim().match(ytRegex));
}
}

Просмотреть файл

@@ -6449,5 +6449,8 @@
"workspace_limits.teams_limit_reached.view_upgrade_options": "View upgrade options",
"workspace_limits.upgrade": "Upgrade to avoid {planName} data limits",
"workspace_limits.upgrade_reasons.free": "{planName} is restricted to {messagesLimit} message history and {storageLimit} file storage. You can delete items to free up space or upgrade to a paid plan.",
"yourcomputer": "Your computer"
"yourcomputer": "Your computer",
"youtube_video.play_button.aria_label": "Play video",
"youtube_video.play.aria_label": "Play {videoTitle} on YouTube",
"youtube_video.thumbnail.alt_text": "Thumbnail for {videoTitle} on YouTube"
}

Просмотреть файл

@@ -1,44 +1,117 @@
@use "utils/functions";
@use "utils/variables";
@use "utils/mixins";
.post {
// Container for both video and thumbnail
.video-div {
position: relative;
overflow: hidden;
overflow: hidden; // Ensure hover effects stay contained
width: 100%;
max-width: 480px;
height: 360px;
border-radius: 4px;
max-height: 270px;
margin-bottom: 8px;
iframe,
video {
display: block;
max-width: 100%;
}
.video-thumbnail {
max-width: 100%;
height: auto;
cursor: pointer;
}
.block {
iframe {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 150px;
border-radius: 10px;
margin: -75px 0 0 -100px;
background-color: functions.alpha-color(variables.$black, 0.5);
top: 0;
left: 0;
display: block;
overflow: hidden;
width: 100%;
height: 100%;
border: none;
border-radius: var(--radius-s);
aspect-ratio: 16/9; // Always use 16:9
background-color: #000;
}
// Container for thumbnails
.video-thumbnail__container {
position: relative;
overflow: hidden;
width: 100%;
border-radius: var(--radius-s);
aspect-ratio: 16/9; // Always use 16:9
background-color: #000;
outline: none; // Remove default focus outline
// Use existing button focus mixin
@include mixins.button-focus;
// Darkening overlay on hover
&::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
content: '';
pointer-events: none;
transition: background-color 0.2s ease;
}
&:hover,
&:focus-visible {
&::after {
background-color: rgba(0, 0, 0, 0.16);
}
.play-button {
background-color: #FF0033; //youtube red
transform: scale(1.08);
}
}
}
// Playing state container
.video-playing {
position: relative;
overflow: hidden;
width: 100%;
aspect-ratio: 16/9; // Always use 16:9
}
// Thumbnail image
.video-thumbnail {
width: 100%;
height: 100%;
cursor: pointer;
object-fit: cover;
}
// Play button container
.play-button {
position: absolute;
top: calc(50% - 28px);
left: calc(50% - 40px);
display: flex;
width: 80px;
height: 56px;
align-items: center;
justify-content: center;
border-radius: var(--radius-m);
background-color: rgba(0, 0, 0, 0.9);
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
i.icon-play {
position: relative;
top: -1px;
left: -1px;
color: #fff;
font-size: 48px;
line-height: 1;
}
}
}
// Video title and type
.video-type {
padding: 0;
margin: 0;
font-size: 15px;
opacity: 0.8;
font-size: 14px;
}
.video-title {
@@ -46,28 +119,6 @@
font-size: 15px;
}
.play-button {
position: absolute;
top: 26px;
right: 51px;
width: 100px;
height: 100px;
border: 4px solid functions.alpha-color(variables.$white, 0.4);
border-radius: 14px;
cursor: pointer;
span {
position: absolute;
top: 10px;
left: 20px;
width: 0;
height: 0;
border-top: 36px solid transparent;
border-bottom: 36px solid transparent;
border-left: 60px solid functions.alpha-color(variables.$white, 0.4);
}
}
.img-div {
position: relative;
max-width: 480px;
@@ -93,11 +144,4 @@
.video-loading {
height: 368px;
}
.video-div__placeholder {
display: flex;
height: 360px;
align-items: center;
background-color: black;
}
}