-
Notifications
You must be signed in to change notification settings - Fork 154
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
base: master
Are you sure you want to change the base?
Changes from 3 commits
96efd7d
5036082
df4623a
e04cb25
14c9e8b
7633e5b
802c360
6e0a69f
0dfcb28
895ffce
f5f39bd
95630fd
dac4f1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
import {GitMergeIcon, GitPullRequestIcon, IssueClosedIcon, IssueOpenedIcon} 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I think it caused by this There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 color; | ||
switch (data.type) { | ||
case 'pull': | ||
icon = <GitPullRequestIcon {...iconProps}/>; | ||
|
||
color = '#28a745'; | ||
if (data.state === 'closed') { | ||
if (data.merged) { | ||
color = '#6f42c1'; | ||
icon = <GitMergeIcon {...iconProps}/>; | ||
} else { | ||
color = '#cb2431'; | ||
} | ||
} | ||
|
||
break; | ||
case 'issues': | ||
color = data.state === 'open' ? '#28a745' : '#cb2431'; | ||
Sn-Kinos marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if (data.state === 'open') { | ||
icon = <IssueOpenedIcon {...iconProps}/>; | ||
} else { | ||
icon = <IssueClosedIcon {...iconProps}/>; | ||
} | ||
break; | ||
} | ||
return ( | ||
<span style={{color}}> | ||
{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'> | ||
<span className='pr-2'> | ||
{ getIconElement() } | ||
</span> | ||
{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, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
:root { | ||
--light-gray: #6a737d; | ||
--blue: #274466; | ||
--light-blue: #eff7ff; | ||
} | ||
|
||
@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 .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; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -86,6 +86,7 @@ | |
|
||
/* Labels */ | ||
.github-tooltip .labels { | ||
display: flex; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What effect does this have on the tooltip's styling? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
justify-content: flex-start; | ||
align-items: center; | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export function isUrlCanPreview(url) { | ||
Sn-Kinos marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (url.includes('github.com/')) { | ||
const [owner, repo, type, number] = url.split('github.com/')[1].split('/'); | ||
Sn-Kinos marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return !(!owner | !repo | !type | !number); | ||
Sn-Kinos marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
return false; | ||
} |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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 🥲
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.