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

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

@@ -7,8 +7,6 @@ import {Provider} from 'react-redux';
import type {DeepPartial} from '@mattermost/types/utilities'; import type {DeepPartial} from '@mattermost/types/utilities';
import ExternalImage from 'components/external_image';
import mockStore from 'tests/test_store'; import mockStore from 'tests/test_store';
import type {GlobalState} from 'types/store'; import type {GlobalState} from 'types/store';
@@ -56,8 +54,10 @@ describe('YoutubeVideo', () => {
</Provider>, </Provider>,
); );
expect(wrapper).toMatchSnapshot(); 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', () => { test('should match snapshot for playing state', () => {
@@ -75,6 +75,12 @@ describe('YoutubeVideo', () => {
); );
wrapper.setState({playing: true}); wrapper.setState({playing: true});
expect(wrapper).toMatchSnapshot(); 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', () => { test('should use url if secure_url is not present', () => {
@@ -89,6 +95,26 @@ describe('YoutubeVideo', () => {
}; };
const wrapper = shallow(<YoutubeVideo {...props}/>); 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. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import {useIntl} from 'react-intl';
import type {OpenGraphMetadata} from '@mattermost/types/posts'; import type {OpenGraphMetadata} from '@mattermost/types/posts';
@@ -20,24 +21,113 @@ type Props = {
type State = { type State = {
playing: boolean; 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> { export default class YoutubeVideo extends React.PureComponent<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
playing: false, 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) { 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 = () => { play = () => {
this.setState({playing: true}); this.setState({playing: true});
}; };
@@ -48,11 +138,12 @@ export default class YoutubeVideo extends React.PureComponent<Props, State> {
render() { render() {
const {metadata, link} = this.props; const {metadata, link} = this.props;
const videoId = getVideoId(link); const videoId = getVideoId(link);
const videoTitle = metadata?.title || 'unknown'; const videoTitle = metadata?.title || 'unknown';
const time = handleYoutubeTime(link); const time = handleYoutubeTime(link);
const thumbnailUrl = this.state.useMaxResThumbnail ? this.getMaxResUrl(link) : this.getHQUrl(link);
const header = ( const header = (
<h4> <h4>
<span className='video-type'>{'YouTube - '}</span> <span className='video-type'>{'YouTube - '}</span>
@@ -71,51 +162,37 @@ export default class YoutubeVideo extends React.PureComponent<Props, State> {
if (this.state.playing) { if (this.state.playing) {
content = ( content = (
<iframe <div className='video-playing'>
src={'https://www.youtube.com/embed/' + videoId + '?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1' + time} <iframe
width='480px' src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0&fs=1&enablejsapi=1${time}`}
height='360px' title={videoTitle}
frameBorder='0' width='100%'
allowFullScreen={true} height='100%'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share' frameBorder='0'
referrerPolicy={this.props.youtubeReferrerPolicy ? 'strict-origin-when-cross-origin' : 'no-referrer'} allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
title={videoTitle} allowFullScreen={true}
sandbox='allow-scripts allow-same-origin allow-popups allow-presentation' referrerPolicy={this.props.youtubeReferrerPolicy ? 'origin' : undefined}
/> sandbox='allow-scripts allow-same-origin allow-popups allow-presentation'
/>
</div>
); );
} else { } else {
const image = metadata?.images[0];
content = ( content = (
<div className='embed-responsive video-div__placeholder'> <YouTubeThumbnail
<div className='video-thumbnail__container'> play={this.play}
<ExternalImage src={image?.secure_url || image?.url || ''}> videoTitle={videoTitle}
{(safeUrl) => ( onError={this.handleImageError}
<img thumbnailUrl={thumbnailUrl}
src={safeUrl} useMaxResThumbnail={this.state.useMaxResThumbnail}
alt='youtube video thumbnail' />
className='video-thumbnail'
/>
)}
</ExternalImage>
<div className='block'>
<span className='play-button'><span/></span>
</div>
</div>
</div>
); );
} }
return ( return (
<div <div className='post__embed-container'>
className='post__embed-container'
>
<div> <div>
{header} {header}
<div <div className='video-div embed-responsive-item'>
className='video-div embed-responsive-item'
onClick={this.play}
>
{content} {content}
</div> </div>
</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)); return Boolean(link.trim().match(ytRegex));
} }
} }

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

@@ -6449,5 +6449,8 @@
"workspace_limits.teams_limit_reached.view_upgrade_options": "View upgrade options", "workspace_limits.teams_limit_reached.view_upgrade_options": "View upgrade options",
"workspace_limits.upgrade": "Upgrade to avoid {planName} data limits", "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.", "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/functions";
@use "utils/variables"; @use "utils/variables";
@use "utils/mixins";
.post { .post {
// Container for both video and thumbnail
.video-div { .video-div {
position: relative; position: relative;
overflow: hidden; overflow: hidden; // Ensure hover effects stay contained
width: 100%;
max-width: 480px; max-width: 480px;
height: 360px; max-height: 270px;
border-radius: 4px;
margin-bottom: 8px; margin-bottom: 8px;
iframe, iframe {
video {
display: block;
max-width: 100%;
}
.video-thumbnail {
max-width: 100%;
height: auto;
cursor: pointer;
}
.block {
position: absolute; position: absolute;
top: 50%; top: 0;
left: 50%; left: 0;
width: 200px; display: block;
height: 150px; overflow: hidden;
border-radius: 10px; width: 100%;
margin: -75px 0 0 -100px; height: 100%;
background-color: functions.alpha-color(variables.$black, 0.5); 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 { .video-type {
padding: 0; padding: 0;
margin: 0; margin: 0;
font-size: 15px; font-size: 14px;
opacity: 0.8;
} }
.video-title { .video-title {
@@ -46,28 +119,6 @@
font-size: 15px; 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 { .img-div {
position: relative; position: relative;
max-width: 480px; max-width: 480px;
@@ -93,11 +144,4 @@
.video-loading { .video-loading {
height: 368px; height: 368px;
} }
.video-div__placeholder {
display: flex;
height: 360px;
align-items: center;
background-color: black;
}
} }