Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[GH-730] Add link preview for PR and issue. #779

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
14 changes: 14 additions & 0 deletions webapp/src/components/link_preview/index.jsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn-Kinos We are trying to move away from javascript to now TypeScript. Will it be possible to convert these files to typescript if possible ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to convert it to TS from JS but It's hard because there are no fundamental types requiring domain knowledge (e.g. state type from Mattermost web app) and ESLint didn't works well (e.g. It can't import with relative path from baseUrl) I think It will need more best practices for contributors to convert to TS appropriately. There are only one TS file on webapp 🥲

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are working on providing a template for converting files from js to ts.
cc: @mickmister

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sn-Kinos Can you please try to have the webapp/src/components/link_preview/link_preview.jsx file be typescript in particular?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mickmister Yes I can. But I think I will need some templates for it. I need more informations of types such as the data from Client, state from redux, etc.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {connect} from 'react-redux';

import manifest from 'manifest';

import {LinkPreview} from './link_preview';

const mapStateToProps = (state) => {
return {connected: state[`plugins-${manifest.id}`].connected};
};

export default connect(mapStateToProps, null)(LinkPreview);
184 changes: 184 additions & 0 deletions webapp/src/components/link_preview/link_preview.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {GitMergeIcon, GitPullRequestIcon, IssueClosedIcon, IssueOpenedIcon, SkipIcon} from '@primer/octicons-react';
import PropTypes from 'prop-types';
import React, {useEffect, useState} from 'react';
import ReactMarkdown from 'react-markdown';
import './preview.css';

import Client from 'client';
import {getLabelFontColor} from '../../utils/styles';
import {isUrlCanPreview} from 'src/utils/github_utils';

const maxTicketDescriptionLength = 160;

export const LinkPreview = ({embed: {url}, connected}) => {
Sn-Kinos marked this conversation as resolved.
Show resolved Hide resolved
const [data, setData] = useState(null);
useEffect(() => {
const initData = async () => {
if (isUrlCanPreview(url)) {
const [owner, repo, type, number] = url.split('github.com/')[1].split('/');
Sn-Kinos marked this conversation as resolved.
Show resolved Hide resolved

let res;
switch (type) {
case 'issues':
res = await Client.getIssue(owner, repo, number);
break;
case 'pull':
res = await Client.getPullRequest(owner, repo, number);
break;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll want to inject these as props instead of accessing the client directly in this component

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I wonder what happens here if the user is connected, but doesn't have access to a private repo being linked here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It bypasses this and shows default embed preview like this.
image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand how that works. It shows the fallback because this component returns null in that case?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think it caused by this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant the fact that the webapp handles this gracefully when the component ends up returning null. I don't think you can coordinate React components together like that

}
if (res) {
res.owner = owner;
res.repo = repo;
res.type = type;
}
setData(res);
}
};

// show is not provided for Mattermost Server < 5.28
if (!connected || data) {
return;
}

initData();
}, [connected, data, url]);

const getIconElement = () => {
const iconProps = {
size: 'small',
verticalAlign: 'text-bottom',
};

let icon;
let colorClass;
switch (data.type) {
case 'pull':
icon = <GitPullRequestIcon {...iconProps}/>;

colorClass = 'github-preview-icon-open';
if (data.state === 'closed') {
if (data.merged) {
colorClass = 'github-preview-icon-merged';
icon = <GitMergeIcon {...iconProps}/>;
} else {
colorClass = 'github-preview-icon-closed';
}
}

break;
case 'issues':
if (data.state === 'open') {
colorClass = 'github-preview-icon-open';
icon = <IssueOpenedIcon {...iconProps}/>;
} else if (data.state_reason === 'not_planned') {
colorClass = 'github-preview-icon-not-planned';
icon = <SkipIcon {...iconProps}/>;
} else {
colorClass = 'github-preview-icon-merged';
icon = <IssueClosedIcon {...iconProps}/>;
}
break;
}
return (
<span className={`pr-2 ${colorClass}`}>
{icon}
</span>
);
};

if (data) {
let date = new Date(data.created_at);
date = date.toDateString();
Sn-Kinos marked this conversation as resolved.
Show resolved Hide resolved

let description = '';
if (data.body) {
description = data.body.substring(0, maxTicketDescriptionLength).trim();
if (data.body.length > maxTicketDescriptionLength) {
description += '...';
}
}

return (
<div className='github-preview github-preview--large p-4 mt-1 mb-1'>
<div className='header'>
<span className='repo'>
{data.repo}
</span>
{' on '}
<span>{date}</span>
</div>

<div className='body d-flex'>

{/* info */}
<div className='preview-info mt-1'>
<a
href={url}
target='_blank'
rel='noopener noreferrer'
>
<h5 className='mr-1'>
{ getIconElement() }
{data.title}
</h5>
<span>{'#' + data.number}</span>
</a>
<div className='markdown-text mt-1 mb-1'>
<ReactMarkdown linkTarget='_blank'>{description}</ReactMarkdown>
</div>

<div className='sub-info mt-1'>
{/* base <- head */}
{data.type === 'pull' && (
<div className='sub-info-block'>
<h6 className='mt-0 mb-1'>{'Base ← Head'}</h6>
<div className='base-head'>
<span
title={data.base.ref}
className='commit-ref'
>{data.base.ref}
</span> <span className='mx-1'>{'←'}</span>{' '}
<span
title={data.head.ref}
className='commit-ref'
>{data.head.ref}
</span>
</div>
</div>
)}

{/* Labels */}
{data.labels && data.labels.length > 0 && (
<div className='sub-info-block'>
<h6 className='mt-0 mb-1'>{'Labels'}</h6>
<div className='labels'>
{data.labels.map((label, idx) => {
return (
<span
key={`${label.name}-${idx}`}
className='label'
title={label.description}
style={{backgroundColor: '#' + label.color, color: getLabelFontColor(label.color)}}
>
<span>{label.name}</span>
</span>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
return null;
};

LinkPreview.propTypes = {
embed: {
url: PropTypes.string.isRequired,
},
connected: PropTypes.bool.isRequired,
};
156 changes: 156 additions & 0 deletions webapp/src/components/link_preview/preview.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
:root {
--light-gray: #6a737d;
--blue: #274466;
--light-blue: #eff7ff;
--github-merged: #6f42c1;
--github-closed: #cb2431;
--github-open: #28a745;
--github-not-planned: #6e7681
}
Sn-Kinos marked this conversation as resolved.
Show resolved Hide resolved

@media (min-width: 544px) {
.github-preview--large {
min-width: 320px;
}
}

/* Github Preview */
.github-preview {
Sn-Kinos marked this conversation as resolved.
Show resolved Hide resolved
background-color: var(--center-channel-bg-rgb);
box-shadow: 0 2px 3px rgba(0,0,0,.08);
position: relative;
width: 100%;
max-width: 700px;
border-radius: 4px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
}

/* Header */
.github-preview .header {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 11px;
line-height: 16px;
white-space: nowrap;
}

.github-preview .header a {
text-decoration: none;
color: rgba(var(--center-channel-color-rgb), 0.64);
display: inline-block;
}

.github-preview .header .repo {
color: var(--center-channel-color-rgb);
}

/* Body */
.github-preview .body > span {
line-height: 1.25;
}

/* Info */
.github-preview .preview-info {
line-height: 1.25;
display: flex;
flex-direction: column;
}
.github-preview .preview-info > a, .github-preview .preview-info > a:hover {
Sn-Kinos marked this conversation as resolved.
Show resolved Hide resolved
display: block;
text-decoration: none;
color: var(--link-color);
}
.github-preview .preview-info > a span {
color: var(--light-gray);
}

.github-preview .preview-info h5 {
font-weight: 600;
font-size: 14px;
display: inline;
}

.github-preview .preview-info h5 span.github-preview-icon-opened {
color: var(--github-open);
}

.github-preview .preview-info h5 span.github-preview-icon-closed {
color: var(--github-closed);
}

.github-preview .preview-info h5 span.github-preview-icon-merged {
color: var(--github-merged);
}

.github-preview .preview-info h5 span.github-preview-icon-not-planned {
color: var(--github-not-planned);
}

.github-preview .preview-info .markdown-text {
max-height: 150px;
line-height: 1.25;
overflow: hidden;
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 12px;
}

.github-preview .preview-info .markdown-text::-webkit-scrollbar {
display: none;
}

.github-preview .sub-info {
display: flex;
width: 100%;
flex-wrap: wrap;
gap: 4px 0;
}

.github-preview .sub-info .sub-info-block {
display: flex;
flex-direction: column;
width: 50%;
}

/* Labels */
.github-preview .labels {
display: flex;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}

.github-preview .label {
height: 20px;
padding: .15em 4px;
font-size: 12px;
font-weight: 600;
line-height: 15px;
border-radius: 2px;
box-shadow: inset 0 -1px 0 rgba(27,31,35,.12);
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
max-width: 125px;
}

.github-preview .base-head {
display: flex;
line-height: 1;
align-items: center;
}

.github-preview .commit-ref {
position: relative;
display: inline-block;
padding: 0 5px;
font: .75em/2 SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;
color: var(--blue);
background-color: var(--light-blue);
border-radius: 3px;
max-width: 140px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
6 changes: 2 additions & 4 deletions webapp/src/components/link_tooltip/link_tooltip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,16 @@ import ReactMarkdown from 'react-markdown';

import Client from 'client';
import {getLabelFontColor, hexToRGB} from '../../utils/styles';
import {isUrlCanPreview} from 'src/utils/github_utils';

const maxTicketDescriptionLength = 160;

export const LinkTooltip = ({href, connected, show, theme}) => {
const [data, setData] = useState(null);
useEffect(() => {
const initData = async () => {
if (href.includes('github.com/')) {
if (isUrlCanPreview(href)) {
const [owner, repo, type, number] = href.split('github.com/')[1].split('/');
if (!owner | !repo | !type | !number) {
return;
}

let res;
switch (type) {
Expand Down
1 change: 1 addition & 0 deletions webapp/src/components/link_tooltip/tooltip.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@

/* Labels */
.github-tooltip .labels {
display: flex;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What effect does this have on the tooltip's styling?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.labels is not flex element so the properties below doesn't working. So I added it and flex works.

Before After
image image
.labels = height: 23px; (auto) display: block;
( flex properties not working )
.labels = height: 20px; (auto) display: flex;
( flex properties working )
.labels > * = height: 20px; .labels > * = height: 20px;

justify-content: flex-start;
align-items: center;
}
Expand Down
Loading
Loading