Skip to content

Commit b5c68a1

Browse files
Vadim Demedessindresorhus
authored andcommitted
add "mark as unread" feature (refined-github#284)
1 parent 31aec69 commit b5c68a1

5 files changed

Lines changed: 307 additions & 2 deletions

File tree

extension/content.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,3 +508,9 @@ hidden content area created to increase hover target
508508
font-weight: normal;
509509
margin-left: 4px;
510510
}
511+
512+
/* mark as unread */
513+
514+
.btn-mark-unread {
515+
margin-top: 8px;
516+
}

extension/content.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* globals gitHubInjection, pageDetect, diffFileHeader, addReactionParticipants, addFileCopyButton, enableCopyOnY, addBlameParentLinks, showRealNames */
1+
/* globals gitHubInjection, pageDetect, diffFileHeader, addReactionParticipants, addFileCopyButton, enableCopyOnY, addBlameParentLinks, showRealNames, markUnread */
22

33
'use strict';
44
const {ownerName, repoName} = pageDetect.getOwnerAndRepo();
@@ -332,11 +332,24 @@ document.addEventListener('DOMContentLoaded', () => {
332332
$(window).on('scroll.infinite resize.infinite', infinitelyMore);
333333
}
334334

335+
if (pageDetect.isNotifications()) {
336+
markUnread.setup();
337+
338+
new MutationObserver(() => {
339+
markUnread.destroy();
340+
341+
if (pageDetect.isNotifications()) {
342+
markUnread.setup();
343+
}
344+
}).observe($('#js-pjax-container').get(0), {childList: true});
345+
}
346+
335347
if (pageDetect.isRepo()) {
336348
gitHubInjection(window, () => {
337349
addReleasesTab();
338350
diffFileHeader.destroy();
339351
enableCopyOnY.destroy();
352+
markUnread.destroy();
340353

341354
if (pageDetect.isPR()) {
342355
linkifyBranchRefs();
@@ -386,6 +399,10 @@ document.addEventListener('DOMContentLoaded', () => {
386399
addFileCopyButton();
387400
enableCopyOnY.setup();
388401
}
402+
403+
if (pageDetect.isPR() || pageDetect.isIssue()) {
404+
markUnread.setup();
405+
}
389406
});
390407
}
391408
});

extension/manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"copy-on-y.js",
3636
"show-names.js",
3737
"content.js",
38-
"add-blame-parent-links.js"
38+
"add-blame-parent-links.js",
39+
"mark-unread.js"
3940
]
4041
}
4142
]

extension/mark-unread.js

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/* globals pageDetect */
2+
3+
'use strict';
4+
5+
const mergedPullRequestIcon = '<svg aria-label="pull request" class="octicon octicon-git-pull-request type-icon type-icon-state-merged" height="16" role="img" version="1.1" viewBox="0 0 12 16" width="12"><path d="M11 11.28V5c-.03-.78-.34-1.47-.94-2.06C9.46 2.35 8.78 2.03 8 2H7V0L4 3l3 3V4h1c.27.02.48.11.69.31.21.2.3.42.31.69v6.28A1.993 1.993 0 0 0 10 15a1.993 1.993 0 0 0 1-3.72zm-1 2.92c-.66 0-1.2-.55-1.2-1.2 0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2zM4 3c0-1.11-.89-2-2-2a1.993 1.993 0 0 0-1 3.72v6.56A1.993 1.993 0 0 0 2 15a1.993 1.993 0 0 0 1-3.72V4.72c.59-.34 1-.98 1-1.72zm-.8 10c0 .66-.55 1.2-1.2 1.2-.65 0-1.2-.55-1.2-1.2 0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2zM2 4.2C1.34 4.2.8 3.65.8 3c0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2z"></path></svg>';
6+
const closedPullRequestIcon = '<svg aria-label="pull request" class="octicon octicon-git-pull-request type-icon type-icon-state-closed" height="16" role="img" version="1.1" viewBox="0 0 12 16" width="12"><path d="M11 11.28V5c-.03-.78-.34-1.47-.94-2.06C9.46 2.35 8.78 2.03 8 2H7V0L4 3l3 3V4h1c.27.02.48.11.69.31.21.2.3.42.31.69v6.28A1.993 1.993 0 0 0 10 15a1.993 1.993 0 0 0 1-3.72zm-1 2.92c-.66 0-1.2-.55-1.2-1.2 0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2zM4 3c0-1.11-.89-2-2-2a1.993 1.993 0 0 0-1 3.72v6.56A1.993 1.993 0 0 0 2 15a1.993 1.993 0 0 0 1-3.72V4.72c.59-.34 1-.98 1-1.72zm-.8 10c0 .66-.55 1.2-1.2 1.2-.65 0-1.2-.55-1.2-1.2 0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2zM2 4.2C1.34 4.2.8 3.65.8 3c0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2z"></path></svg>';
7+
const openPullRequestIcon = '<svg aria-label="pull request" class="octicon octicon-git-pull-request type-icon type-icon-state-open" height="16" role="img" version="1.1" viewBox="0 0 12 16" width="12"><path d="M11 11.28V5c-.03-.78-.34-1.47-.94-2.06C9.46 2.35 8.78 2.03 8 2H7V0L4 3l3 3V4h1c.27.02.48.11.69.31.21.2.3.42.31.69v6.28A1.993 1.993 0 0 0 10 15a1.993 1.993 0 0 0 1-3.72zm-1 2.92c-.66 0-1.2-.55-1.2-1.2 0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2zM4 3c0-1.11-.89-2-2-2a1.993 1.993 0 0 0-1 3.72v6.56A1.993 1.993 0 0 0 2 15a1.993 1.993 0 0 0 1-3.72V4.72c.59-.34 1-.98 1-1.72zm-.8 10c0 .66-.55 1.2-1.2 1.2-.65 0-1.2-.55-1.2-1.2 0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2zM2 4.2C1.34 4.2.8 3.65.8 3c0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2z"></path></svg>';
8+
const closedIssueIcon = '<svg aria-label="issues" class="octicon octicon-issue-closed type-icon type-icon-state-closed" height="16" role="img" version="1.1" viewBox="0 0 16 16" width="16"><path d="M7 10h2v2H7v-2zm2-6H7v5h2V4zm1.5 1.5l-1 1L12 9l4-4.5-1-1L12 7l-1.5-1.5zM8 13.7A5.71 5.71 0 0 1 2.3 8c0-3.14 2.56-5.7 5.7-5.7 1.83 0 3.45.88 4.5 2.2l.92-.92A6.947 6.947 0 0 0 8 1C4.14 1 1 4.14 1 8s3.14 7 7 7 7-3.14 7-7l-1.52 1.52c-.66 2.41-2.86 4.19-5.48 4.19v-.01z"></path></svg>';
9+
const openIssueIcon = '<svg aria-label="issues" class="octicon octicon-issue-opened type-icon type-icon-state-open" height="16" role="img" version="1.1" viewBox="0 0 14 16" width="14"><path d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg>';
10+
const checkIcon = '<svg aria-hidden="true" class="octicon octicon-check" height="16" version="1.1" viewBox="0 0 12 16" width="12"><path d="M12 5l-8 8-4-4 1.5-1.5L4 10l6.5-6.5z"></path></svg>';
11+
const muteIcon = '<svg aria-hidden="true" class="octicon octicon-mute" height="16" version="1.1" viewBox="0 0 16 16" width="16"><path d="M8 2.81v10.38c0 .67-.81 1-1.28.53L3 10H1c-.55 0-1-.45-1-1V7c0-.55.45-1 1-1h2l3.72-3.72C7.19 1.81 8 2.14 8 2.81zm7.53 3.22l-1.06-1.06-1.97 1.97-1.97-1.97-1.06 1.06L11.44 8 9.47 9.97l1.06 1.06 1.97-1.97 1.97 1.97 1.06-1.06L13.56 8l1.97-1.97z"></path></svg>';
12+
13+
window.markUnread = (() => {
14+
function stripHash(url) {
15+
return url.replace(/#.+$/, '');
16+
}
17+
18+
function addMarkUnreadButton() {
19+
const button = $('<button class="btn btn-sm btn-mark-unread js-mark-unread">Mark as unread</button>');
20+
$('.js-thread-subscription-status').append(button);
21+
}
22+
23+
function markRead(url) {
24+
const unreadNotifications = JSON.parse(localStorage.unreadNotifications || '[]');
25+
unreadNotifications.forEach((notification, index) => {
26+
if (notification.url === url) {
27+
unreadNotifications.splice(index, 1);
28+
}
29+
});
30+
31+
localStorage.unreadNotifications = JSON.stringify(unreadNotifications);
32+
}
33+
34+
function markUnread() {
35+
$(this).attr('disabled', 'disabled');
36+
$(this).text('Marked as unread');
37+
38+
const participants = $('.participant-avatar').toArray().map(el => {
39+
const $el = $(el);
40+
41+
return {
42+
username: $el.attr('aria-label'),
43+
avatar: $el.find('img').attr('src')
44+
};
45+
});
46+
47+
const {ownerName, repoName} = pageDetect.getOwnerAndRepo();
48+
const repository = `${ownerName}/${repoName}`;
49+
const title = $('.js-issue-title').text().trim();
50+
const type = pageDetect.isPR() ? 'pull-request' : 'issue';
51+
const url = stripHash(location.href);
52+
53+
const stateLabel = $('.gh-header-meta .state');
54+
let state;
55+
56+
if (stateLabel.hasClass('state-open')) {
57+
state = 'open';
58+
}
59+
60+
if (stateLabel.hasClass('state-merged')) {
61+
state = 'merged';
62+
}
63+
64+
if (stateLabel.hasClass('state-closed')) {
65+
state = 'closed';
66+
}
67+
68+
const lastCommentTime = $('.timeline-comment-header:last relative-time');
69+
const dateTitle = lastCommentTime.attr('title');
70+
const date = lastCommentTime.attr('datetime');
71+
72+
const unreadNotifications = JSON.parse(localStorage.unreadNotifications || '[]');
73+
unreadNotifications.push({
74+
participants,
75+
repository,
76+
title,
77+
state,
78+
type,
79+
dateTitle,
80+
date,
81+
url
82+
});
83+
84+
localStorage.unreadNotifications = JSON.stringify(unreadNotifications);
85+
}
86+
87+
function renderNotifications() {
88+
const unreadNotifications = JSON.parse(localStorage.unreadNotifications || '[]')
89+
.filter(notification => !isNotificationExist(notification.url))
90+
.filter(notification => {
91+
if (!isParticipatingPage()) {
92+
return true;
93+
}
94+
95+
const {participants} = notification;
96+
const myUserName = getUserName();
97+
98+
return participants
99+
.filter(participant => participant.username === myUserName)
100+
.length > 0;
101+
});
102+
103+
if (unreadNotifications.length === 0) {
104+
return;
105+
}
106+
107+
if (isEmptyPage()) {
108+
$('.blankslate').remove();
109+
$('.js-navigation-container').append('<div class="notifications-list"></div>');
110+
}
111+
112+
unreadNotifications.forEach(notification => {
113+
const {
114+
participants,
115+
repository,
116+
title,
117+
state,
118+
type,
119+
dateTitle,
120+
date,
121+
url
122+
} = notification;
123+
124+
let icon;
125+
126+
if (type === 'issue') {
127+
if (state === 'open') {
128+
icon = openIssueIcon;
129+
}
130+
131+
if (state === 'closed') {
132+
icon = closedIssueIcon;
133+
}
134+
}
135+
136+
if (type === 'pull-request') {
137+
if (state === 'open') {
138+
icon = openPullRequestIcon;
139+
}
140+
141+
if (state === 'merged') {
142+
icon = mergedPullRequestIcon;
143+
}
144+
145+
if (state === 'closed') {
146+
icon = closedPullRequestIcon;
147+
}
148+
}
149+
150+
const hasList = $(`a.notifications-repo-link[title="${repository}"]`).length > 0;
151+
if (!hasList) {
152+
const list = $(`
153+
<div class="boxed-group flush">
154+
<form class="boxed-group-action">
155+
<button class="mark-all-as-read css-truncate tooltipped tooltipped-w js-mark-all-read" aria-label="Mark all notifications as read">
156+
${checkIcon}
157+
</button>
158+
</form>
159+
<h3>
160+
<a href="/${repository}" class="css-truncate css-truncate-target notifications-repo-link" title="${repository}">
161+
${repository}
162+
</a>
163+
</h3>
164+
<ul class="boxed-group-inner list-group notifications"></ul>
165+
</div>
166+
`);
167+
168+
$('.notifications-list').prepend(list);
169+
}
170+
171+
const list = $(`a.notifications-repo-link[title="${repository}"]`).parent().siblings('ul.notifications');
172+
173+
const usernames = participants
174+
.map(participant => participant.username)
175+
.join(', ');
176+
177+
const avatars = participants
178+
.map(participant => {
179+
return `
180+
<img alt="@${participant.username}" class="avatar from-avatar" src="${participant.avatar}" width="39" height="39">
181+
`;
182+
})
183+
.join('');
184+
185+
const item = $(`
186+
<li class="list-group-item js-notification js-navigation-item unread ${type}-notification">
187+
<span class="list-group-item-name css-truncate">
188+
${icon}
189+
<a class="css-truncate-target js-notification-target js-navigation-open list-group-item-link" href="${url}">
190+
${title}
191+
</a>
192+
</span>
193+
<ul class="notification-actions">
194+
<li class="delete">
195+
<button aria-label="Mark as read" class="btn-link delete-note tooltipped tooltipped-w js-mark-read">
196+
${checkIcon}
197+
</button>
198+
</li>
199+
<li class="mute">
200+
<button style="opacity: 0; pointer-events: none;">
201+
${muteIcon}
202+
</button>
203+
</li>
204+
<li class="age">
205+
<relative-time datetime="${date}" title="${dateTitle}"></relative-time>
206+
</li>
207+
<li class="tooltipped tooltipped-s" aria-label="${usernames}">
208+
<div class="avatar-stack clearfix">
209+
${avatars}
210+
</div>
211+
</li>
212+
</ul>
213+
</li>
214+
`);
215+
216+
list.prepend(item);
217+
});
218+
}
219+
220+
function isNotificationExist(url) {
221+
return $('a.js-notification-target')
222+
.toArray()
223+
.filter(link => stripHash($(link).attr('href')) === stripHash(url))
224+
.length > 0;
225+
}
226+
227+
function isEmptyPage() {
228+
return $('.blankslate').length > 0;
229+
}
230+
231+
function isParticipatingPage() {
232+
return /\/notifications\/participating/.test(location.pathname);
233+
}
234+
235+
function getUserName() {
236+
return $('#user-links a.name img').attr('alt').slice(1);
237+
}
238+
239+
function markNotificationRead(e) {
240+
const notification = $(e.target).parents('li.js-notification');
241+
notification.addClass('read');
242+
243+
const url = notification.find('a.js-notification-target').attr('href');
244+
markRead(url);
245+
}
246+
247+
function markAllNotificationsRead(e) {
248+
e.preventDefault();
249+
250+
$(e.target)
251+
.parents('.boxed-group')
252+
.find('ul.notifications li a.js-notification-target')
253+
.toArray()
254+
.forEach(el => {
255+
$(el).parents('.js-notification').removeClass('unread').addClass('read');
256+
markRead(el.href);
257+
});
258+
}
259+
260+
function setup() {
261+
if (pageDetect.isNotifications()) {
262+
renderNotifications();
263+
$(document).on('click', '.js-mark-read', markNotificationRead);
264+
$(document).on('click', '.js-mark-all-read', markAllNotificationsRead);
265+
} else {
266+
markRead(location.href);
267+
addMarkUnreadButton();
268+
$(document).one('click', '.js-mark-unread', markUnread);
269+
}
270+
}
271+
272+
function destroy() {
273+
$(document).off('click', '.js-mark-unread', markUnread);
274+
$('.js-mark-unread').remove();
275+
}
276+
277+
return {setup, destroy};
278+
})();

extension/page-detect.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ window.pageDetect = (() => {
3333

3434
const isBlame = () => isRepo() && /^\/blame\//.test(getRepoPath());
3535

36+
const isNotifications = () => /\/notifications(\/participating)?/.test(location.pathname);
37+
3638
const getOwnerAndRepo = () => {
3739
const [, ownerName, repoName] = location.pathname.split('/');
3840

@@ -65,6 +67,7 @@ window.pageDetect = (() => {
6567
isCommit,
6668
isReleases,
6769
isBlame,
70+
isNotifications,
6871
getOwnerAndRepo,
6972
isSingleFile
7073
};

0 commit comments

Comments
 (0)