summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--contributing.md8
-rw-r--r--source/features/batch-open-issues.tsx9
-rw-r--r--source/features/copy-file.tsx18
-rw-r--r--source/features/cycle-lists-with-keyboard-shortcuts.tsx8
-rw-r--r--source/features/default-to-rich-diff.tsx5
-rw-r--r--source/features/extend-diff-expander.tsx2
-rw-r--r--source/features/fix-view-file-link-in-pr.tsx2
-rw-r--r--source/features/hidden-review-comments-indicator.tsx10
-rw-r--r--source/features/hide-comments-faster.tsx64
-rw-r--r--source/features/hide-useless-comments.tsx8
-rw-r--r--source/features/limit-commit-title-length.tsx30
-rw-r--r--source/features/mark-unread.tsx9
-rw-r--r--source/features/minimize-user-comments.tsx7
-rw-r--r--source/features/pr-branch-auto-delete.tsx2
-rw-r--r--source/features/pr-filters.tsx35
-rw-r--r--source/features/quick-mention.tsx2
-rw-r--r--source/features/quick-review-buttons.tsx1
-rw-r--r--source/features/raw-file-link.tsx2
-rw-r--r--source/features/revert-file.tsx19
-rw-r--r--source/features/submit-review-as-single-comment.tsx4
-rw-r--r--source/features/sync-pr-commit-title.tsx30
-rw-r--r--source/features/toggle-everything-with-alt.tsx6
-rw-r--r--source/features/update-pr-from-base-branch.tsx2
-rw-r--r--source/features/wait-for-build.tsx6
-rw-r--r--source/features/warning-for-disallow-edits.tsx45
-rw-r--r--source/globals.d.ts2
-rw-r--r--source/libs/on-new-comments.ts43
-rw-r--r--source/libs/on-pr-file-load.ts33
-rw-r--r--source/libs/on-pr-merge-panel-open.ts34
29 files changed, 244 insertions, 202 deletions
diff --git a/contributing.md b/contributing.md
index 2a094394..7b01ced8 100644
--- a/contributing.md
+++ b/contributing.md
@@ -38,16 +38,20 @@ Here's an example using all of the possible `feature.add` options:
```ts
import React from 'dom-chef';
import select from 'select-dom';
+import delegate, {DelegateSubscription} from 'delegate-it';
import features from '../libs/features';
+let delegate;
function log() {
console.log('✨', <div className="rgh-jsx-element"/>);
}
function init(): void {
- select('.btn')!.addEventListener('click', log);
+ // Events must be set via delegate, unless shortlived
+ delegate = delegate('.btn', 'click', log);
}
function deinit(): void {
- select('.btn')!.removeEventListener('load', log);
+ delegate?.destroy();
+ delegate = undefined;
}
features.add({
diff --git a/source/features/batch-open-issues.tsx b/source/features/batch-open-issues.tsx
index f85c62a7..0a53a3ee 100644
--- a/source/features/batch-open-issues.tsx
+++ b/source/features/batch-open-issues.tsx
@@ -1,5 +1,6 @@
import React from 'dom-chef';
import select from 'select-dom';
+import delegate from 'delegate-it';
import features from '../libs/features';
const confirmationRequiredCount = 10;
@@ -35,13 +36,14 @@ function init(): void | false {
return false;
}
+ delegate('.rgh-batch-open-issues', 'click', openIssues);
+
const filtersBar = select('.table-list-header-toggle:not(.states)');
if (filtersBar) {
filtersBar.prepend(
<button
type="button"
- onClick={openIssues}
- className="btn-link rgh-open-all-selected pr-2"
+ className="btn-link rgh-batch-open-issues pr-2"
>
Open All
</button>
@@ -54,8 +56,7 @@ function init(): void | false {
triageFiltersBar.append(
<button
type="button"
- onClick={openIssues}
- className="btn-link rgh-open-all-selected pl-3"
+ className="btn-link rgh-batch-open-issues pl-3"
>
Open all
</button>
diff --git a/source/features/copy-file.tsx b/source/features/copy-file.tsx
index 6a110fa8..6b4660ab 100644
--- a/source/features/copy-file.tsx
+++ b/source/features/copy-file.tsx
@@ -1,10 +1,10 @@
import React from 'dom-chef';
import select from 'select-dom';
-import delegate from 'delegate-it';
+import delegate, {DelegateEvent} from 'delegate-it';
import copyToClipboard from 'copy-text-to-clipboard';
import features from '../libs/features';
-function handleClick({currentTarget: button}: React.MouseEvent<HTMLButtonElement>): void {
+function handleClick({delegateTarget: button}: DelegateEvent<MouseEvent, HTMLButtonElement>): void {
const file = button.closest('.Box, .js-gist-file-update-container');
const content = select.all('.blob-code-inner', file!)
.map(({innerText: line}) => line === '\n' ? '' : line) // Must be `.innerText`
@@ -25,7 +25,6 @@ function renderButton(): void {
.parentElement! // `BtnGroup`
.prepend(
<button
- onClick={handleClick}
className="btn btn-sm tooltipped tooltipped-n BtnGroup-item rgh-copy-file"
aria-label="Copy file to clipboard"
type="button">
@@ -35,15 +34,16 @@ function renderButton(): void {
}
}
+function removeButton(): void {
+ select('.rgh-copy-file')?.remove();
+}
+
function init(): void {
+ delegate('.rgh-copy-file', 'click', handleClick);
+
if (select.exists('.blob.instapaper_body')) {
delegate('.rgh-md-source', 'rgh:view-markdown-source', renderButton);
- delegate('.rgh-md-source', 'rgh:view-markdown-rendered', () => {
- const button = select('.rgh-copy-file');
- if (button) {
- button.remove();
- }
- });
+ delegate('.rgh-md-source', 'rgh:view-markdown-rendered', removeButton);
} else {
renderButton();
}
diff --git a/source/features/cycle-lists-with-keyboard-shortcuts.tsx b/source/features/cycle-lists-with-keyboard-shortcuts.tsx
index 2077b323..a0283167 100644
--- a/source/features/cycle-lists-with-keyboard-shortcuts.tsx
+++ b/source/features/cycle-lists-with-keyboard-shortcuts.tsx
@@ -46,12 +46,8 @@ function init(): void {
// Input fields for projects and milestones are added dynamically to the page
// GitHub triggers events on the document element for us, which can be used to detect new input elements
- delegate(document, '.js-filterable-field', 'filterable:change', event => {
- populateSelectableItems();
-
- const input = event.delegateTarget as HTMLElement;
- input.addEventListener('keydown', handleKeyDown);
- });
+ delegate(document, '.js-filterable-field', 'filterable:change', populateSelectableItems);
+ delegate(document, '.js-filterable-field', 'keydown', handleKeyDown);
}
features.add({
diff --git a/source/features/default-to-rich-diff.tsx b/source/features/default-to-rich-diff.tsx
index f2c5a7b8..95c13438 100644
--- a/source/features/default-to-rich-diff.tsx
+++ b/source/features/default-to-rich-diff.tsx
@@ -1,4 +1,5 @@
import select from 'select-dom';
+import delegate from 'delegate-it';
import features from '../libs/features';
function run(): void {
@@ -30,9 +31,7 @@ function init(): void {
run();
// Some files are loaded progressively later. On load, look for more buttons and more fragments
- for (const fragment of select.all('include-fragment.diff-progressive-loader')) {
- fragment.addEventListener('load', init);
- }
+ delegate('include-fragment.diff-progressive-loader', 'load', run);
}
features.add({
diff --git a/source/features/extend-diff-expander.tsx b/source/features/extend-diff-expander.tsx
index cfca695a..6581d567 100644
--- a/source/features/extend-diff-expander.tsx
+++ b/source/features/extend-diff-expander.tsx
@@ -11,7 +11,7 @@ function expandDiff(event: DelegateEvent): void {
}
function init(): void {
- delegate('.diff-view', '.js-expandable-line', 'click', expandDiff);
+ delegate('.diff-view .js-expandable-line', 'click', expandDiff);
}
features.add({
diff --git a/source/features/fix-view-file-link-in-pr.tsx b/source/features/fix-view-file-link-in-pr.tsx
index 6b561910..75a4e605 100644
--- a/source/features/fix-view-file-link-in-pr.tsx
+++ b/source/features/fix-view-file-link-in-pr.tsx
@@ -36,7 +36,7 @@ function handleMenuOpening(event: DelegateEvent): void {
}
function init(): void {
- delegate('#files', '.js-file-header-dropdown > summary', 'click', handleMenuOpening);
+ delegate('.js-file-header-dropdown > summary', 'click', handleMenuOpening);
}
features.add({
diff --git a/source/features/hidden-review-comments-indicator.tsx b/source/features/hidden-review-comments-indicator.tsx
index 4ae15126..3ba23e64 100644
--- a/source/features/hidden-review-comments-indicator.tsx
+++ b/source/features/hidden-review-comments-indicator.tsx
@@ -3,15 +3,16 @@ import mem from 'mem';
import React from 'dom-chef';
import select from 'select-dom';
import commentIcon from 'octicon/comment.svg';
+import delegate, {DelegateEvent} from 'delegate-it';
import features from '../libs/features';
import anchorScroll from '../libs/anchor-scroll';
import onPrFileLoad from '../libs/on-pr-file-load';
// When an indicator is clicked, this will show comments on the current file
-const handleIndicatorClick = ({currentTarget}: React.MouseEvent<HTMLElement>): void => {
- const commentedLine = currentTarget.closest('tr')!.previousElementSibling!;
+const handleIndicatorClick = ({delegateTarget}: DelegateEvent): void => {
+ const commentedLine = delegateTarget.closest('tr')!.previousElementSibling!;
const resetScroll = anchorScroll(commentedLine);
- currentTarget
+ delegateTarget
.closest('.file.js-file')!
.querySelector<HTMLInputElement>('.js-toggle-file-notes')!
.click();
@@ -25,7 +26,7 @@ const addIndicator = mem((commentThread: HTMLElement): void => {
commentThread.before(
<tr>
- <td className="rgh-comments-indicator blob-num" colSpan={2} onClick={handleIndicatorClick}>
+ <td className="rgh-comments-indicator blob-num" colSpan={2}>
<button type="button" className="btn-link">
{commentIcon()}
<span>{commentCount}</span>
@@ -63,6 +64,7 @@ function observeFiles(): void {
function init(): void {
observeFiles();
onPrFileLoad(observeFiles);
+ delegate('.rgh-comments-indicator', 'click', handleIndicatorClick);
}
features.add({
diff --git a/source/features/hide-comments-faster.tsx b/source/features/hide-comments-faster.tsx
index 14fa9165..50bebeeb 100644
--- a/source/features/hide-comments-faster.tsx
+++ b/source/features/hide-comments-faster.tsx
@@ -3,22 +3,21 @@ import select from 'select-dom';
import delegate, {DelegateEvent} from 'delegate-it';
import features from '../libs/features';
-function handleMenuOpening(event: DelegateEvent): void {
- const hideButton = select('.js-comment-hide-button', event.delegateTarget.parentElement!);
- if (!hideButton) {
- // User unable to hide or menu already created
+function generateSubmenu(hideButton: Element): void {
+ if (hideButton.closest('.rgh-hide-comments-faster-details')) {
+ // Already generated
return;
}
- // Disable default behavior
- hideButton.classList.remove('js-comment-hide-button');
+ const detailsElement = hideButton.closest('details')!;
+ detailsElement.classList.add('rgh-hide-comments-faster-details');
const comment = hideButton.closest('.unminimized-comment')!;
- const form = select('.js-comment-minimize', comment)!;
+ const hideCommentForm = select('.js-comment-minimize', comment)!;
// Generate dropdown items
for (const reason of select.all<HTMLInputElement>('[name="classifier"] :not([value=""])', comment)) {
- form.append(
+ hideCommentForm.append(
<button
name="classifier"
value={reason.value}
@@ -30,33 +29,44 @@ function handleMenuOpening(event: DelegateEvent): void {
}
// Drop previous form controls
- select('.btn', form)!.remove();
- select('[name="classifier"]', form)!.remove();
+ select('.btn', hideCommentForm)!.remove();
+ select('[name="classifier"]', hideCommentForm)!.remove();
// Imitate existing menu
- form.classList.add('dropdown-menu', 'dropdown-menu-sw', 'text-gray-dark', 'show-more-popover', 'anim-scale-in');
+ hideCommentForm.classList.add('dropdown-menu', 'dropdown-menu-sw', 'text-gray-dark', 'show-more-popover', 'anim-scale-in');
- // Show menu on top of optionList when "Hide" is clicked
- // Hide it when dropdown closes.
- // Uses `v-hidden` and `d-none` to avoid conflicts with `close-out-of-view-modals`
+ detailsElement.append(hideCommentForm);
+}
+
+// Shows menu on top of mainDropdownContent when "Hide" is clicked;
+// Hide it when dropdown closes.
+// Uses `v-hidden` and `d-none` to avoid conflicts with `close-out-of-view-modals`
+function toggleSubMenu(hideButton: Element, show: boolean): void {
const dropdown = hideButton.closest('details')!;
- const optionList = select('.show-more-popover', dropdown)!;
- hideButton.addEventListener('click', event => {
- event.stopImmediatePropagation();
- event.preventDefault();
- optionList.classList.add('v-hidden');
- form.classList.remove('d-none');
- });
- dropdown.addEventListener('toggle', () => {
- optionList.classList.remove('v-hidden');
- form.classList.add('d-none');
- });
- dropdown.append(form);
+ // Native dropdown
+ select('details-menu', dropdown)!.classList.toggle('v-hidden', show);
+
+ // "Hide comment" dropdown
+ select('form.js-comment-minimize', dropdown)!.classList.toggle('d-none', !show);
+}
+
+function resetDropdowns(event: DelegateEvent): void {
+ toggleSubMenu(event.delegateTarget, false);
+}
+
+function showSubmenu(event: DelegateEvent): void {
+ generateSubmenu(event.delegateTarget);
+ toggleSubMenu(event.delegateTarget, true);
+
+ event.stopImmediatePropagation();
+ event.preventDefault();
}
function init(): void {
- delegate('.timeline-comment-action', 'click', handleMenuOpening);
+ // `useCapture` required to be fired before GitHub's handlers
+ delegate('.js-comment-hide-button', 'click', showSubmenu, true);
+ delegate('.rgh-hide-comments-faster-details', 'toggle', resetDropdowns, true);
}
features.add({
diff --git a/source/features/hide-useless-comments.tsx b/source/features/hide-useless-comments.tsx
index be0b2ac1..edac8fa1 100644
--- a/source/features/hide-useless-comments.tsx
+++ b/source/features/hide-useless-comments.tsx
@@ -1,6 +1,7 @@
import './hide-useless-comments.css';
import React from 'dom-chef';
import select from 'select-dom';
+import delegate, {DelegateEvent} from 'delegate-it';
import features from '../libs/features';
function init(): void {
@@ -40,19 +41,20 @@ function init(): void {
select('.discussion-timeline-actions')!.prepend(
<p className="rgh-useless-comments-note">
{`${uselessCount} unhelpful comment${uselessCount > 1 ? 's were' : ' was'} automatically hidden. `}
- <button className="btn-link text-emphasized" onClick={unhide}>Show</button>
+ <button className="btn-link text-emphasized rgh-unhide-useless-comments">Show</button>
</p>
);
+ delegate('.rgh-unhide-useless-comments', 'click', unhide);
}
}
-function unhide(event: React.MouseEvent<HTMLButtonElement>): void {
+function unhide(event: DelegateEvent<MouseEvent, HTMLButtonElement>): void {
for (const comment of select.all('.rgh-hidden-comment')) {
comment.hidden = false;
}
select('.rgh-hidden-comment')!.scrollIntoView();
- event.currentTarget.parentElement!.remove();
+ event.delegateTarget.parentElement!.remove();
}
features.add({
diff --git a/source/features/limit-commit-title-length.tsx b/source/features/limit-commit-title-length.tsx
index 81d60111..ac94fc09 100644
--- a/source/features/limit-commit-title-length.tsx
+++ b/source/features/limit-commit-title-length.tsx
@@ -1,28 +1,28 @@
import './limit-commit-title-length.css';
import select from 'select-dom';
+import delegate, {DelegateEvent} from 'delegate-it';
import features from '../libs/features';
import onPrMergePanelOpen from '../libs/on-pr-merge-panel-open';
-function init(): void {
- const inputField = select<HTMLInputElement>([
- '#commit-summary-input', // Commit title on edit file page
- '#merge_title_field' // PR merge message field
- ]);
+const fieldSelector = [
+ '#commit-summary-input', // Commit title on edit file page
+ '#merge_title_field' // PR merge message field
+].join();
- // The input field doesn't exist on PR merge page if you don't have access to that repo
- if (!inputField) {
- return;
- }
+function validateInput({delegateTarget: inputField}: DelegateEvent<InputEvent, HTMLInputElement>): void {
+ inputField.setCustomValidity(inputField.value.length > 72 ? `The title should be maximum 72 characters, but is ${inputField.value.length}` : '');
+}
- inputField.addEventListener('input', () => {
- inputField.setCustomValidity(inputField.value.length > 72 ? `The title should be maximum 72 characters, but is ${inputField.value.length}` : '');
- });
+function triggerValidation(): void {
+ select(fieldSelector)!.dispatchEvent(new Event('input'));
+}
+
+function init(): void {
+ delegate(fieldSelector, 'input', validateInput);
// For PR merges, GitHub restores any saved commit messages on page load
// Triggering input event for these fields immediately validates the form
- onPrMergePanelOpen(() => {
- inputField.dispatchEvent(new Event('input'));
- });
+ onPrMergePanelOpen(triggerValidation);
}
features.add({
diff --git a/source/features/mark-unread.tsx b/source/features/mark-unread.tsx
index 373800da..dc8a2846 100644
--- a/source/features/mark-unread.tsx
+++ b/source/features/mark-unread.tsx
@@ -71,7 +71,7 @@ function stripHash(url: string): string {
function addMarkUnreadButton(): void {
if (!select.exists('.rgh-btn-mark-unread')) {
select('.thread-subscription-status')!.after(
- <button className="btn btn-sm btn-block mt-2 rgh-btn-mark-unread" onClick={markUnread}>
+ <button className="btn btn-sm btn-block mt-2 rgh-btn-mark-unread">
Mark as unread
</button>
);
@@ -96,7 +96,7 @@ async function markRead(urls: string|string[]): Promise<void> {
await setNotifications(updated);
}
-async function markUnread({currentTarget}: React.MouseEvent): Promise<void> {
+async function markUnread({delegateTarget}: DelegateEvent): Promise<void> {
const participants: Participant[] = select.all('.participant-avatar').slice(0, 3).map(element => ({
username: element.getAttribute('aria-label')!,
avatar: element.querySelector('img')!.src
@@ -135,8 +135,8 @@ async function markUnread({currentTarget}: React.MouseEvent): Promise<void> {
await setNotifications(unreadNotifications);
await updateUnreadIndicator();
- currentTarget.setAttribute('disabled', 'disabled');
- currentTarget.textContent = 'Marked as unread';
+ delegateTarget.setAttribute('disabled', 'disabled');
+ delegateTarget.textContent = 'Marked as unread';
}
function getNotification(notification: Notification): Element {
@@ -423,6 +423,7 @@ async function init(): Promise<void> {
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()) {
diff --git a/source/features/minimize-user-comments.tsx b/source/features/minimize-user-comments.tsx
index 79c9b940..1cb6e6d1 100644
--- a/source/features/minimize-user-comments.tsx
+++ b/source/features/minimize-user-comments.tsx
@@ -32,8 +32,8 @@ function toggleComment(comment: HTMLElement, minimize: boolean): void {
select('.minimized-comment .Details-element', comment)!.toggleAttribute('open', !minimize);
}
-async function onButtonClick(event: React.MouseEvent<HTMLButtonElement>): Promise<void> {
- const comment = event.currentTarget.closest('.js-targetable-comment')!;
+async function handleMinimizeClick(event: DelegateEvent): Promise<void> {
+ const comment = event.delegateTarget.closest('.js-targetable-comment')!;
const user = getUsernameFromComment(comment);
let minimizedUsers = await getMinimizedUsers();
@@ -71,7 +71,7 @@ async function handleMenuOpening(event: DelegateEvent): Promise<void> {
className="dropdown-item btn-link rgh-minimize-user-comments-button"
role="menuitem"
type="button"
- onClick={onButtonClick}>
+ >
{getLabel(shouldMinimizeComment)}
</button>
);
@@ -94,6 +94,7 @@ function init(): void {
onNewComments(minimizeMutedUserComments);
// `summary` excludes the `edit-comments-faster` button
delegate('summary.timeline-comment-action:not([aria-label="Add your reaction"])', 'click', handleMenuOpening);
+ delegate('.rgh-minimize-user-comments-button', 'click', handleMinimizeClick);
}
features.add({
diff --git a/source/features/pr-branch-auto-delete.tsx b/source/features/pr-branch-auto-delete.tsx
index acfd28fa..bdeadaf2 100644
--- a/source/features/pr-branch-auto-delete.tsx
+++ b/source/features/pr-branch-auto-delete.tsx
@@ -4,7 +4,7 @@ import features from '../libs/features';
import observeEl from '../libs/simplified-element-observer';
function init(): void {
- const [subscription] = delegate('#discussion_bucket', '.js-merge-commit-button', 'click', () => {
+ const subscription = delegate('.js-merge-commit-button', 'click', () => {
subscription.destroy();
observeEl('.discussion-timeline-actions', (_, observer) => {
diff --git a/source/features/pr-filters.tsx b/source/features/pr-filters.tsx
index 490fae35..2e3ae059 100644
--- a/source/features/pr-filters.tsx
+++ b/source/features/pr-filters.tsx
@@ -1,14 +1,18 @@
import React from 'dom-chef';
import select from 'select-dom';
+import delegate, {DelegateEvent} from 'delegate-it';
import checkIcon from 'octicon/check.svg';
import features from '../libs/features';
import {getIcon as fetchCIStatus} from './ci-link';
-let currentQuerySegments: string[];
+// The Reviews dropdown doesn't have a specific class, so we expect this sequence (span contains Projects and Milestones)
+const reviewsFilterSelector = '#label-select-menu + span + details';
function addDropdownItem(dropdown: HTMLElement, title: string, filterCategory: string, filterValue: string): void {
const filterQuery = `${filterCategory}:${filterValue}`;
+ const searchParameter = new URLSearchParams(location.search);
+ const currentQuerySegments = searchParameter.get('q')?.split(/\s+/) ?? [];
const isSelected = currentQuerySegments.some(
segment => segment.toLowerCase() === filterQuery
);
@@ -34,7 +38,14 @@ function addDropdownItem(dropdown: HTMLElement, title: string, filterCategory: s
);
}
-function addDraftFilter(reviewsFilter: HTMLElement): void {
+const hasDraftFilter = new WeakSet();
+function addDraftFilter({delegateTarget: reviewsFilter}: DelegateEvent): void {
+ if (hasDraftFilter.has(reviewsFilter)) {
+ return;
+ }
+
+ hasDraftFilter.add(reviewsFilter);
+
const dropdown = select('.select-menu-list', reviewsFilter)!;
dropdown.append(
@@ -47,7 +58,12 @@ function addDraftFilter(reviewsFilter: HTMLElement): void {
addDropdownItem(dropdown, 'Not ready for review (Draft PR)', 'draft', 'true');
}
-async function addStatusFilter(reviewsFilter: HTMLElement): Promise<void> {
+async function addStatusFilter(): Promise<void> {
+ const reviewsFilter = select(reviewsFilterSelector);
+ if (!reviewsFilter) {
+ return;
+ }
+
// TODO: replace this with an API call
const hasCI = await fetchCIStatus();
if (!hasCI) {
@@ -70,17 +86,8 @@ async function addStatusFilter(reviewsFilter: HTMLElement): Promise<void> {
}
function init(): void {
- const reviewsFilter = select('.table-list-header-toggle > details:nth-last-child(3)');
-
- if (!reviewsFilter) {
- return;
- }
-
- const searchParameter = new URLSearchParams(location.search);
- currentQuerySegments = (searchParameter.get('q') ?? '').split(/\s+/);
-
- reviewsFilter.addEventListener('toggle', () => addDraftFilter(reviewsFilter), {once: true});
- addStatusFilter(reviewsFilter);
+ delegate(reviewsFilterSelector, 'toggle', addDraftFilter, true);
+ addStatusFilter();
}
features.add({
diff --git a/source/features/quick-mention.tsx b/source/features/quick-mention.tsx
index 5f215d26..eca1e6be 100644
--- a/source/features/quick-mention.tsx
+++ b/source/features/quick-mention.tsx
@@ -43,7 +43,7 @@ function init(): void | false {
);
}
- delegate('#discussion_bucket', 'button.rgh-quick-mention', 'click', mentionUser);
+ delegate('button.rgh-quick-mention', 'click', mentionUser);
}
features.add({
diff --git a/source/features/quick-review-buttons.tsx b/source/features/quick-review-buttons.tsx
index 86cc35e8..8d47266b 100644
--- a/source/features/quick-review-buttons.tsx
+++ b/source/features/quick-review-buttons.tsx
@@ -74,6 +74,7 @@ function init(): false | void {
});
// Freeze form to avoid duplicate submissions
+ // TODO: maybe `data-disable-with` can do this like it does for the generic "Comment" button
form.addEventListener('submit', () => {
// Delay disabling the fields to let them be submitted first
setTimeout(() => {
diff --git a/source/features/raw-file-link.tsx b/source/features/raw-file-link.tsx
index 34f35e01..efd583ae 100644
--- a/source/features/raw-file-link.tsx
+++ b/source/features/raw-file-link.tsx
@@ -23,7 +23,7 @@ function handleMenuOpening(event: DelegateEvent): void {
}
function init(): void {
- delegate('#files', '.js-file-header-dropdown > summary', 'click', handleMenuOpening);
+ delegate('.js-file-header-dropdown > summary', 'click', handleMenuOpening);
}
features.add({
diff --git a/source/features/revert-file.tsx b/source/features/revert-file.tsx
index 9221d9cf..c0b0e25b 100644
--- a/source/features/revert-file.tsx
+++ b/source/features/revert-file.tsx
@@ -72,10 +72,17 @@ async function commitFileContent(menuItem: Element, content: string): Promise<vo
await postForm(form);
}
-async function handleRevertFileClick(event: React.MouseEvent<HTMLButtonElement>): Promise<void> {
- const menuItem = event.currentTarget;
- // Allow only one click
- menuItem.removeEventListener('click', handleRevertFileClick as unknown as EventListener);
+const filesReverted = new WeakSet<HTMLButtonElement>();
+async function handleRevertFileClick(event: DelegateEvent<MouseEvent, HTMLButtonElement>): Promise<void> {
+ const menuItem = event.delegateTarget;
+
+ // Only allow one click
+ if (filesReverted.has(menuItem)) {
+ return;
+ }
+
+ filesReverted.add(menuItem);
+
menuItem.textContent = 'Reverting…';
event.preventDefault();
event.stopPropagation();
@@ -120,7 +127,6 @@ function handleMenuOpening(event: DelegateEvent): void {
style={{whiteSpace: 'pre-wrap'}}
role="menuitem"
type="button"
- onClick={handleRevertFileClick}
>
Revert changes
</button>
@@ -128,7 +134,8 @@ function handleMenuOpening(event: DelegateEvent): void {
}
function init(): void {
- delegate('#files', '.js-file-header-dropdown > summary', 'click', handleMenuOpening);
+ delegate('.js-file-header-dropdown > summary', 'click', handleMenuOpening);
+ delegate('.rgh-revert-file', 'click', handleRevertFileClick, true);
}
features.add({
diff --git a/source/features/submit-review-as-single-comment.tsx b/source/features/submit-review-as-single-comment.tsx
index 32238e28..e2b44c05 100644
--- a/source/features/submit-review-as-single-comment.tsx
+++ b/source/features/submit-review-as-single-comment.tsx
@@ -101,8 +101,8 @@ async function handleSubmitSingle(event: DelegateEvent): Promise<void> {
}
function init(): void {
- delegate('#files', '[action$="/review_comment/create"]', 'submit', handleReviewSubmission);
- delegate('#files', '.rgh-submit-single', 'click', handleSubmitSingle);
+ delegate('#files [action$="/review_comment/create"]', 'submit', handleReviewSubmission);
+ delegate('.rgh-submit-single', 'click', handleSubmitSingle);
updateUI();
}
diff --git a/source/features/sync-pr-commit-title.tsx b/source/features/sync-pr-commit-title.tsx
index 78e0b686..93eea6e0 100644
--- a/source/features/sync-pr-commit-title.tsx
+++ b/source/features/sync-pr-commit-title.tsx
@@ -1,8 +1,7 @@
import React from 'dom-chef';
import select from 'select-dom';
-import onetime from 'onetime';
import debounce from 'debounce-fn';
-import delegate, {DelegateSubscription} from 'delegate-it';
+import delegate, {DelegateSubscription, DelegateEvent} from 'delegate-it';
import insertTextTextarea from 'insert-text-textarea';
import features from '../libs/features';
import onPrMergePanelOpen from '../libs/on-pr-merge-panel-open';
@@ -26,19 +25,23 @@ const createCommitTitle = debounce<[], string>((): string => {
immediate: true
});
-const getNote = onetime<[], HTMLElement>((): HTMLElement =>
- <p className="note">
- The title of this PR will be updated to match this title. <button type="button" className="btn-link muted-link text-underline" onClick={event => {
- deinit();
- event.currentTarget.parentElement!.remove(); // Hide note
- }}>Cancel</button>
- </p>
-);
+function getNote(): HTMLElement {
+ return select('.note.rgh-sync-pr-commit-title-note') ?? (
+ <p className="note rgh-sync-pr-commit-title-note">
+ The title of this PR will be updated to match this title. <button type="button" className="btn-link muted-link text-underline rgh-sync-pr-commit-title">Cancel</button>
+ </p>
+ );
+}
function getPRNumber(): string {
return select('.gh-header-number')!.textContent!;
}
+function handleCancelClick(event: DelegateEvent): void {
+ deinit();
+ event.delegateTarget.parentElement!.remove(); // Hide note
+}
+
function maybeShowNote(): void {
const inputField = select<HTMLInputElement>('#merge_title_field')!;
const needsSubmission = createCommitTitle() !== inputField.value;
@@ -89,9 +92,10 @@ function onMergePanelOpen(event: Event): void {
let listeners: DelegateSubscription[];
function init(): void {
listeners = [
- ...delegate('#discussion_bucket', '#merge_title_field', 'input', maybeShowNote),
- ...delegate('#discussion_bucket', 'form.js-merge-pull-request', 'submit', submitPRTitleUpdate),
- ...onPrMergePanelOpen(onMergePanelOpen)
+ delegate('#merge_title_field', 'input', maybeShowNote),
+ delegate('form.js-merge-pull-request', 'submit', submitPRTitleUpdate),
+ delegate('.rgh-sync-pr-commit-title', 'click', handleCancelClick),
+ onPrMergePanelOpen(onMergePanelOpen)
];
}
diff --git a/source/features/toggle-everything-with-alt.tsx b/source/features/toggle-everything-with-alt.tsx
index cc9aea48..a2ac4ab5 100644
--- a/source/features/toggle-everything-with-alt.tsx
+++ b/source/features/toggle-everything-with-alt.tsx
@@ -7,13 +7,13 @@ type EventHandler = (event: DelegateEvent<MouseEvent, HTMLElement>) => void;
function init(): void {
// Collapsed comments in PR conversations and files
- delegate('.repository-content', '.minimized-comment details summary', 'click', clickAll(minimizedCommentsSelector));
+ delegate('.minimized-comment details summary', 'click', clickAll(minimizedCommentsSelector));
// "Load diff" buttons in PR files
- delegate('.repository-content', '.js-file .js-diff-load', 'click', clickAll(allDiffsSelector));
+ delegate('.js-file .js-diff-load', 'click', clickAll(allDiffsSelector));
// Review comments in PR
- delegate('.repository-content', '.js-file .js-resolvable-thread-toggler', 'click', clickAll(resolvedCommentsSelector));
+ delegate('.js-file .js-resolvable-thread-toggler', 'click', clickAll(resolvedCommentsSelector));
}
function clickAll(selectorGetter: ((clickedItem: HTMLElement) => string)): EventHandler {
diff --git a/source/features/update-pr-from-base-branch.tsx b/source/features/update-pr-from-base-branch.tsx
index bc487809..b1f476aa 100644
--- a/source/features/update-pr-from-base-branch.tsx
+++ b/source/features/update-pr-from-base-branch.tsx
@@ -99,7 +99,7 @@ function init(): void | false {
}
observer = observeEl('.discussion-timeline-actions', addButton)!;
- delegate('.discussion-timeline-actions', '.rgh-update-pr-from-master', 'click', handler);
+ delegate('.rgh-update-pr-from-master', 'click', handler);
}
features.add({
diff --git a/source/features/wait-for-build.tsx b/source/features/wait-for-build.tsx
index 4947cb8f..b9169dbd 100644
--- a/source/features/wait-for-build.tsx
+++ b/source/features/wait-for-build.tsx
@@ -90,13 +90,11 @@ function init(): false | void {
onPrMergePanelOpen(showCheckboxIfNecessary);
- const container = select('.discussion-timeline-actions')!;
-
// One of the merge buttons has been clicked
- delegate(container, '.js-merge-commit-button', 'click', handleMergeConfirmation);
+ delegate('.js-merge-commit-button', 'click', handleMergeConfirmation);
// Cancel wait when the user presses the Cancel button
- delegate(container, '.commit-form-actions button:not(.js-merge-commit-button)', 'click', () => {
+ delegate('.commit-form-actions button:not(.js-merge-commit-button)', 'click', () => {
disableForm(false);
});
diff --git a/source/features/warning-for-disallow-edits.tsx b/source/features/warning-for-disallow-edits.tsx
index e7ca4b83..8432870c 100644
--- a/source/features/warning-for-disallow-edits.tsx
+++ b/source/features/warning-for-disallow-edits.tsx
@@ -1,33 +1,40 @@
import './warning-for-disallow-edits.css';
import React from 'dom-chef';
import select from 'select-dom';
+import oneTime from 'onetime';
+import delegate, {DelegateEvent} from 'delegate-it';
import features from '../libs/features';
+const getWarning = oneTime(() => (
+ <div className="flash flash-error mt-3 rgh-warning-for-disallow-edits">
+ <strong>Note:</strong> Maintainers may require changes. It’s easier and faster to allow them to make direct changes before merging.
+ </div>
+));
+
+function update(checkbox: HTMLInputElement): void {
+ if (checkbox.checked) {
+ getWarning().remove();
+ } else {
+ // Select every time because the sidebar content may be replaced
+ select(`
+ .new-pr-form .timeline-comment,
+ #partial-discussion-sidebar .js-collab-form + .js-dropdown-details
+ `)!.after(getWarning());
+ }
+}
+
+function toggleHandler(event: DelegateEvent<UIEvent, HTMLInputElement>): void {
+ update(event.delegateTarget);
+}
+
function init(): void {
const checkbox = select<HTMLInputElement>('[name="collab_privs"]');
if (!checkbox) {
return;
}
- const warning = (
- <div className="flash flash-error mt-3 rgh-warning-for-disallow-edits">
- <strong>Note:</strong> Maintainers may require changes. It’s easier and faster to allow them to make direct changes before merging.
- </div>
- );
- const update = (): void => {
- if (checkbox.checked) {
- warning.remove();
- } else {
- // Select every time because the sidebar content may be replaced
- select(`
- .new-pr-form .timeline-comment,
- #partial-discussion-sidebar .js-collab-form + .js-dropdown-details
- `)!.after(warning);
- }
- };
-
- update(); // The sidebar checkbox may already be un-checked
- checkbox.addEventListener('change', update);
+ update(checkbox); // The sidebar checkbox may already be un-checked
+ delegate('[name="collab_privs"]', 'change', toggleHandler);
}
features.add({
diff --git a/source/globals.d.ts b/source/globals.d.ts
index e00f9431..76117070 100644
--- a/source/globals.d.ts
+++ b/source/globals.d.ts
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// TODO: Drop some definitions when their related bugs are resolved
-// TODO: Improve JSX types for event listeners so we can use `MouseEvent` instead of `React.MouseEvent`, which is incompatible with regular `addEventListeners` calls
type AnyObject = Record<string, any>;
type AsyncVoidFunction = () => Promise<void>;
@@ -36,6 +35,7 @@ interface GlobalEventHandlersEventMap {
'rgh:view-markdown-source': CustomEvent;
'rgh:view-markdown-rendered': CustomEvent;
'filterable:change': CustomEvent;
+ 'page:loaded': CustomEvent;
}
declare namespace JSX {
diff --git a/source/libs/on-new-comments.ts b/source/libs/on-new-comments.ts
index 26c7d43b..265ad867 100644
--- a/source/libs/on-new-comments.ts
+++ b/source/libs/on-new-comments.ts
@@ -1,44 +1,51 @@
import select from 'select-dom';
-import observeEl from './simplified-element-observer';
+import delegate, {DelegateSubscription} from 'delegate-it';
+const discussionsWithListeners = new WeakSet();
const handlers = new Set<VoidFunction>();
-const observed = new WeakSet();
+const delegates = new Set<DelegateSubscription>();
+const observer = new MutationObserver(run);
function run(): void {
// Run all callbacks without letting an error stop the loop and without silencing it
handlers.forEach(async callback => callback());
}
-// On new page loads, run the callbacks and look for the new elements.
-// (addEventListener doesn't add duplicate listeners)
-function addListenersOnNewElements(): void {
- for (const loadMore of select.all('.js-ajax-pagination')) {
- loadMore.addEventListener('page:loaded', run);
- loadMore.addEventListener('page:loaded', addListenersOnNewElements);
+function removeListeners(): void {
+ for (const subscription of delegates) {
+ subscription.destroy();
}
- // Outdated comment are loaded later using an include-fragment element
- for (const fragment of select.all('details.outdated-comment > include-fragment')) {
- fragment.addEventListener('load', run);
- }
+ delegates.clear();
+ handlers.clear();
+ observer.disconnect();
}
-function setup(): void {
+function addListeners(): void {
const discussion = select('.js-discussion');
- if (!discussion || observed.has(discussion)) {
+ if (!discussion || discussionsWithListeners.has(discussion)) {
return;
}
- observed.add(discussion);
+ // Ensure listeners are only ever added once
+ discussionsWithListeners.add(discussion);
+
+ // Remember to remove all listeners when a new page is loaded
+ document.addEventListener('pjax:beforeReplace', removeListeners);
// When new comments come in via AJAX
- observeEl(discussion, run);
+ observer.observe(discussion, {
+ childList: true
+ });
// When hidden comments are loaded by clicking "Load more..."
- addListenersOnNewElements();
+ delegates.add(delegate('.js-ajax-pagination', 'page:loaded', run));
+
+ // Outdated comment are loaded later using an include-fragment element
+ delegates.add(delegate('details.outdated-comment > include-fragment', 'load', run, true));
}
export default function (callback: VoidFunction): void {
- setup();
+ addListeners();
handlers.add(callback);
}
diff --git a/source/libs/on-pr-file-load.ts b/source/libs/on-pr-file-load.ts
index b06c8d4a..1c3f3330 100644
--- a/source/libs/on-pr-file-load.ts
+++ b/source/libs/on-pr-file-load.ts
@@ -1,22 +1,19 @@
-import select from 'select-dom';
+import mem from 'mem';
+import delegate, {DelegateSubscription, DelegateEventHandler, DelegateEvent} from 'delegate-it';
-// In PRs' Files tab, some files are loaded progressively later.
-const handlers = new WeakMap<EventListener, EventListener>();
+const fragmentSelector = [
+ 'include-fragment.diff-progressive-loader', // Incremental file loader on scroll
+ 'include-fragment.js-diff-entry-loader' // File diff loader on clicking "Load Diff"
+].join();
-export default function onPrFileLoad(callback: EventListener): void {
- // When a fragment loads, more fragments might be nested in it. The following code avoids duplicate event handlers.
- const recursiveCallback = handlers.get(callback) ?? ((event: Event) => {
- callback(event);
- onPrFileLoad(callback);
- });
- handlers.set(callback, recursiveCallback);
+// This lets you call `onPrFileLoad` multiple times with the same callback but only ever a `load` listener is registered
+const getDeduplicatedHandler = mem((callback: EventListener): DelegateEventHandler => {
+ return (event: DelegateEvent) => event.delegateTarget.addEventListener('load', callback);
+});
- const fragments = select.all([
- 'include-fragment.diff-progressive-loader', // Incremental file loader on scroll
- 'include-fragment.js-diff-entry-loader' // File diff loader on clicking "Load Diff"
- ].join());
-
- for (const fragment of fragments) {
- fragment.addEventListener('load', recursiveCallback);
- }
+export default function onPrFileLoad(callback: EventListener): DelegateSubscription {
+ // `loadstart` is fired when the fragment is still attached so event delegation works.
+ // `load` is fired after it’s detached, so `delegate` would never listen to it.
+ // This is why we listen to a global `loadstart` and then add a specific `load` listener on the element, which is fired even when the element is detached.
+ return delegate(fragmentSelector, 'loadstart', getDeduplicatedHandler(callback), true);
}
diff --git a/source/libs/on-pr-merge-panel-open.ts b/source/libs/on-pr-merge-panel-open.ts
index d8df675b..6ed88d48 100644
--- a/source/libs/on-pr-merge-panel-open.ts
+++ b/source/libs/on-pr-merge-panel-open.ts
@@ -13,27 +13,25 @@ const sessionResumeHandler = mem((callback: EventListener) => async (event: Even
callback(event);
});
-export default function (callback: EventListener): DelegateSubscription[] {
+export default function (callback: EventListener): DelegateSubscription {
document.addEventListener(
'session:resume',
sessionResumeHandler(callback)
);
+ const toggleSubscription = delegate(
+ '.js-merge-pr:not(.is-rebasing)',
+ 'details:toggled',
+ delegateHandler(callback)
+ );
- return [
- {
- // Imitate a DelegateSubscription for this event as well
- destroy() {
- document.removeEventListener(
- 'session:resume',
- sessionResumeHandler(callback)
- );
- }
- },
- ...delegate(
- '#discussion_bucket',
- '.js-merge-pr:not(.is-rebasing)',
- 'details:toggled',
- delegateHandler(callback)
- )
- ];
+ // Imitate a DelegateSubscription for this event as well
+ return {
+ destroy() {
+ toggleSubscription.destroy();
+ document.removeEventListener(
+ 'session:resume',
+ sessionResumeHandler(callback)
+ );
+ }
+ };
}