diff --git a/.all-contributorsrc b/.all-contributorsrc index 84b6ee61e..1ba64a7cd 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -384,6 +384,15 @@ ] }, { + "login": "brandly", + "name": "Matthew Brandly", + "avatar_url": "https://avatars3.githubusercontent.com/u/820696?v=4", + "profile": "http://words.brandly.me/about/", + "contributions": [ + "code" + ] + }, + { "login": "Jpfonseca", "name": "João Fonseca", "avatar_url": "https://avatars2.githubusercontent.com/u/11836470?v=4", diff --git a/src/api/index.js b/src/api/index.js index d4820135f..668706bd4 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -379,3 +379,14 @@ export const fetchNotificationsCount = accessToken => export const fetchRepoNotificationsCount = (owner, repoName, accessToken) => v3.count(`/repos/${owner}/${repoName}/notifications?per_page=1`, accessToken); + +export const fetchIssueEvents = ( + owner: string, + repoName: string, + issueNum: number, + accessToken: string +) => + v3.getJson( + `/repos/${owner}/${repoName}/issues/${issueNum}/events`, + accessToken + ); diff --git a/src/components/index.js b/src/components/index.js index 811f89c0c..3446b7af1 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -7,7 +7,9 @@ export * from './diff-blocks.component'; export * from './entity-info.component'; export * from './issue-description.component'; export * from './issue-list-item.component'; +export * from './issue-event-list-item.component'; export * from './label-button.component'; +export * from './inline-label.component'; export * from './label-list-item.component'; export * from './github-htmlview.component'; export * from './markdown-webview.component'; diff --git a/src/components/inline-label.component.js b/src/components/inline-label.component.js new file mode 100644 index 000000000..850af5d5b --- /dev/null +++ b/src/components/inline-label.component.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import { StyleSheet, Text } from 'react-native'; + +import { normalize } from 'config'; +import { getFontColorByBackground } from 'utils'; + +const styles = StyleSheet.create({ + inlineLabel: { + fontSize: normalize(10), + fontWeight: 'bold', + padding: 3, + paddingLeft: 5, + paddingRight: 5, + margin: 2, + borderWidth: 1, + overflow: 'hidden', + borderRadius: 2, + minWidth: 50, + textAlign: 'center', + }, +}); + +export class InlineLabel extends Component { + props: { + label: Object, + }; + + render() { + const { color, name } = this.props.label; + + return ( + + {name} + + ); + } +} diff --git a/src/components/issue-event-list-item.component.js b/src/components/issue-event-list-item.component.js new file mode 100644 index 000000000..7b1006094 --- /dev/null +++ b/src/components/issue-event-list-item.component.js @@ -0,0 +1,459 @@ +import React, { Component } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Icon } from 'react-native-elements'; +import moment from 'moment/min/moment-with-locales.min'; +import { colors, fonts, normalize } from 'config'; +import { InlineLabel } from 'components'; + +const styles = StyleSheet.create({ + container: { + paddingRight: 10, + paddingTop: 10, + backgroundColor: 'transparent', + }, + header: { + flexDirection: 'row', + alignItems: 'stretch', + }, + iconContainer: { + backgroundColor: colors.greyLight, + borderRadius: 13, + width: 26, + height: 26, + marginLeft: 14, + marginRight: 14, + flexGrow: 0, + flexShrink: 0, + flexBasis: 26, + }, + contentContainer: { + flexDirection: 'row', + flexGrow: 1, + flexShrink: 1, + borderBottomColor: colors.greyLight, + borderBottomWidth: 1, + }, + eventTextContainer: { + paddingBottom: 10, + flexDirection: 'row', + flexWrap: 'wrap', + flexGrow: 1, + flexShrink: 1, + alignItems: 'center', + }, + dateContainer: { + flex: 1, + alignItems: 'flex-end', + justifyContent: 'center', + alignSelf: 'flex-start', + marginTop: 2, + flexGrow: 0, + flexShrink: 0, + flexBasis: 39, + width: 39, + }, + actorLink: { + padding: 1, + paddingRight: 4, + }, + boldText: { + ...fonts.fontPrimaryBold, + fontSize: normalize(13), + color: colors.primaryDark, + }, + date: { + color: colors.greyDark, + }, +}); + +export class IssueEventListItem extends Component { + props: { + event: Object, + navigation: Object, + }; + + onPressUser = user => { + this.props.navigation.navigate('Profile', { user }); + }; + + render() { + const { event } = this.props; + + switch (event.event) { + case 'review_requested': + return ( + + {' '} + requested review from{' '} + + + } + createdAt={event.created_at} + /> + ); + case 'labeled': + case 'unlabeled': + return ( + + + + {' '}{event.event === 'unlabeled' ? 'removed' : 'added'}{' '} + + + + } + createdAt={event.created_at} + /> + ); + case 'label-group': + return ; + case 'closed': + return ( + + {' '} + closed this + + } + createdAt={event.created_at} + /> + ); + case 'reopened': + return ( + + {' '} + reopened this + + } + createdAt={event.created_at} + /> + ); + case 'merged': + return ( + + {' '} + merged {event.commit_id.slice(0, 7)} + + } + createdAt={event.created_at} + /> + ); + // case 'referenced': + case 'renamed': + return ( + + {' '} + changed the title from {event.rename.from.trim()}{' '} + to {event.rename.to.trim()} + + } + createdAt={event.created_at} + /> + ); + case 'assigned': + case 'unassigned': + return ( + + {' '} + {event.event}{' '} + + + } + createdAt={event.created_at} + /> + ); + // case 'review_dismissed': + // case 'review_request_removed': + case 'milestoned': + case 'demilestoned': { + const milestoneAction = + event.event === 'demilestoned' + ? 'removed this from' + : 'added this to'; + + return ( + + {' '} + {milestoneAction} the {event.milestone.title}{' '} + milestone + + } + createdAt={event.created_at} + /> + ); + } + case 'locked': + case 'unlocked': + return ( + + {' '} + {event.event} this conversation + + } + createdAt={event.created_at} + /> + ); + case 'head_ref_deleted': + case 'head_ref_restored': { + const isRestored = event.event === 'head_ref_restored'; + const headRefAction = isRestored ? 'deleted' : 'restored'; + + return ( + + {' '} + {headRefAction} this branch + + } + createdAt={event.created_at} + /> + ); + } + case 'marked_as_duplicate': + case 'unmarked_as_duplicate': + return ( + + {' '} + marked this as{' '} + {event.event === 'unmarked_as_duplicate' ? 'not ' : ''}a + duplicate + + } + createdAt={event.created_at} + /> + ); + // case 'added_to_project': + // case 'moved_columns_in_project': + // case 'removed_from_project': + // case 'converted_note_to_issue': + default: + return null; + } + } +} + +const marginLeftForIconName = name => { + switch (name) { + case 'git-branch': + case 'git-merge': + case 'primitive-dot': + return 8; + case 'bookmark': + return 6; + case 'person': + case 'lock': + return 4; + default: + return 2; + } +}; + +class Event extends Component { + props: { + iconName: String, + iconColor: String, + iconBackgroundColor: String, + text: React.Element<*>, + createdAt: String, + }; + + render() { + const { + text, + createdAt, + iconName, + iconColor = '#586069', + iconBackgroundColor = '#E6EBF1', + } = this.props; + + return ( + + + + + {text} + + + + {moment(createdAt).fromNow()} + + + + + ); + } +} + +class LabelGroup extends Component { + props: { + group: Object, + onPressUser: Function, + }; + + render() { + const { + actor, + labeled, + unlabeled, + created_at: createdAt, + } = this.props.group; + + const toInlineLabel = (type, { label }, index) => + ; + + /* eslint-disable react/jsx-no-bind */ + const labels = labeled.map(toInlineLabel.bind(null, 'added')); + const unlabels = unlabeled.map(toInlineLabel.bind(null, 'removed')); + + let textChildren = [ + , + ]; + + if (labels.length) { + textChildren = [ + ...textChildren, + added , + ...labels, + ]; + } + + if (labels.length && unlabels.length) { + textChildren.push( and); + } + + if (unlabels.length) { + textChildren = [ + ...textChildren, + removed , + ...unlabels, + ]; + } + + return ; + } +} + +class ActorLink extends Component { + props: { + actor: Object, + onPress: Function, + }; + + render() { + const { actor, onPress } = this.props; + + return ( + { + onPress(actor); + }} + > + {actor.login} + + ); + } +} + +class Bold extends Component { + props: { + children: Object | String, + }; + + render() { + return ( + + {this.props.children} + + ); + } +} diff --git a/src/components/label-button.component.js b/src/components/label-button.component.js index 9f10c4532..6995b184b 100644 --- a/src/components/label-button.component.js +++ b/src/components/label-button.component.js @@ -2,22 +2,14 @@ import React from 'react'; import { StyleSheet } from 'react-native'; import { Button } from 'react-native-elements'; -import { colors, fonts } from 'config'; +import { fonts } from 'config'; +import { getFontColorByBackground } from 'utils'; type Props = { label: Object, largeWithTag: boolean, }; -const getFontColorByBackground = bgColor => { - const r = parseInt(bgColor.substr(0, 2), 16); - const g = parseInt(bgColor.substr(2, 2), 16); - const b = parseInt(bgColor.substr(4, 2), 16); - const yiq = (r * 299 + g * 587 + b * 114) / 1000; - - return yiq >= 128 ? colors.black : colors.white; -}; - const styles = StyleSheet.create({ smallLabelButton: { padding: 5, diff --git a/src/issue/issue.action.js b/src/issue/issue.action.js index df97fe975..4ce97a2db 100644 --- a/src/issue/issue.action.js +++ b/src/issue/issue.action.js @@ -7,6 +7,7 @@ import { fetchSubmitNewIssue, fetchDeleteIssueComment, fetchEditIssueComment, + fetchIssueEvents, v3, } from 'api'; import { @@ -23,6 +24,7 @@ import { SUBMIT_NEW_ISSUE, DELETE_ISSUE_COMMENT, EDIT_ISSUE_COMMENT, + GET_ISSUE_EVENTS, } from './issue.type'; const getDiff = url => { @@ -371,3 +373,25 @@ export const submitNewIssue = (owner, repo, issueTitle, issueComment) => { }); }; }; + +export const getIssueEvents = (owner, repoName, issueNum) => { + return (dispatch, getState) => { + const accessToken = getState().auth.accessToken; + + dispatch({ type: GET_ISSUE_EVENTS.PENDING }); + + return fetchIssueEvents(owner, repoName, issueNum, accessToken) + .then(events => { + dispatch({ + type: GET_ISSUE_EVENTS.SUCCESS, + payload: events, + }); + }) + .catch(error => { + dispatch({ + type: GET_ISSUE_EVENTS.ERROR, + payload: error, + }); + }); + }; +}; diff --git a/src/issue/issue.reducer.js b/src/issue/issue.reducer.js index 30ea34cda..1dd36ad85 100644 --- a/src/issue/issue.reducer.js +++ b/src/issue/issue.reducer.js @@ -12,15 +12,18 @@ import { MERGE_PULL_REQUEST, GET_ISSUE_FROM_URL, SUBMIT_NEW_ISSUE, + GET_ISSUE_EVENTS, } from './issue.type'; const initialState = { issue: {}, comments: [], + events: [], pr: {}, diff: '', isMerged: false, isPendingComments: false, + isPendingEvents: false, isPostingComment: false, isDeletingComment: false, isEditingComment: false, @@ -53,6 +56,23 @@ export const issueReducer = (state = initialState, action = {}) => { error: action.payload, isPendingComments: false, }; + case GET_ISSUE_EVENTS.PENDING: + return { + ...state, + isPendingEvents: true, + }; + case GET_ISSUE_EVENTS.SUCCESS: + return { + ...state, + events: action.payload, + isPendingEvents: false, + }; + case GET_ISSUE_EVENTS.ERROR: + return { + ...state, + error: action.payload, + isPendingEvents: false, + }; case POST_ISSUE_COMMENT.PENDING: return { ...state, diff --git a/src/issue/issue.type.js b/src/issue/issue.type.js index 4b820f853..ad22aab85 100644 --- a/src/issue/issue.type.js +++ b/src/issue/issue.type.js @@ -15,3 +15,4 @@ export const GET_PULL_REQUEST_FROM_URL = createActionSet( export const MERGE_PULL_REQUEST = createActionSet('MERGE_PULL_REQUEST'); export const GET_ISSUE_FROM_URL = createActionSet('GET_ISSUE_FROM_URL'); export const SUBMIT_NEW_ISSUE = createActionSet('SUBMIT_NEW_ISSUE'); +export const GET_ISSUE_EVENTS = createActionSet('GET_ISSUE_EVENTS'); diff --git a/src/issue/screens/issue.screen.js b/src/issue/screens/issue.screen.js index b86113473..d6482db81 100644 --- a/src/issue/screens/issue.screen.js +++ b/src/issue/screens/issue.screen.js @@ -18,9 +18,10 @@ import { IssueDescription, CommentListItem, CommentInput, + IssueEventListItem, } from 'components'; import { v3 } from 'api'; -import { translate, openURLInView } from 'utils'; +import { translate, formatEventsToRender, openURLInView } from 'utils'; import { colors } from 'config'; import { getRepository, getContributors } from 'repository'; import { @@ -28,6 +29,7 @@ import { postIssueComment, getIssueFromUrl, deleteIssueComment, + getIssueEvents, } from '../issue.action'; const mapStateToProps = state => ({ @@ -40,9 +42,11 @@ const mapStateToProps = state => ({ pr: state.issue.pr, isMerged: state.issue.isMerged, comments: state.issue.comments, + events: state.issue.events, isPendingDiff: state.issue.isPendingDiff, isPendingCheckMerge: state.issue.isPendingCheckMerge, isPendingComments: state.issue.isPendingComments, + isPendingEvents: state.issue.isPendingEvents, isPostingComment: state.issue.isPostingComment, isPendingContributors: state.repository.isPendingContributors, isDeletingComment: state.issue.isDeletingComment, @@ -57,10 +61,21 @@ const mapDispatchToProps = dispatch => postIssueComment, getIssueFromUrl, deleteIssueComment, + getIssueEvents, }, dispatch ); +const compareCreatedAt = (a, b) => { + if (a.created_at < b.created_at) { + return -1; + } else if (a.created_at > b.created_at) { + return 1; + } + + return 0; +}; + class Issue extends Component { static navigationOptions = ({ navigation }) => { const getHeaderIcon = () => { @@ -104,6 +119,7 @@ class Issue extends Component { getContributors: Function, postIssueComment: Function, getIssueFromUrl: Function, + getIssueEvents: Function, deleteIssueComment: Function, diff: string, issue: Object, @@ -113,10 +129,12 @@ class Issue extends Component { repository: Object, contributors: Array, comments: Array, + events: Array, isPendingIssue: boolean, isPendingDiff: boolean, isPendingCheckMerge: boolean, isPendingComments: boolean, + isPendingEvents: boolean, isDeletingComment: boolean, isPendingContributors: boolean, // isPostingComment: boolean, @@ -172,6 +190,7 @@ class Issue extends Component { getRepository, getContributors, getIssueFromUrl, + getIssueEvents, } = this.props; const params = navigation.state.params; @@ -180,6 +199,9 @@ class Issue extends Component { .replace(`${v3.root}/repos/`, '') .replace(/([^/]+\/[^/]+)\/issues\/\d+$/, '$1'); + const repoName = repository.name; + const owner = repository.owner.login; + Promise.all([ getIssueFromUrl(issueURL), getIssueComments(`${issueURL}/comments`), @@ -196,6 +218,8 @@ class Issue extends Component { } else { this.setNavigationParams(); } + + return getIssueEvents(owner, repoName, issue.number); }); }; @@ -284,7 +308,11 @@ class Issue extends Component { }; renderItem = ({ item }) => { - const { locale } = this.props; + const { locale, navigation } = this.props; + + if (item.event) { + return ; + } return ( ); }; @@ -304,6 +332,7 @@ class Issue extends Component { comments, contributors, isPendingComments, + isPendingEvents, isPendingContributors, isPendingIssue, isDeletingComment, @@ -316,11 +345,15 @@ class Issue extends Component { isPendingIssue || isDeletingComment ); - const isShowLoadingContainer = isPendingComments || isPendingIssue; - const fullComments = !isPendingComments ? [issue, ...comments] : []; + const isShowLoadingContainer = + isPendingComments || isPendingIssue || isPendingEvents; + const events = formatEventsToRender([...this.props.events]); + const conversation = !isPendingComments + ? [issue, ...comments, ...events].sort(compareCreatedAt) + : []; const participantNames = !isPendingComments - ? fullComments.map(item => item && item.user && item.user.login) + ? conversation.map(item => item && item.user && item.user.login) : []; const contributorNames = !isPendingContributors ? contributors.map(item => item && item.login) @@ -356,7 +389,7 @@ class Issue extends Component { contentContainerStyle={{ flexGrow: 1 }} ListHeaderComponent={this.renderHeader} removeClippedSubviews={false} - data={fullComments} + data={conversation} keyExtractor={this.keyExtractor} renderItem={this.renderItem} /> diff --git a/src/utils/color-helpers.js b/src/utils/color-helpers.js new file mode 100644 index 000000000..373f45e66 --- /dev/null +++ b/src/utils/color-helpers.js @@ -0,0 +1,10 @@ +import { colors } from 'config'; + +export const getFontColorByBackground = bgColor => { + const r = parseInt(bgColor.substr(0, 2), 16); + const g = parseInt(bgColor.substr(2, 2), 16); + const b = parseInt(bgColor.substr(4, 2), 16); + const yiq = (r * 299 + g * 587 + b * 114) / 1000; + + return yiq >= 128 ? colors.black : colors.white; +}; diff --git a/src/utils/event-helpers.js b/src/utils/event-helpers.js new file mode 100644 index 000000000..707b2bbae --- /dev/null +++ b/src/utils/event-helpers.js @@ -0,0 +1,60 @@ +import moment from 'moment/min/moment-with-locales.min'; + +export function formatEventsToRender(events = []) { + return events + .filter(({ event }) => event !== 'mentioned' && event !== 'subscribed') + .filter(({ event }, index, list) => { + if (index === 0) { + return true; + } + + // Merge events are always followed by a closed event, but we don't + // want to render them. + if (event === 'closed' && list[index - 1].event === 'merged') { + return false; + } + + return true; + }) + .reduce((results, event, index, list) => { + // Label events are recorded individually, but we want to group them for display + const labelEvents = ['labeled', 'unlabeled']; + const prevEvent = index > 0 ? list[index - 1] : {}; + + if ( + index > 0 && + labelEvents.includes(event.event) && + labelEvents.includes(prevEvent.event) && + moment(event.created_at).diff(prevEvent.created_at) < 10000 && + event.actor.login === prevEvent.actor.login + ) { + const labelGroup = results[results.length - 1]; + + if (event.event === 'labeled') { + labelGroup.labeled.push(event); + } else { + labelGroup.unlabeled.push(event); + } + } else if (labelEvents.includes(event.event)) { + const labelGroup = { + event: 'label-group', + labeled: [], + unlabeled: [], + created_at: event.created_at, + actor: event.actor, + }; + + if (event.event === 'labeled') { + labelGroup.labeled.push(event); + } else { + labelGroup.unlabeled.push(event); + } + + results.push(labelGroup); + } else { + results.push(event); + } + + return results; + }, []); +} diff --git a/src/utils/index.js b/src/utils/index.js index 3bdc96801..365b3f91c 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -2,4 +2,6 @@ export * from './action-helper'; export * from './loading-animation'; export * from './text-helper'; export * from './method-helpers'; +export * from './color-helpers'; +export * from './event-helpers'; export * from './localization-helper';