diff --git a/webapp/src/components/link_embed_preview/embed_preview.scss b/webapp/src/components/link_embed_preview/embed_preview.scss new file mode 100644 index 000000000..b9d8c329d --- /dev/null +++ b/webapp/src/components/link_embed_preview/embed_preview.scss @@ -0,0 +1,157 @@ +$light-gray: #6a737d; +$light-blue: #eff7ff; +$github-merged: #6f42c1; +$github-closed: #cb2431; +$github-open: #28a745; +$github-not-planned: #6e7681; + +@media (min-width: 544px) { + .github-preview--large { + min-width: 320px; + } +} + +/* Github Preview */ +.github-preview { + 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 */ + .header { + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 11px; + line-height: 16px; + white-space: nowrap; + + a { + text-decoration: none; + color: rgba(var(--center-channel-color-rgb), 0.64); + display: inline-block; + + .repo { + color: var(--center-channel-color-rgb); + } + } + } + + /* Body */ + .body > span { + line-height: 1.25; + } + + /* Info */ + .preview-info { + line-height: 1.25; + display: flex; + flex-direction: column; + + > a, + > a:hover { + display: block; + text-decoration: none; + color: var(--link-color); + } + + > a span { + color: $light-gray; + + h5 { + font-weight: 600; + font-size: 14px; + display: inline; + + span.github-preview-icon-opened { + color: $github-open; + } + + span.github-preview-icon-closed { + color: $github-closed; + } + + span.github-preview-icon-merged { + color: $github-merged; + } + + span.github-preview-icon-not-planned { + color: $github-not-planned; + } + } + + .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; + + &::-webkit-scrollbar { + display: none; + } + } + } + } + + .sub-info { + display: flex; + width: 100%; + flex-wrap: wrap; + gap: 4px 0; + + .sub-info-block { + display: flex; + flex-direction: column; + width: 50%; + } + } + + /* Labels */ + .labels { + display: flex; + justify-content: flex-start; + align-items: center; + flex-wrap: wrap; + gap: 4px; + } + + .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; + } + + .base-head { + display: flex; + line-height: 1; + align-items: center; + } + + .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: $light-blue; + border-radius: 3px; + max-width: 140px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + +} \ No newline at end of file diff --git a/webapp/src/components/link_embed_preview/index.jsx b/webapp/src/components/link_embed_preview/index.jsx new file mode 100644 index 000000000..309dd980e --- /dev/null +++ b/webapp/src/components/link_embed_preview/index.jsx @@ -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 {LinkEmbedPreview} from './link_embed_preview'; + +const mapStateToProps = (state) => { + return {connected: state[`plugins-${manifest.id}`].connected}; +}; + +export default connect(mapStateToProps, null)(LinkEmbedPreview); diff --git a/webapp/src/components/link_embed_preview/link_embed_preview.jsx b/webapp/src/components/link_embed_preview/link_embed_preview.jsx new file mode 100644 index 000000000..5e9c2a8de --- /dev/null +++ b/webapp/src/components/link_embed_preview/link_embed_preview.jsx @@ -0,0 +1,183 @@ +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 './embed_preview.scss'; + +import Client from 'client'; +import {getLabelFontColor} from '../../utils/styles'; +import {isUrlCanPreview} from '../../utils/github_utils'; + +const maxTicketDescriptionLength = 160; + +export const LinkEmbedPreview = ({embed: {url}, connected}) => { + const [data, setData] = useState(null); + useEffect(() => { + const initData = async () => { + if (isUrlCanPreview(url)) { + const [owner, repo, type, number] = url.split('github.com/')[1].split('/'); + + let res; + switch (type) { + case 'issues': + res = await Client.getIssue(owner, repo, number); + break; + case 'pull': + res = await Client.getPullRequest(owner, repo, number); + break; + } + if (res) { + res.owner = owner; + res.repo = repo; + res.type = type; + } + setData(res); + } + }; + + 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 = ; + + colorClass = 'github-preview-icon-open'; + if (data.state === 'closed') { + if (data.merged) { + colorClass = 'github-preview-icon-merged'; + icon = ; + } else { + colorClass = 'github-preview-icon-closed'; + } + } + + break; + case 'issues': + if (data.state === 'open') { + colorClass = 'github-preview-icon-open'; + icon = ; + } else if (data.state_reason === 'not_planned') { + colorClass = 'github-preview-icon-not-planned'; + icon = ; + } else { + colorClass = 'github-preview-icon-merged'; + icon = ; + } + break; + } + return ( + + {icon} + + ); + }; + + if (!data) { + return null; + } + let date = new Date(data.created_at); + date = date.toDateString(); + + let description = ''; + if (data.body) { + description = data.body.substring(0, maxTicketDescriptionLength).trim(); + if (data.body.length > maxTicketDescriptionLength) { + description += '...'; + } + } + + return ( +
+
+ + {data.repo} + + {' on '} + {date} +
+ +
+ + {/* info */} +
+ +
+ { getIconElement() } + {data.title} +
+ {'#' + data.number} +
+
+ {description} +
+ +
+ {/* base <- head */} + {data.type === 'pull' && ( +
+
{'Base ← Head'}
+
+ {data.base.ref} + {'←'}{' '} + {data.head.ref} + +
+
+ )} + + {/* Labels */} + {data.labels && data.labels.length > 0 && ( +
+
{'Labels'}
+
+ {data.labels.map((label, idx) => { + return ( + + {label.name} + + ); + })} +
+
+ )} +
+
+
+
+ ); +}; + +LinkEmbedPreview.propTypes = { + embed: { + url: PropTypes.string.isRequired, + }, + connected: PropTypes.bool.isRequired, +}; diff --git a/webapp/src/components/link_tooltip/link_tooltip.jsx b/webapp/src/components/link_tooltip/link_tooltip.jsx index 806f79c45..d0958fb58 100644 --- a/webapp/src/components/link_tooltip/link_tooltip.jsx +++ b/webapp/src/components/link_tooltip/link_tooltip.jsx @@ -6,6 +6,7 @@ import ReactMarkdown from 'react-markdown'; import Client from 'client'; import {getLabelFontColor, hexToRGB} from '../../utils/styles'; +import {isUrlCanPreview} from '../../utils/github_utils'; const maxTicketDescriptionLength = 160; @@ -13,11 +14,8 @@ 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) { diff --git a/webapp/src/components/link_tooltip/tooltip.css b/webapp/src/components/link_tooltip/tooltip.css index 2c1475bff..dca33aff7 100644 --- a/webapp/src/components/link_tooltip/tooltip.css +++ b/webapp/src/components/link_tooltip/tooltip.css @@ -86,6 +86,7 @@ /* Labels */ .github-tooltip .labels { + display: flex; justify-content: flex-start; align-items: center; } diff --git a/webapp/src/index.js b/webapp/src/index.js index 4b0a50d2b..fbee5e38a 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -2,6 +2,7 @@ // See License.txt for license information. import AttachCommentToIssuePostMenuAction from 'components/post_menu_actions/attach_comment_to_issue'; import AttachCommentToIssueModal from 'components/modals/attach_comment_to_issue'; +import {isUrlCanPreview} from 'utils/github_utils'; import CreateIssueModal from './components/modals/create_issue'; import CreateIssuePostMenuAction from './components/post_menu_action/create_issue'; @@ -10,6 +11,7 @@ import TeamSidebar from './components/team_sidebar'; import UserAttribute from './components/user_attribute'; import SidebarRight from './components/sidebar_right'; import LinkTooltip from './components/link_tooltip'; +import LinkEmbedPreview from './components/link_embed_preview'; import Reducer from './reducers'; import Client from './client'; import {getConnected, setShowRHSAction} from './actions'; @@ -37,6 +39,7 @@ class PluginClass { registry.registerRootComponent(AttachCommentToIssueModal); registry.registerPostDropdownMenuComponent(AttachCommentToIssuePostMenuAction); registry.registerLinkTooltipComponent(LinkTooltip); + registry.registerPostWillRenderEmbedComponent((embed) => embed.url && isUrlCanPreview(embed.url), LinkEmbedPreview, true); const {showRHSPlugin} = registry.registerRightHandSidebarComponent(SidebarRight, 'GitHub'); store.dispatch(setShowRHSAction(() => store.dispatch(showRHSPlugin))); diff --git a/webapp/src/utils/github_utils.ts b/webapp/src/utils/github_utils.ts new file mode 100644 index 000000000..8596b6c63 --- /dev/null +++ b/webapp/src/utils/github_utils.ts @@ -0,0 +1,8 @@ +export function isUrlCanPreview(url: string) { + const {hostname, pathname} = new URL(url); + if (hostname.includes('github.com') && pathname.split('/')[1]) { + const [_, owner, repo, type, number] = pathname.split('/'); + return Boolean(owner && repo && type && number); + } + return false; +}