import './mark-unread.css'; import React from 'dom-chef'; import select from 'select-dom'; import onDomReady from 'dom-loaded'; import elementReady from 'element-ready'; import delegate, {DelegateSubscription, DelegateEvent} from 'delegate-it'; import xIcon from 'octicon/x.svg'; import infoIcon from 'octicon/info.svg'; import checkIcon from 'octicon/check.svg'; import mergeIcon from 'octicon/git-merge.svg'; import issueOpenedIcon from 'octicon/issue-opened.svg'; import issueClosedIcon from 'octicon/issue-closed.svg'; import pullRequestIcon from 'octicon/git-pull-request.svg'; import features from '../libs/features'; import * as pageDetect from '../libs/page-detect'; import {getUsername, getRepoURL} from '../libs/utils'; import onUpdatableContentUpdate from '../libs/on-updatable-content-update'; type NotificationType = 'pull-request' | 'issue'; type NotificationState = 'open' | 'merged' | 'closed' | 'draft'; interface Participant { username: string; avatar: string; } interface Notification { participants: Participant[]; state: NotificationState; isParticipating: boolean; repository: string; dateTitle: string; title: string; type: NotificationType; date: string; url: string; } const listeners: DelegateSubscription[] = []; const stateIcons = { issue: { open: issueOpenedIcon, closed: issueClosedIcon, merged: issueClosedIcon, // Required just for TypeScript draft: issueOpenedIcon // Required just for TypeScript }, 'pull-request': { open: pullRequestIcon, closed: pullRequestIcon, merged: mergeIcon, draft: pullRequestIcon } }; async function getNotifications(): Promise { const {unreadNotifications} = await browser.storage.local.get({ unreadNotifications: [] }); // Only show notifications for the current domain. Accounts for gist.github.com as well return unreadNotifications.filter(({url}: Notification) => location.hostname.endsWith(new URL(url).hostname)); } async function setNotifications(unreadNotifications: Notification[]): Promise { return browser.storage.local.set({unreadNotifications}); } function stripHash(url: string): string { return url.replace(/#.+$/, ''); } function addMarkUnreadButton(): void { if (!select.exists('.rgh-btn-mark-unread')) { select('.thread-subscription-status')!.after( ); } } async function markRead(urls: string|string[]): Promise { if (!Array.isArray(urls)) { urls = [urls]; } const cleanUrls = urls.map(stripHash); for (const a of select.all('a.js-notification-target')) { if (cleanUrls.includes(a.getAttribute('href')!)) { a.closest('li.js-notification')!.classList.replace('unread', 'read'); } } const notifications = await getNotifications(); const updated = notifications.filter(({url}) => !cleanUrls.includes(url)); await setNotifications(updated); } async function markUnread({delegateTarget}: DelegateEvent): Promise { const participants: Participant[] = select.all('.participant-avatar').slice(0, 3).map(element => ({ username: element.getAttribute('aria-label')!, avatar: element.querySelector('img')!.src })); const stateLabel = select('.gh-header-meta .State')!; let state: NotificationState; if (stateLabel.classList.contains('State--green')) { state = 'open'; } else if (stateLabel.classList.contains('State--purple')) { state = 'merged'; } else if (stateLabel.classList.contains('State--red')) { state = 'closed'; } else if (stateLabel.title.includes('Draft')) { state = 'draft'; } else { throw new Error('Refined GitHub: A new issue state was introduced?'); } const lastCommentTime = select.last('.timeline-comment-header relative-time')!; const unreadNotifications = await getNotifications(); unreadNotifications.push({ participants, state, isParticipating: select.exists(`.participant-avatar[href="/${getUsername()}"]`), repository: getRepoURL(), dateTitle: lastCommentTime.title, title: select('.js-issue-title')!.textContent!.trim(), type: pageDetect.isPR() ? 'pull-request' : 'issue', date: lastCommentTime.getAttribute('datetime')!, url: stripHash(location.href) }); await setNotifications(unreadNotifications); await updateUnreadIndicator(); delegateTarget.setAttribute('disabled', 'disabled'); delegateTarget.textContent = 'Marked as unread'; } function getNotification(notification: Notification): Element { const { participants, dateTitle, title, state, type, date, url } = notification; const existing = select(`a.js-notification-target[href^="${stripHash(url)}"]`); if (existing) { const item = existing.closest('.js-notification')!; item.classList.replace('read', 'unread'); return item; } const usernames = participants .map(participant => participant.username) .join(' and ') .replace(/ and (.+) and/, ', $1, and'); // 3 people only: A, B, and C const avatars = participants.map(participant => {`@${participant.username}`} ); return (
  • {stateIcons[type][state]()} {title}
    • {infoIcon()}
    • {avatars}
  • ); } function getNotificationGroup({repository}: Notification): Element { const existing = select(`a.notifications-repo-link[title="${repository}"]`); if (existing) { return existing.closest('.boxed-group')!; } return (

    {repository}

    ); } async function renderNotifications(unreadNotifications: Notification[]): Promise { unreadNotifications = unreadNotifications.filter(shouldNotificationAppearHere); if (unreadNotifications.length === 0) { return; } // Don’t simplify selector, it’s for cross-extension compatibility let pageList = (await elementReady('#notification-center .notifications-list'))!; if (!pageList) { pageList =
    ; select('.blankslate')!.replaceWith(pageList); } unreadNotifications.reverse().forEach(notification => { const group = getNotificationGroup(notification); const item = getNotification(notification); pageList.prepend(group); group .querySelector('ul.notifications')! .prepend(item); }); // Make sure that all the boxes with unread items are at the top // This is necessary in the "All notifications" view for (const repo of select.all('.boxed-group').reverse()) { if (select.exists('.unread', repo)) { pageList.prepend(repo); } } } function shouldNotificationAppearHere(notification: Notification): boolean { if (isSingleRepoPage()) { return isCurrentSingleRepoPage(notification); } if (isParticipatingPage()) { return notification.isParticipating; } return true; } function isSingleRepoPage(): boolean { return location.pathname.split('/')[3] === 'notifications'; } function isCurrentSingleRepoPage({repository}: Notification): boolean { const singleRepo = /^[/](.+[/].+)[/]notifications/.exec(location.pathname)?.[1]; return singleRepo === repository; } function isParticipatingPage(): boolean { return location.pathname.startsWith('/notifications/participating'); } async function updateUnreadIndicator(): Promise { const icon = select('a.notification-indicator'); // "a" required in responsive views if (!icon) { return; } const statusMark = icon.querySelector('.mail-status'); if (!statusMark) { return; } const hasRealNotifications = icon.matches('[data-ga-click$=":unread"]'); const rghUnreadCount = (await getNotifications()).length; const hasUnread = hasRealNotifications || rghUnreadCount > 0; const label = hasUnread ? 'You have unread notifications' : 'You have no unread notifications'; icon.setAttribute('aria-label', label); statusMark.classList.toggle('unread', hasUnread); if (rghUnreadCount > 0) { icon.dataset.rghUnread = String(rghUnreadCount); // Store in attribute to let other extensions know } else { delete icon.dataset.rghUnread; } } async function markNotificationRead({delegateTarget}: DelegateEvent): Promise { const {href} = delegateTarget .closest('li.js-notification')! .querySelector('a.js-notification-target')!; await markRead(href); await updateUnreadIndicator(); } async function markAllNotificationsRead(event: DelegateEvent): Promise { event.preventDefault(); const repoGroup = event.delegateTarget.closest('.boxed-group')!; const urls = select.all('a.js-notification-target', repoGroup).map(a => a.href); await markRead(urls); await updateUnreadIndicator(); } async function markVisibleNotificationsRead({delegateTarget}: DelegateEvent): Promise { const group = delegateTarget.closest('.boxed-group')!; const repo = select('.notifications-repo-link', group)!.textContent; const notifications = await getNotifications(); setNotifications(notifications.filter(({repository}) => repository !== repo)); } function addCustomAllReadButton(): void { const nativeMarkUnreadForm = select('details [action="/notifications/mark"]'); if (nativeMarkUnreadForm) { nativeMarkUnreadForm.addEventListener('submit', () => { setNotifications([]); }); return; } select('.tabnav .float-right')!.append(
    Mark all as read

    Are you sure?

    Are you sure you want to mark all unread notifications as read?

    ); delegate('#clear-local-notification', 'click', async () => { await setNotifications([]); location.reload(); }); } function updateLocalNotificationsCount(localNotifications: Notification[]): void { const unreadCount = select('#notification-center .filter-list a[href="/notifications"] .count')!; const githubNotificationsCount = Number(unreadCount.textContent); unreadCount.textContent = String(githubNotificationsCount + localNotifications.length); } function updateLocalParticipatingCount(notifications: Notification[]): void { const participatingNotifications = notifications .filter(({isParticipating}) => isParticipating) .length; if (participatingNotifications > 0) { const unreadCount = select('#notification-center .filter-list a[href="/notifications/participating"] .count')!; const githubNotificationsCount = Number(unreadCount.textContent); unreadCount.textContent = String(githubNotificationsCount + participatingNotifications); } } function destroy(): void { for (const listener of listeners) { listener.destroy(); } listeners.length = 0; } async function init(): Promise { destroy(); await onDomReady; if (pageDetect.isNotifications()) { const notifications = await getNotifications(); if (notifications.length > 0) { await renderNotifications(notifications); addCustomAllReadButton(); updateLocalNotificationsCount(notifications); updateLocalParticipatingCount(notifications); document.dispatchEvent(new CustomEvent('refined-github:mark-unread:notifications-added')); } listeners.push( delegate('.btn-link.delete-note', 'click', markNotificationRead), delegate('.js-mark-all-read', 'click', markAllNotificationsRead), delegate('.js-delete-notification button', 'click', updateUnreadIndicator), delegate('.js-mark-visible-as-read', 'submit', markVisibleNotificationsRead) ); } else if (pageDetect.isPR() || pageDetect.isIssue()) { await markRead(location.href); if (pageDetect.isPRConversation() || pageDetect.isIssue()) { addMarkUnreadButton(); onUpdatableContentUpdate(select('#partial-discussion-sidebar')!, addMarkUnreadButton); delegate('.rgh-btn-mark-unread', 'click', markUnread); } } else if (pageDetect.isDiscussionList()) { for (const discussion of await getNotifications()) { const {pathname} = new URL(discussion.url); const listItem = select(`.read [href='${pathname}']`); if (listItem) { listItem.closest('.read')!.classList.replace('read', 'unread'); } } } updateUnreadIndicator(); } features.add({ id: __featureName__, description: 'Adds button to mark issues and PRs as unread. They will reappear in Notifications.', screenshot: 'https://user-images.githubusercontent.com/1402241/27847663-963b7d7c-6171-11e7-9470-6e86d8463771.png', load: features.onAjaxedPagesRaw, init });