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.ts b/webapp/src/components/link_embed_preview/index.ts new file mode 100644 index 000000000..64c46ec31 --- /dev/null +++ b/webapp/src/components/link_embed_preview/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {GlobalState} from '@/types/store'; +import {getPluginState} from '@/selectors'; + +import {LinkEmbedPreview} from './link_embed_preview'; + +const mapStateToProps = (state: GlobalState) => { + return {connected: getPluginState(state).connected}; +}; + +// Use a more direct approach with type assertion +// @ts-ignore - Ignoring type errors for connect function +export default connect(mapStateToProps)(LinkEmbedPreview); diff --git a/webapp/src/components/link_embed_preview/link_embed_preview.tsx b/webapp/src/components/link_embed_preview/link_embed_preview.tsx new file mode 100644 index 000000000..bec9df7e4 --- /dev/null +++ b/webapp/src/components/link_embed_preview/link_embed_preview.tsx @@ -0,0 +1,235 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GitMergeIcon, GitPullRequestIcon, IssueClosedIcon, IssueOpenedIcon, SkipIcon, IconProps} 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 {getLabelFontColor} from '../../utils/styles'; +import {isUrlCanPreview} from '../../utils/github_utils'; +import Client from '@/client'; + +const maxTicketDescriptionLength = 160; + +interface Label { + name: string; + color: string; + description?: string; +} + +interface RepoRef { + ref: string; +} + +interface GitHubData { + owner: string; + repo: string; + type: 'issues' | 'pull'; + state: string; + merged?: boolean; + state_reason?: string; + created_at: string; + title: string; + body?: string; + number: number; + labels?: Label[]; + base?: RepoRef; + head?: RepoRef; +} + +type LinkEmbedProps = { + embed: { + url: string; + }; + connected: boolean; +}; + +export const LinkEmbedPreview = ({embed: {url}, connected}: LinkEmbedProps) => { + const [data, setData] = useState(null); + useEffect(() => { + const initData = async () => { + if (isUrlCanPreview(url)) { + const [owner, repo, type, number] = url.split('github.com/')[1].split('/'); + + let issueOrPR: any; + if (type === 'issues') { + issueOrPR = await Client.getIssue(owner, repo, Number(number)); + } else if (type === 'pull') { + issueOrPR = await Client.getPullRequest(owner, repo, Number(number)); + } else { + return; + } + + if (issueOrPR && !('error' in issueOrPR)) { + const githubData: GitHubData = { + owner, + repo, + type: type as 'issues' | 'pull', + state: issueOrPR.state || '', + created_at: issueOrPR.created_at || '', + title: issueOrPR.title || '', + number: issueOrPR.number || 0, + merged: type === 'pull' && issueOrPR.merged, + state_reason: issueOrPR.state_reason, + body: issueOrPR.body, + labels: Array.isArray(issueOrPR.labels) ? issueOrPR.labels : [], + base: type === 'pull' && issueOrPR.base && issueOrPR.base, + head: type === 'pull' && issueOrPR.head && issueOrPR.head, + }; + setData(githubData); + } + } + }; + + if (!connected || data) { + return; + } + + initData(); + }, [connected, data, url]); + + const getIconElement = () => { + if (!data) { + return null; + } + + const iconProps = { + size: 16, // Use a number instead of 'small' + verticalAlign: 'text-bottom' as const, + }; + + 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; + } + const dateObj = new Date(data.created_at); + const dateStr = dateObj.toDateString(); + + let description = ''; + if (data.body) { + description = data.body.substring(0, maxTicketDescriptionLength).trim(); + if (data.body.length > maxTicketDescriptionLength) { + description += '...'; + } + } + + return ( +
+
+ + {data.repo} + + {' on '} + {dateStr} +
+ +
+ + {/* info */} +
+ +
+ { getIconElement() } + {data.title} +
+ {'#' + data.number} +
+
+ {description} +
+ +
+ {/* base <- head */} + {data.type === 'pull' && data.base && data.head && ( +
+
{'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 962273d04..37f975178 100644 --- a/webapp/src/components/link_tooltip/link_tooltip.jsx +++ b/webapp/src/components/link_tooltip/link_tooltip.jsx @@ -10,6 +10,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; @@ -17,11 +18,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 5be4fb8b8..8198f967d 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 ac44b3442..a9d63be43 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -16,12 +16,14 @@ 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 {handleConnect, handleDisconnect, handleConfigurationUpdate, handleOpenCreateIssueModal, handleReconnect, handleRefresh} from './websocket'; import {getServerRoute} from './selectors'; import manifest from './manifest'; +import {isUrlCanPreview} from './utils/github_utils'; let activityFunc; let lastActivityTime = Number.MAX_SAFE_INTEGER; @@ -67,6 +69,7 @@ class PluginClass { }, }); 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/types/github_types.ts b/webapp/src/types/github_types.ts index 2bc95d5ea..faad3e4af 100644 --- a/webapp/src/types/github_types.ts +++ b/webapp/src/types/github_types.ts @@ -85,8 +85,36 @@ export type PrsDetailsData = { } export type GithubIssueData = { + id?: number; number: number; + state?: string; + state_reason?: string; + locked?: boolean; + title?: string; + body?: string; + author_association?: string; + user?: GitHubUser; + labels?: GithubLabel[]; + assignee?: GitHubUser; + comments?: number; + closed_at?: string; + created_at?: string; + updated_at?: string; + closed_by?: GitHubUser; + url?: string; + html_url?: string; + comments_url?: string; + events_url?: string; + labels_url?: string; repository_url: string; + milestone?: { title: string }; + pull_request?: unknown; + repository?: { full_name: string }; + reactions?: unknown; + assignees?: GitHubUser[]; + node_id?: string; + text_matches?: unknown[]; + active_lock_reason?: string; } export type DefaultRepo = { @@ -126,7 +154,54 @@ export type GithubUsersData = { } export type GitHubPullRequestData = { - id: number; + id?: number; + number?: number; + state?: string; + locked?: boolean; + title?: string; + body?: string; + created_at?: string; + updated_at?: string; + closed_at?: string; + merged_at?: string; + labels?: GithubLabel[]; + user?: GitHubUser; + draft?: boolean; + merged?: boolean; + mergeable?: boolean; + mergeable_state?: string; + merged_by?: GitHubUser; + merge_commit_sha?: string; + rebaseable?: boolean; + comments?: number; + commits?: number; + additions?: number; + deletions?: number; + changed_files?: number; + url?: string; + html_url?: string; + issue_url?: string; + statuses_url?: string; + diff_url?: string; + patch_url?: string; + commits_url?: string; + comments_url?: string; + review_comments_url?: string; + review_comment_url?: string; + review_comments?: number; + assignee?: GitHubUser; + assignees?: GitHubUser[]; + milestone?: { title: string; number: number }; + maintainer_can_modify?: boolean; + author_association?: string; + node_id?: string; + requested_reviewers?: GitHubUser[]; + auto_merge?: unknown; + requested_teams?: { name: string; id: number }[]; + links?: unknown; + head?: { ref: string; sha: string; repo?: { full_name: string } }; + base?: { ref: string; sha: string; repo?: { full_name: string } }; + active_lock_reason?: string; } export type MilestoneData = { diff --git a/webapp/src/utils/github_utils.ts b/webapp/src/utils/github_utils.ts new file mode 100644 index 000000000..1783f3692 --- /dev/null +++ b/webapp/src/utils/github_utils.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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; +} diff --git a/webapp/src/utils/styles.js b/webapp/src/utils/styles.js index 9964b08c1..8a95eb7cd 100644 --- a/webapp/src/utils/styles.js +++ b/webapp/src/utils/styles.js @@ -1,4 +1,4 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {changeOpacity} from 'mattermost-redux/utils/theme_utils';