diff --git a/src/js/actions/index.test.ts b/src/js/actions/index.test.ts index af317754c..d15e31661 100644 --- a/src/js/actions/index.test.ts +++ b/src/js/actions/index.test.ts @@ -373,6 +373,81 @@ describe('actions/index.js', () => { }); }); + it('should unsubscribe from a notification thread with success - github.com', () => { + const id = 123; + const hostname = 'github.com'; + + // The unsubscribe endpoint call. + nock('https://api.github.com/') + .delete(`/notifications/threads/${id}/subscription`) + .reply(204); + + // The mark read endpoint call. + nock('https://api.github.com/') + .patch(`/notifications/threads/${id}`) + .reply(200); + + const expectedActions = [ + { type: actions.UNSUBSCRIBE_NOTIFICATION.REQUEST }, + { + type: actions.UNSUBSCRIBE_NOTIFICATION.SUCCESS, + meta: { id, hostname }, + }, + ]; + + const store = createMockStore( + { + auth: { + token: 'THISISATOKEN', + enterpriseAccounts: mockedEnterpriseAccounts, + }, + notifications: { response: [] }, + }, + expectedActions + ); + + return store + .dispatch(actions.unsubscribeNotification(id, hostname)) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('should unsubscribe from a notification thread with failure - github.com', () => { + const id = 123; + const hostname = 'github.com'; + const message = 'Oops! Something went wrong.'; + + nock('https://api.github.com/') + .delete(`/notifications/threads/${id}/subscription`) + .reply(400, { message }); + + const expectedActions = [ + { type: actions.UNSUBSCRIBE_NOTIFICATION.REQUEST }, + { type: actions.UNSUBSCRIBE_NOTIFICATION.FAILURE, payload: { message } }, + ]; + + const store = createMockStore( + { + auth: { + token: 'THISISATOKEN', + enterpriseAccounts: mockedEnterpriseAccounts, + }, + settings: { + participating: false, + }, + notifications: { response: [] }, + }, + expectedActions + ); + + return store + .dispatch(actions.unsubscribeNotification(id, hostname)) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + it('should mark a notification as read with success - github.com', () => { const id = 123; const hostname = 'github.com'; diff --git a/src/js/actions/index.ts b/src/js/actions/index.ts index 66af262a7..aabee3c01 100644 --- a/src/js/actions/index.ts +++ b/src/js/actions/index.ts @@ -18,6 +18,14 @@ export function makeAsyncActionSet(actionName) { }; } +enum Methods { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', + DELETE = 'DELETE', +} + // Authentication export const LOGIN = makeAsyncActionSet('LOGIN'); @@ -27,7 +35,6 @@ export function loginUser(authOptions, code) { return (dispatch) => { const url = `https://${hostname}/login/oauth/access_token`; - const method = 'POST'; const data = { client_id: authOptions.clientId, client_secret: authOptions.clientSecret, @@ -36,7 +43,7 @@ export function loginUser(authOptions, code) { dispatch({ type: LOGIN.REQUEST }); - return apiRequest(url, method, data) + return apiRequest(url, Methods.POST, data) .then(function (response) { dispatch({ type: LOGIN.SUCCESS, @@ -60,7 +67,6 @@ export function logout(): LogoutAction { export const NOTIFICATIONS = makeAsyncActionSet('NOTIFICATIONS'); export function fetchNotifications() { return (dispatch, getState: () => AppState) => { - const method = 'GET'; const { settings }: { settings: SettingsState } = getState(); const isGitHubLoggedIn = getState().auth.token !== null; const endpointSuffix = `notifications?participating=${settings.participating}`; @@ -72,7 +78,7 @@ export function fetchNotifications() { const url = `https://api.${Constants.DEFAULT_AUTH_OPTIONS.hostname}/${endpointSuffix}`; const token = getState().auth.token; - return apiRequestAuth(url, method, token); + return apiRequestAuth(url, Methods.GET, token); } function getEnterpriseNotifications() { @@ -81,7 +87,7 @@ export function fetchNotifications() { const hostname = account.hostname; const token = account.token; const url = `https://${hostname}/api/v3/${endpointSuffix}`; - return apiRequestAuth(url, method, token); + return apiRequestAuth(url, Methods.GET, token); }); } @@ -130,7 +136,6 @@ export const MARK_NOTIFICATION = makeAsyncActionSet('MARK_NOTIFICATION'); export function markNotification(id, hostname) { return (dispatch, getState: () => AppState) => { const url = `${generateGitHubAPIUrl(hostname)}notifications/threads/${id}`; - const method = 'PATCH'; const isEnterprise = hostname !== Constants.DEFAULT_AUTH_OPTIONS.hostname; const entAccounts = getState().auth.enterpriseAccounts; @@ -140,7 +145,7 @@ export function markNotification(id, hostname) { dispatch({ type: MARK_NOTIFICATION.REQUEST }); - return apiRequestAuth(url, method, token, {}) + return apiRequestAuth(url, Methods.PATCH, token, {}) .then(function (response) { dispatch({ type: MARK_NOTIFICATION.SUCCESS, @@ -156,6 +161,48 @@ export function markNotification(id, hostname) { }; } +export const UNSUBSCRIBE_NOTIFICATION = makeAsyncActionSet( + 'UNSUBSCRIBE_NOTIFICATION' +); +export function unsubscribeNotification(id, hostname) { + return (dispatch, getState: () => AppState) => { + const markReadURL = `${generateGitHubAPIUrl( + hostname + )}notifications/threads/${id}`; + const unsubscribeURL = `${generateGitHubAPIUrl( + hostname + )}notifications/threads/${id}/subscription`; + + const isEnterprise = hostname !== Constants.DEFAULT_AUTH_OPTIONS.hostname; + const entAccounts = getState().auth.enterpriseAccounts; + const token = isEnterprise + ? getEnterpriseAccountToken(hostname, entAccounts) + : getState().auth.token; + + dispatch({ type: UNSUBSCRIBE_NOTIFICATION.REQUEST }); + + return apiRequestAuth(unsubscribeURL, Methods.DELETE, token, {}) + .then((response) => { + // The GitHub notifications API doesn't automatically mark things as read + // like it does in the UI, so after unsubscribing we also need to hit the + // endpoint to mark it as read. + return apiRequestAuth(markReadURL, Methods.PATCH, token, {}); + }) + .then((response) => { + dispatch({ + type: UNSUBSCRIBE_NOTIFICATION.SUCCESS, + meta: { id, hostname }, + }); + }) + .catch((error) => { + dispatch({ + type: UNSUBSCRIBE_NOTIFICATION.FAILURE, + payload: error.response.data, + }); + }); + }; +} + // Repo's Notification export const MARK_REPO_NOTIFICATION = makeAsyncActionSet( @@ -166,7 +213,6 @@ export function markRepoNotifications(repoSlug, hostname) { const url = `${generateGitHubAPIUrl( hostname )}repos/${repoSlug}/notifications`; - const method = 'PUT'; const isEnterprise = hostname !== Constants.DEFAULT_AUTH_OPTIONS.hostname; const entAccounts = getState().auth.enterpriseAccounts; @@ -176,7 +222,7 @@ export function markRepoNotifications(repoSlug, hostname) { dispatch({ type: MARK_REPO_NOTIFICATION.REQUEST }); - return apiRequestAuth(url, method, token, {}) + return apiRequestAuth(url, Methods.PUT, token, {}) .then(function (response) { dispatch({ type: MARK_REPO_NOTIFICATION.SUCCESS, diff --git a/src/js/components/__snapshots__/notification.test.tsx.snap b/src/js/components/__snapshots__/notification.test.tsx.snap index c1afbfa75..dfecc2448 100644 --- a/src/js/components/__snapshots__/notification.test.tsx.snap +++ b/src/js/components/__snapshots__/notification.test.tsx.snap @@ -51,6 +51,34 @@ exports[`components/notification.js should render itself & its children 1`] = ` - Updated in over 3 years +
{ const props = { markNotification: jest.fn(), + unsubscribeNotification: jest.fn(), markOnClick: false, notification: notification, hostname: 'github.com', @@ -44,6 +45,7 @@ describe('components/notification.js', () => { it('should open a notification in the browser', () => { const props = { markNotification: jest.fn(), + unsubscribeNotification: jest.fn(), markOnClick: false, notification: notification, hostname: 'github.com', @@ -54,30 +56,46 @@ describe('components/notification.js', () => { expect(shell.openExternal).toHaveBeenCalledTimes(1); }); - it('should mark a notification as read', () => { + it('should open a notification in browser & mark it as read', () => { const props = { markNotification: jest.fn(), - markOnClick: false, + unsubscribeNotification: jest.fn(), + markOnClick: true, notification: notification, hostname: 'github.com', }; const { getByRole } = render(); - fireEvent.click(getByRole('button')); + fireEvent.click(getByRole('main')); + expect(shell.openExternal).toHaveBeenCalledTimes(1); expect(props.markNotification).toHaveBeenCalledTimes(1); }); - it('should open a notification in browser & mark it as read', () => { + it('should mark a notification as read', () => { const props = { markNotification: jest.fn(), - markOnClick: true, + unsubscribeNotification: jest.fn(), + markOnClick: false, notification: notification, hostname: 'github.com', }; - const { getByRole } = render(); - fireEvent.click(getByRole('main')); - expect(shell.openExternal).toHaveBeenCalledTimes(1); + const { getByLabelText } = render(); + fireEvent.click(getByLabelText('Mark as Read')); expect(props.markNotification).toHaveBeenCalledTimes(1); }); + + it('should unsubscribe from a notification thread', () => { + const props = { + markNotification: jest.fn(), + unsubscribeNotification: jest.fn(), + markOnClick: false, + notification: notification, + hostname: 'github.com', + }; + + const { getByLabelText } = render(); + fireEvent.click(getByLabelText('Unsubscribe')); + expect(props.unsubscribeNotification).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/js/components/notification.tsx b/src/js/components/notification.tsx index ad15cd7bf..3b930550c 100644 --- a/src/js/components/notification.tsx +++ b/src/js/components/notification.tsx @@ -3,13 +3,13 @@ const { shell } = require('electron'); import * as React from 'react'; import { connect } from 'react-redux'; import { formatDistanceToNow, parseISO } from 'date-fns'; -import Octicon, { Check, getIconByName } from '@primer/octicons-react'; +import Octicon, { Check, Mute, getIconByName } from '@primer/octicons-react'; import styled from 'styled-components'; import { AppState } from '../../types/reducers'; import { formatReason, getNotificationTypeIcon } from '../utils/github-api'; import { generateGitHubWebUrl } from '../utils/helpers'; -import { markNotification } from '../actions'; +import { markNotification, unsubscribeNotification } from '../actions'; import { Notification } from '../../types/github'; const Wrapper = styled.div` @@ -52,7 +52,7 @@ const IconWrapper = styled.div` align-items: center; `; -const Button = styled.button` +const PrimaryButton = styled.button` background: none; border: none; @@ -62,11 +62,23 @@ const Button = styled.button` } `; +const SecondaryButton = styled.button` + background: none; + border: none; + float: right; + + .octicon:hover { + color: ${(props) => props.theme.danger}; + cursor: pointer; + } +`; + interface IProps { hostname: string; notification: Notification; markOnClick: boolean; markNotification: (id: string, hostname: string) => void; + unsubscribeNotification?: (id: string, hostname: string) => void; } export const NotificationItem: React.FC = (props) => { @@ -91,6 +103,14 @@ export const NotificationItem: React.FC = (props) => { props.markNotification(notification.id, hostname); }; + const unsubscribe = (event: React.MouseEvent) => { + // Don't trigger onClick of parent element. + event.stopPropagation(); + + const { hostname, notification } = props; + props.unsubscribeNotification(notification.id, hostname); + }; + const { notification } = props; const reason = formatReason(notification.reason); const typeIcon = getNotificationTypeIcon(notification.subject.type); @@ -113,12 +133,15 @@ export const NotificationItem: React.FC = (props) => {
{reason.type} - Updated{' '} {updatedAt} + unsubscribe(e)}> + +
- + ); @@ -130,4 +153,7 @@ export function mapStateToProps(state: AppState) { }; } -export default connect(mapStateToProps, { markNotification })(NotificationItem); +export default connect(mapStateToProps, { + markNotification, + unsubscribeNotification, +})(NotificationItem); diff --git a/src/js/middleware/notifications.ts b/src/js/middleware/notifications.ts index 0c5872aee..835f4e438 100644 --- a/src/js/middleware/notifications.ts +++ b/src/js/middleware/notifications.ts @@ -3,6 +3,7 @@ import { NOTIFICATIONS, MARK_NOTIFICATION, MARK_REPO_NOTIFICATION, + UNSUBSCRIBE_NOTIFICATION, } from '../actions'; import NativeNotifications from '../utils/notifications'; import { updateTrayIcon } from '../utils/comms'; @@ -56,6 +57,7 @@ export default (store) => (next) => (action) => { break; case MARK_NOTIFICATION.SUCCESS: + case UNSUBSCRIBE_NOTIFICATION.SUCCESS: const prevNotificationsCount = accountNotifications.reduce( (memo, acc) => memo + acc.notifications.length, 0 diff --git a/src/js/reducers/notifications.ts b/src/js/reducers/notifications.ts index 19a400b30..8c566411b 100644 --- a/src/js/reducers/notifications.ts +++ b/src/js/reducers/notifications.ts @@ -3,6 +3,7 @@ import { NOTIFICATIONS, MARK_NOTIFICATION, MARK_REPO_NOTIFICATION, + UNSUBSCRIBE_NOTIFICATION, } from '../actions'; import { LOGOUT } from '../../types/actions'; import { Notification } from '../../types/github'; @@ -26,6 +27,7 @@ export default function reducer( case NOTIFICATIONS.FAILURE: return { ...state, isFetching: false, failed: true, response: [] }; case MARK_NOTIFICATION.SUCCESS: + case UNSUBSCRIBE_NOTIFICATION.SUCCESS: const accountIndex = state.response.findIndex( (obj) => obj.hostname === action.meta.hostname );