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';