diff --git a/e2e-tests/cypress/tests/integration/channels/files_and_attachments/youtube_video_spec.js b/e2e-tests/cypress/tests/integration/channels/files_and_attachments/youtube_video_spec.js
index 58aa8eb086..db67be9600 100644
--- a/e2e-tests/cypress/tests/integration/channels/files_and_attachments/youtube_video_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/files_and_attachments/youtube_video_spec.js
@@ -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');
});
});
});
diff --git a/webapp/channels/src/components/youtube_video/__snapshots__/youtube_video.test.tsx.snap b/webapp/channels/src/components/youtube_video/__snapshots__/youtube_video.test.tsx.snap
index 4e5f490148..6f62daa7ae 100644
--- a/webapp/channels/src/components/youtube_video/__snapshots__/youtube_video.test.tsx.snap
+++ b/webapp/channels/src/components/youtube_video/__snapshots__/youtube_video.test.tsx.snap
@@ -63,41 +63,51 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
-
-
-
-
+
-
+
@@ -129,19 +139,21 @@ exports[`YoutubeVideo should match snapshot for playing state 1`] = `
@@ -171,19 +183,22 @@ exports[`YoutubeVideo should match snapshot for playing state and \`youtubeRefer
diff --git a/webapp/channels/src/components/youtube_video/youtube_video.test.tsx b/webapp/channels/src/components/youtube_video/youtube_video.test.tsx
index fa63f498bb..f9ad2c793a 100644
--- a/webapp/channels/src/components/youtube_video/youtube_video.test.tsx
+++ b/webapp/channels/src/components/youtube_video/youtube_video.test.tsx
@@ -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', () => {
,
);
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();
- 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();
+
+ // 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();
+
+ // Verify that the component initializes with useMaxResThumbnail = true
+ expect(wrapper.state('useMaxResThumbnail')).toBe(true);
});
});
diff --git a/webapp/channels/src/components/youtube_video/youtube_video.tsx b/webapp/channels/src/components/youtube_video/youtube_video.tsx
index cfc9176adc..686c0d59cd 100644
--- a/webapp/channels/src/components/youtube_video/youtube_video.tsx
+++ b/webapp/channels/src/components/youtube_video/youtube_video.tsx
@@ -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 (
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ play();
+ }
+ }}
+ >
+
+ {(src) => (
+
+ )}
+
+
+
+
+
+ );
}
export default class YoutubeVideo extends React.PureComponent {
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 | null {
+ const nextState: Partial = {};
+
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 {
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 = (
{'YouTube - '}
@@ -71,51 +162,37 @@ export default class YoutubeVideo extends React.PureComponent {
if (this.state.playing) {
content = (
-
+
+
+
);
} else {
- const image = metadata?.images[0];
-
content = (
-
-
-
- {(safeUrl) => (
-
- )}
-
-
-
-
-
-
+
);
}
return (
-
+
{header}
-
@@ -123,7 +200,7 @@ export default class YoutubeVideo extends React.PureComponent
{
);
}
- public static isYoutubeLink(link: string): boolean {
+ static isYoutubeLink(link: string): boolean {
return Boolean(link.trim().match(ytRegex));
}
}
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index f2f79a0184..3f68dc3e78 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -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"
}
diff --git a/webapp/channels/src/sass/components/_videos.scss b/webapp/channels/src/sass/components/_videos.scss
index 64a299784d..7e6ecb1b1e 100644
--- a/webapp/channels/src/sass/components/_videos.scss
+++ b/webapp/channels/src/sass/components/_videos.scss
@@ -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;
- }
}