NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Fimfiction Comment Bookmarks // @description Comment bookmarking on Fimfiction.net // @version 1.0.8 // @author Marker // @license MIT // @namespace https://github.com/marktaiwan/ // @homepageURL https://github.com/marktaiwan/Fimfiction-Comment-Bookmarks // @supportURL https://github.com/marktaiwan/Fimfiction-Comment-Bookmarks/issues // @include https://www.fimfiction.net/* // @grant none // @run-at document-start // @noframes // ==/UserScript== (function () { 'use strict'; const SCRIPT_LABEL = 'markers_comment_bookmarks'; const CATEGORY_LIST = ['story', 'blog', 'user', 'group', 'group_forum']; const DB_VERSION = 1; const BC_SUPPORT = ('BroadcastChannel' in window); /** Modified from https://gist.github.com/MoOx/8614711 */ function composeElement(obj) { /** https://gist.github.com/youssman/745578062609e8acac9f * camelToDash('userId') => "user-id" */ function camelToDash(str) { return str.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase(); } let ele; if (obj.tag !== undefined) { ele = document.createElement(obj.tag); if (obj.attributes !== undefined) { for (const attr in obj.attributes) { if (obj.attributes.hasOwnProperty(attr)) { ele.setAttribute(camelToDash(attr), obj.attributes[attr]); } } } } else { ele = document.createDocumentFragment(); } if (obj.html !== undefined) ele.innerHTML = obj.html; if (obj.text) ele.appendChild(document.createTextNode(obj.text)); if (Array.isArray(obj.children)) { for (const child of obj.children) { ele.appendChild((child instanceof window.HTMLElement) ? child : composeElement(child)); } } return ele; } const onReady = (() => { const callbacks = []; document.addEventListener('DOMContentLoaded', () => callbacks.forEach(fn => fn()), {once: true}); return (fn) => { if (document.readyState == 'loading') { callbacks.push(fn); } else { fn(); } }; })(); function initCSS() { const styleElement = document.createElement('style'); styleElement.id = `${SCRIPT_LABEL}-css`; styleElement.type = 'text/css'; styleElement.innerHTML = ` /* Generated by userscript "FiM Comment Bookmark" */ /* colors */ #${SCRIPT_LABEL}--pop-up-wrapper { --window-background: #f7f5f2; --comment-body-color: #555; --comment-body-background-color: hsla(0, 0%, 50%, 0.01); --overlay-gradient: linear-gradient( to bottom, var(--comment-body-color) 70%, var(--comment-body-background-color) 100% ); --subject-font-color: #3073b9; --border-color: #e6e6e6; --pop-up-background: #fff; --title-row-bg: #eee; --title-row-bg-hover: #fafafa; --button-generic-background: #258bd4; --button-generic-background-hightlight: #409cdd; --button-generic-color: #fff; --category-story: #4caf50; --category-blog: #9c27b0; --category-forum: #f44336; --category-profile: #ff9800; --category-story-highlight: #5cb75f; --category-blog-highlight: #ae2bc5; --category-forum-highlight: #f6574c; --category-profile-highlight: #ffa31a; } #${SCRIPT_LABEL}--pop-up-wrapper.nightmode { --window-background: #222935; --comment-body-color: #a3abc3; --comment-body-background-color: rgba(255, 255, 255, 0.04); --subject-font-color: #52b7ff; --border-color: #313b4c; --pop-up-background: #29313f; --title-row-bg: #2f3848; --title-row-bg-hover: #394257; --button-generic-background: #5088b5; --button-generic-background-hightlight: #457aa5; --category-story: #449c47; --category-blog: #89229b; --category-forum: #f32a1b; --category-profile: #e68a00; --category-story-highlight: #3d8a3f; --category-blog-highlight: #761d86; --category-forum-highlight: #e91b0c; --category-profile-highlight: #cc7a00; } /* /colors */ .${SCRIPT_LABEL}-options-button.active[data-filter-type="story"] { background-color: var(--category-story); } .${SCRIPT_LABEL}-options-button.active[data-filter-type="blog"] { background-color: var(--category-blog); } .${SCRIPT_LABEL}-options-button.active[data-filter-type="forum"] { background-color: var(--category-forum); } .${SCRIPT_LABEL}-options-button.active[data-filter-type="profile"] { background-color: var(--category-profile); } .${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="story"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="story"] { background-color: var(--category-story-highlight); } .${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="blog"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="blog"] { background-color: var(--category-blog-highlight); } .${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="forum"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="forum"] { background-color: var(--category-forum-highlight); } .${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="profile"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="profile"] { background-color: var(--category-profile-highlight); } /* scroller */ #${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar { width: 6px; height: 6px; background-color: transparent; } #${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb { background-color: #888; } #${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-track { background-color: #ddd; } #${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb:hover { background-color: #a3a3a3; } #${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb:active { background-color: #6d6d6d; } .${SCRIPT_LABEL}-flex-grow { flex-grow: 1; } .${SCRIPT_LABEL}--window-content select, .${SCRIPT_LABEL}--window-content input[type="text"] { background-color: var(--pop-up-background); color: inherit; font-size: inherit; border: 1px groove; padding: 0px 4px; } .${SCRIPT_LABEL}--window-content input[type="text"] { padding: 2px 4px; } #${SCRIPT_LABEL}--pop-up-wrapper { position: fixed; top: 0; left: 0; z-index: 10000; display: flex; width: 100vw; height: 100vh; align-items: center; justify-content: center; background-color: rgba(0,0,0,0.5); } #${SCRIPT_LABEL}--pop-up { max-width: 1200px; width: 90vw; } #${SCRIPT_LABEL}--pop-up > div { width: 100%; } .${SCRIPT_LABEL}--window-content { display: flex; flex-direction: column; overflow: auto; padding: 10px 18px; height: calc(100vh - 120px); background-color: var(--window-background); } .${SCRIPT_LABEL}-list { display: flex; flex-direction: column; min-height: 400px; border: 1px solid rgba(0,0,0,0.2); background-color: var(--pop-up-background); } .${SCRIPT_LABEL}-list-header { border-bottom: 1px solid var(--border-color); padding: 3px 8px; line-height: 2; font-size: 16px; display: flex; justify-content: flex-start; align-items: baseline; flex-wrap: wrap; } .${SCRIPT_LABEL}-list-header > * { margin: 0px 2px; } #${SCRIPT_LABEL}-list-container { overflow: auto; overflow-anchor: none; } #${SCRIPT_LABEL}-list-container > :first-child { border-top-style: none; } #${SCRIPT_LABEL}-list-container > * { border-top: 1px solid var(--border-color); } #${SCRIPT_LABEL}-list-container li.comment { display: block; padding: 8px; line-height: 1.5em; overflow: hidden; position: relative; padding-right: 100px; } #${SCRIPT_LABEL}-list-container .date>span { font-size: 0.9em; } #${SCRIPT_LABEL}-list-container .date { position: absolute; top: 50%; right: 10px; margin-top: -7px; font-size: 0.9em; } #${SCRIPT_LABEL}-list-container .comment_information { display: inherit; padding-right: 50px; min-height: 32px; } #${SCRIPT_LABEL}-list-container .comment_information > a.subject { font-size: 1.2em; color: var(--subject-font-color); } #${SCRIPT_LABEL}-list-container .comment .comment_information .buttons { position: absolute; right: 0px; user-select: none; -moz-user-select: none; } #${SCRIPT_LABEL}-list-container .comment:hover .comment_information .buttons { opacity: 1; } #${SCRIPT_LABEL}-list-container .comment .comment_information .buttons a.disabled { opacity: 0.2; cursor: default; } #${SCRIPT_LABEL}-pagination { border-bottom: 1px solid var(--border-color); padding: 3px 8px; user-select: none; -moz-user-select: none; } .${SCRIPT_LABEL}-pagination-link { margin: 0px 2px; } .${SCRIPT_LABEL}-pagination-link.disabled { color: unset; cursor: unset; } .${SCRIPT_LABEL}-pagination-link.disabled:hover { text-decoration: none; } .${SCRIPT_LABEL}-comment-author-span { font-size: 1em; } .${SCRIPT_LABEL}-comment-author { color: unset; white-space: nowrap; } .${SCRIPT_LABEL}-divider { margin: 0px 4px; } .${SCRIPT_LABEL}-snippet-wrapper { position: relative; background-color: var(--comment-body-background-color); box-shadow: inset 1px 1px 5px 0px rgba(0, 0, 0, 0.05); } .${SCRIPT_LABEL}-snippet { overflow: hidden; max-height: 80px; white-space: pre-line; padding: 0.4em; color: var(--comment-body-color); } .${SCRIPT_LABEL}-snippet.expanded { max-height: unset; } .${SCRIPT_LABEL}-snippet.overlay { transition: background-color 0.5s cubic-bezier(0.5, 0, 0, 1); -webkit-background-clip: text; background-clip: text; background-image: var(--overlay-gradient); background-color: transparent; color: transparent; cursor: pointer; user-select: none; -moz-user-select: none; } .${SCRIPT_LABEL}-snippet.overlay:hover { background-color: var(--comment-body-color); } .${SCRIPT_LABEL}-options-bar { display: flex; flex-wrap: wrap; margin-bottom: 4px; } .${SCRIPT_LABEL}-options-button { color: var(--comment-body-color); background-color: var(--pop-up-background); cursor: pointer; user-select: none; -moz-user-select: none; font-size: .8125rem; font-weight: normal; margin: 2px 0px; padding: 3px 8px; border: 1px solid var(--border-color); border-radius: 4px; display: inline-block; text-align: center; line-height: 16px; outline: none; } .${SCRIPT_LABEL}-options-button.active { color: var(--button-generic-color); background-color: var(--button-generic-background); } .${SCRIPT_LABEL}-options-button.clickable:hover, .${SCRIPT_LABEL}-options-button.active:hover, .${SCRIPT_LABEL}-options-button:focus { background-color: var(--button-generic-background-hightlight); color: var(--button-generic-color); } .${SCRIPT_LABEL}-article-container { display: block; } .${SCRIPT_LABEL}-article-container.expanded .${SCRIPT_LABEL}-comment-container { display: initial; } #${SCRIPT_LABEL}-list-container .${SCRIPT_LABEL}-comment-container li.comment{ padding-left: 24px; } .${SCRIPT_LABEL}-title-row { padding: 2px 0px; font-size: 18px; border-left-style: solid; border-left-width: 4px; background-color: var(--title-row-bg); cursor: pointer; user-select: none; -moz-user-select: none; } .${SCRIPT_LABEL}-title-row:hover { background-color: var(--title-row-bg-hover); } .${SCRIPT_LABEL}-article-container .expand-indicator:before { content: '\\f0da'; } .${SCRIPT_LABEL}-article-container.expanded .expand-indicator:before { content: '\\f0d7'; } .${SCRIPT_LABEL}-preview.quote_container { position: initial; z-index: initial; margin-top: 8px; } .preview_active .${SCRIPT_LABEL}-preview.quote_container { display: block; } #${SCRIPT_LABEL}--pop-up-wrapper.hidden, .${SCRIPT_LABEL}-article-container .${SCRIPT_LABEL}-comment-container, .${SCRIPT_LABEL}-comment-container .hidden-when-nested, .preview_active .${SCRIPT_LABEL}-snippet-wrapper, .preview_active .${SCRIPT_LABEL}-comment-author-span, .${SCRIPT_LABEL}-preview.quote_container, .${SCRIPT_LABEL}-preview.quote_container .buttons { display: none; } div.corner-stubb { border-width: 5px; border-style: solid; border-right-color: transparent; border-bottom-color: transparent; top: 0px; left: 0px; width: 0px; height: 0px; position: absolute; } .${SCRIPT_LABEL}-comment-container div.corner-stubb { border-style: none; } [data-category-indicator="story"] { border-color: var(--category-story); } [data-category-indicator="blog"] { border-color: var(--category-blog); } [data-category-indicator="group_forum"] { border-color: var(--category-forum); } [data-category-indicator="user"], [data-category-indicator="group"] { border-color: var(--category-profile); } /* Layout fixes for small screens/mobile view */ @media (max-width: 700px) { .${SCRIPT_LABEL}-preview.quote_container .comment_information { display: flex; padding-right: initial; } .${SCRIPT_LABEL}-preview.quote_container .comment { background-color: unset; } .quote_container .data { margin: 0px; } #${SCRIPT_LABEL}-list-container .${SCRIPT_LABEL}-comment-container li.comment, #${SCRIPT_LABEL}-list-container li.comment { padding: 4px; } #${SCRIPT_LABEL}-list-container .date { display: none; } }`; document.head.appendChild(styleElement); } function initLocalStorage() { if (!localStorage[SCRIPT_LABEL]) localStorage[SCRIPT_LABEL] = '{}'; // default settings const store = JSON.parse(localStorage[SCRIPT_LABEL]); const defaults = { 'comment_collapse': true, 'comment_pagination': true, 'sort-method': 'post', 'sort-direction': 'asc', 'category_filter': {story: true, blog: true, forum: true, profile: true} }; for (const prop in defaults) { if (!store.hasOwnProperty(prop)) setLocalStorage(prop, defaults[prop]); } } function getLocalStorage(prop) { const store = JSON.parse(localStorage[SCRIPT_LABEL]); return store[prop]; } function setLocalStorage(prop, val) { const store = JSON.parse(localStorage[SCRIPT_LABEL]); store[prop] = val; localStorage[SCRIPT_LABEL] = JSON.stringify(store); return val; } const getDB = (() => { let dbPromise = null; return () => { return (dbPromise) ? dbPromise : dbPromise = new Promise((resolve, reject) => { const dbRequest = window.indexedDB.open(SCRIPT_LABEL, DB_VERSION); dbRequest.onsuccess = (e) => { const dbConnection = e.target.result; dbConnection.onclose = () => dbPromise = null; dbConnection.onversionchange = () => dbConnection.close(); resolve(dbConnection); }; dbRequest.onerror = (e) => reject(e.target.error); dbRequest.onupgradeneeded = (e) => { const db = e.target.result; if (e.oldVersion < 1) { /* --- Initialize database --- */ // Record includes: {category, commentId, commentListId, commentTimestamp, bookmarkTimestamp, commentAuthor, commentAuthorId, commentSnippet, chapterName} // commentId from different categories do not share the same counter, // so we cannot use them to sort chronologically and must use the timestamps const bookmarkStore = db.createObjectStore('bookmarkStore', {keyPath: ['commentId', 'category']}); bookmarkStore.createIndex('commentTimestamp', 'commentTimestamp'); bookmarkStore.createIndex('bookmarkTimestamp', 'bookmarkTimestamp'); // Record includes: {commentListId, category, title, author, comments[]} // 'title' would be the title of the story/blog post/forum thread. // 'author' corresponds to the user that created the story/blog post/profile, or corresponds to the group in the case of forum posts. const articleStore = db.createObjectStore('articleStore', {keyPath: ['commentListId', 'category']}); articleStore.createIndex('articleTitle', 'title'); articleStore.createIndex('articleAuthor', 'author'); } }; }); }; })(); async function clearDB() { const db = await getDB(); const tx = db.transaction(db.objectStoreNames, 'readwrite'); for (const storeName of tx.objectStoreNames) { const store = tx.objectStore(storeName); const request = store.clear(); request.onerror = (e) => console.error(e.target.error); } tx.oncomplete = () => { broadcastBookmarkChangeEvent({type: 'dbClear'}); window.StyledAlert('All bookmarks deleted.'); }; } async function exportDB() { const db = await getDB(); const tx = db.transaction(db.objectStoreNames, 'readonly'); const storeArray = []; for (const storeName of tx.objectStoreNames) { const store = tx.objectStore(storeName); const request = store.getAll(); request.onsuccess = (e) => storeArray.push({name: storeName, records: e.target.result}); request.onerror = (e) => console.error(e.target.error); } tx.oncomplete = () => { const blob = new Blob([JSON.stringify({version: db.version, stores: storeArray})], {type: 'application/json'}); const anchor = document.createElement('a'); anchor.setAttribute('href', URL.createObjectURL(blob)); anchor.setAttribute('download', 'FiM Comment Bookmarks.json'); document.body.appendChild(anchor); // Won't work in Firefox otherwise anchor.click(); anchor.remove(); }; tx.onerror = (e) => console.error(e.target.error); } function importDB() { const filePicker = composeElement({tag: 'input', attributes: {type: 'file'}}); filePicker.addEventListener('change', () => { const reader = new FileReader(); reader.readAsText(filePicker.files[0]); reader.onload = async (e) => { const parsedDB = JSON.parse(e.target.result); if (parsedDB.version !== DB_VERSION) { throw new Error(`Database version mismatch: The script only support imports with the same database version. Current version: ${DB_VERSION} Imported version: ${parsedDB.version}`); } const bookmarkStore = parsedDB.stores.find(store => store.name == 'bookmarkStore').records; const articleStore = parsedDB.stores.find(store => store.name == 'articleStore').records; for (const entry of bookmarkStore) { const {commentId, category, commentListId} = entry; if (await checkBookmark(commentId, category)) continue; const article = articleStore.find(article => (article.commentListId == commentListId && article.category == category)); await setBookmark(entry, article); } broadcastBookmarkChangeEvent({type: 'dbImport'}); window.StyledAlert('Comment bookmarks successfully imported.'); }; }, {once: true}); filePicker.click(); } function setBookmark(bookmarkEntry, articleRecord) { return new Promise(async (resolve, reject) => { const {commentId, commentListId, category} = bookmarkEntry; articleRecord = {...articleRecord, commentListId, category}; const oldArticle = await getArticleRecord(commentListId, category); articleRecord.comments = (oldArticle) ? oldArticle.comments : []; // insert into sorted list let index; for (index = 0; index < articleRecord.comments.length; ++index) { if (commentId < articleRecord.comments[index]) break; } articleRecord.comments.splice(index, 0, commentId); const db = await getDB(); const tx = db.transaction(['bookmarkStore', 'articleStore'], 'readwrite'); const bookmarkRequest = tx.objectStore('bookmarkStore').add(bookmarkEntry); const articleRequest = tx.objectStore('articleStore').put(articleRecord); tx.oncomplete = () => { resolve(); }; bookmarkRequest.onerror = (e) => { console.warn('Bookmark entry already exist in database', bookmarkEntry); reject(e.target.error); }; articleRequest.onerror = (e) => reject(e.target.error); }); } async function updateBookmarkSnippet(commentId, category, newComment) { const bookmarkEntry = await getBookmark(commentId, category); const newSnippet = plaintext(newComment); if (bookmarkEntry.commentSnippet == newSnippet) return; bookmarkEntry.commentSnippet = newSnippet; const db = await getDB(); const tx = db.transaction('bookmarkStore', 'readwrite'); tx.objectStore('bookmarkStore') .put(bookmarkEntry) .onerror = (e) => e.target.error; tx.oncomplete = () => broadcastBookmarkChangeEvent({type: 'change', record: {commentId, category}}); } function deleteBookmark(commentId, category) { return new Promise(async (resolve, reject) => { const bookmark = await getBookmark(commentId, category); if (!bookmark) throw {name: 'InvalidStateError'}; const article = await getArticleRecord(bookmark.commentListId, category); if (!article) throw {name: 'InvalidStateError'}; // remove commentId from article's comments array const indexPosition = article.comments.findIndex(ele => ele == commentId); if (indexPosition < 0) { console.warn('commentId not found in articleRecord. Attempt to delete bookmark anyway.'); } else { article.comments.splice(indexPosition, 1); } const db = await getDB(); const tx = db.transaction(['bookmarkStore', 'articleStore'], 'readwrite'); const bookmarkRequest = tx.objectStore('bookmarkStore').delete([commentId, category]); const articleRequest = (article.comments.length > 0) ? tx.objectStore('articleStore').put(article) : tx.objectStore('articleStore').delete([article.commentListId, article.category]); tx.oncomplete = () => { broadcastBookmarkChangeEvent({type: 'remove', record: {commentId, category}}); resolve(); }; bookmarkRequest.onerror = (e) => reject(e.target.error); articleRequest.onerror = (e) => reject(e.target.error); }); } function checkBookmark(commentId, category) { return new Promise(async (resolve) => { const db = await getDB(); const tx = db.transaction('bookmarkStore', 'readwrite'); tx.objectStore('bookmarkStore') .count(IDBKeyRange.only([commentId, category])) .onsuccess = (e) => resolve(e.target.result !== 0); }); } function getBookmark(commentId, category) { return new Promise(async (resolve) => { const db = await getDB(); const tx = db.transaction('bookmarkStore', 'readonly'); tx.objectStore('bookmarkStore') .get([commentId, category]) .onsuccess = (e) => resolve(e.target.result); // result is 'undefined' if no matching record }); } function getArticleRecord(commentListId, category) { return new Promise(async (resolve) => { const db = await getDB(); const tx = db.transaction('articleStore', 'readonly'); tx.objectStore('articleStore') .get([commentListId, category]) .onsuccess = (e) => resolve(e.target.result); }); } function getAllRecords(store, index) { return new Promise(async (resolve) => { const db = await getDB(); const tx = db.transaction(store, 'readonly'); if (index) { tx.objectStore(store) .index(index).getAll() .onsuccess = (e) => resolve(e.target.result); } else { tx.objectStore(store) .getAll() .onsuccess = (e) => resolve(e.target.result); } }); } function broadcastBookmarkChangeEvent(data = {}) { // notify other open tabs of database change if (BC_SUPPORT) { const bc = new BroadcastChannel(SCRIPT_LABEL); bc.postMessage({lastModified: data}); bc.close(); } else { // localStorage fallback, dispatch event on current document setLocalStorage('db_last_modified', {timestamp: Date.now(), data: data}); const e = new StorageEvent('storage'); e.initStorageEvent('storage', false, false, SCRIPT_LABEL); window.dispatchEvent(e); } } const getPageCategory = (() => { let category; const fn = () => { // identiy whether the current page is a forum thread, blog post, or story page by regexing the URL const re = new RegExp('^https://www.fimfiction.net/(.+?)/'); const result = re.exec(document.location.href); if (result === null || !CATEGORY_LIST.includes(result[1])) { throw new Error('Unexpected category for URL:' + document.location.href); } if (result[1] == 'group' && document.querySelector('.comment_list[data-type="comments_group_thread"]')) { category = 'group_forum'; } else { category = result[1]; } return category; }; return () => category || fn(); })(); function toggleOn(commentId) { document.querySelectorAll(`.${SCRIPT_LABEL}-bookmark-button[data-comment-id="${commentId}"]`).forEach(button => { button.dataset.bookmarked = '1'; button.querySelector('i').classList.add('fa-bookmark'); button.querySelector('i').classList.remove('fa-bookmark-o'); }); } function toggleOff(commentId) { document.querySelectorAll(`.${SCRIPT_LABEL}-bookmark-button[data-comment-id="${commentId}"]`).forEach(button => { button.dataset.bookmarked = '0'; button.querySelector('i').classList.add('fa-bookmark-o'); button.querySelector('i').classList.remove('fa-bookmark'); }); } const addButton = (() => { const callbackId = new WeakMap(); const io = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { const comment = entry.target; if (entry.isIntersecting) { const id = window.requestAnimationFrame(() => { callbackId.delete(comment); observer.unobserve(comment); comment.removeAttribute(`${SCRIPT_LABEL}-observer-pending`); execAddButton(comment); }); callbackId.set(comment, id); } else { const id = callbackId.get(comment); window.cancelAnimationFrame(id); callbackId.delete(comment); } }); }, {rootMargin: '100px'}); function execAddButton(comment) { const commentId = Number.parseInt(comment.dataset.comment_id); const category = getPageCategory(); checkBookmark(commentId, category).then(isBookmarked => { if (isBookmarked) { toggleOn(commentId); updateBookmarkSnippet(commentId, category, comment); } else { toggleOff(commentId); } }); } return (comment) => { const buttons = comment.querySelector('.comment_information .buttons'); if (buttons === null || comment.closest(`#${SCRIPT_LABEL}-list-container`)) return; // deleted messages or is bookmark preview const commentId = Number.parseInt(comment.dataset.comment_id); const category = getPageCategory(); if (!buttons.querySelector(`.${SCRIPT_LABEL}-bookmark-button`)) { const anchor = composeElement({ tag: 'a', attributes: {title: 'Bookmark this comment', class: `${SCRIPT_LABEL}-bookmark-button`, dataCommentId: commentId, dataCommentCategory: category}, children: [{ tag: 'i', attributes: {class: 'fa fa-fw'} },{ tag: 'span', text: 'Bookmark' }] }); buttons.insertBefore(anchor, buttons.firstChild); } if (!comment.hasAttribute(`${SCRIPT_LABEL}-observer-pending`)) { comment.setAttribute(`${SCRIPT_LABEL}-observer-pending`, ''); io.observe(comment); } }; })(); function commentButtonHandler(event) { const button = event.target.closest(`.${SCRIPT_LABEL}-bookmark-button`); if (!button || !button.hasAttribute('data-bookmarked')) return; const comment = button.closest('.comment'); const commentList = comment.closest('.comment_list'); const bookmarkEntry = {}; const articleRecord = {}; bookmarkEntry.commentId = Number.parseInt(comment.dataset.comment_id); bookmarkEntry.commentListId = Number.parseInt(commentList.dataset.item); bookmarkEntry.category = button.dataset.commentCategory; if (!CATEGORY_LIST.includes(bookmarkEntry.category) || window.isNaN(bookmarkEntry.commentId)) { throw new Error('Unexpected value in ', bookmarkEntry); } checkBookmark(bookmarkEntry.commentId, bookmarkEntry.category).then(isBookmarked => { if (!isBookmarked) { try { bookmarkEntry.commentTimestamp = Number.parseInt(comment.querySelector('[data-time]').dataset.time) * 1000; } catch (exception) { if (!(exception instanceof TypeError)) throw exception; /* fallback for when recently made comments lacks the 'data-time' attribute in their timestamp. Oh joy... * e.g. <span title="Tuesday 1st of January 2019 @4:20am">Tuesday</span> */ const monthLookup = { January: '01', February: '02', March: '03', April: '04', May: '05', June: '06', July: '07', August: '08', September: '09', October: '10', November: '11', December: '12'}; const span = comment.querySelector('.meta a>[title]'); // most liable to break const rx = new RegExp('^\\w+ (\\d+)(?:st|nd|rd|th) of (\\w+) (\\d+) @(\\d+):(\\d+)(am|pm)$'); const results = rx.exec(span.getAttribute('title')); const day = results[1].padStart(2, '0'); const month = monthLookup[results[2]]; const year = results[3]; let hour = (Number.parseInt(results[4]) + (results[6] == 'pm' ? 12 : 0)).toString().padStart(2, '0'); const minute = results[5]; if (hour == '24') { // TIL there is no such thing as 2019-01-15T24:58, // change that to 2019-01-16T00:58 instead. hour = '00'; const date = new Date(`${year}-${month}-${day}T${hour}:${minute}`); date.setDate(date.getDate() + 1); // no need to worry about end-of-month, setDate takes that into account bookmarkEntry.commentTimestamp = date.getTime(); } else { bookmarkEntry.commentTimestamp = Date.parse(`${year}-${month}-${day}T${hour}:${minute}`); } } bookmarkEntry.bookmarkTimestamp = Date.now(); bookmarkEntry.commentAuthor = comment.querySelector('.author .name').textContent; bookmarkEntry.commentAuthorId = Number.parseInt(new RegExp('^/user/([0-9]+)/[^/]*$').exec(comment.querySelector('.author .name').getAttribute('href'))[1]); bookmarkEntry.commentSnippet = plaintext(comment); switch (bookmarkEntry.category) { /* For each category, commentListId corresponds to: * story -> story id / chapter id * group -> group id * group_forum -> forum thread * blog -> blog post * user -> user id */ case 'story': { const storyName = document.querySelector('meta[property="og:title"]').content; let chapterName, storyAuthor; if (document.querySelector('article.story_container') !== null) { // title page const anchor = comment.querySelector('.desktop a'); if (anchor) { chapterName = anchor.textContent; } else { chapterName = null; } storyAuthor = document.querySelector('.user-page-header h1 a[href^="/user/"]').textContent; } else { // chapter page bookmarkEntry.commentListId = Number.parseInt(document.querySelector('[data-story-id]').dataset.storyId); chapterName = document.querySelector('#chapter_title').textContent; storyAuthor = document.querySelector('.story-page-header .author a').textContent; } bookmarkEntry.chapterName = chapterName; articleRecord.title = storyName; articleRecord.author = storyAuthor; break; } case 'group': { const groupName = document.querySelector('.group_name').textContent.trim(); articleRecord.title = groupName; articleRecord.author = groupName; break; } case 'group_forum': { const groupName = document.querySelector('.group_name').textContent.trim(); const subject = document.querySelectorAll('.group .breadcrumbs a')[1].textContent.trim(); bookmarkEntry.groupId = Number.parseInt(document.querySelector('.group-page').dataset.groupId); articleRecord.title = subject; articleRecord.author = groupName; break; } case 'blog': { const postTitle = document.querySelector('.blog-post-content-box h1 a[href^="/blog/"]').textContent; const blogAuthor = document.querySelector('.blog-page .information_box a[href^="/user/"]').textContent; articleRecord.title = postTitle; articleRecord.author = blogAuthor; break; } case 'user': { const userName = document.querySelector('.user-page-header h1 a[href^="/user/"]').textContent; articleRecord.title = userName; articleRecord.author = userName; break; } default: { throw Error('Unexpected data-comment-category value: ' + bookmarkEntry.category); } } // rudimentary error checking for (const prop in bookmarkEntry) { const val = bookmarkEntry[prop]; if (val === undefined || (val === null && prop !== 'chapterName') || (typeof val == 'number' && window.isNaN(val))) { console.log(bookmarkEntry); throw TypeError('bookmarkEntry contains undefined values'); } } for (const prop in articleRecord) { const val = articleRecord[prop]; if (val === undefined || val === null || (typeof val == 'number' && window.isNaN(val))) { console.log(articleRecord); throw TypeError('articleRecord contains undefined values'); } } // everything checks out, update the button setBookmark(bookmarkEntry, articleRecord).then(() => { const {commentId, category} = bookmarkEntry; toggleOn(commentId); broadcastBookmarkChangeEvent({type: 'add', record: {commentId, category}}); }); } else { deleteBookmark(bookmarkEntry.commentId, bookmarkEntry.category) .then(() => toggleOff(bookmarkEntry.commentId)) .catch(e => { if (e.name == 'InvalidStateError') { console.log(e); toggleOff(bookmarkEntry.commentId); } else { throw e; } }); } }); } function plaintext(comment) { const body = comment.querySelector('.comment_data').cloneNode(true); // remove inline comment previews body.querySelectorAll('.comment.inline-quote').forEach(inlineComment => inlineComment.remove()); // quote links for (const quote of body.querySelectorAll('.comment_quote_link')) { quote.insertAdjacentText('afterbegin', '@'); quote.normalize(); } // horizontal rule for (const ele of body.querySelectorAll('hr')) { ele.parentElement.replaceChild(document.createTextNode('\n------\n'), ele); } // process emotes, replace all <img> with its 'alt' attribute (e.g. :rainbowhuh:) for (const emote of body.querySelectorAll('img.emoticon')) { emote.parentElement.replaceChild(document.createTextNode(emote.alt), emote); } // process image embeds for (const ele of body.querySelectorAll('.collapsed-image-container, .user_image')) { ele.parentElement.replaceChild(document.createTextNode('[image embed]'), ele); } // process other embeds for (const embed of body.querySelectorAll('.bbcode-embed, .oembed')) { let prefix = ''; let suffix = ''; let ele; if (embed.dataset.origin !== undefined) { /* This should apply to: * YouTube * Gfycat * Streamable * Twitch */ prefix = embed.dataset.origin; } else if (embed.dataset.provider == 'soundcloud') { prefix = 'SoundCloud'; } else if ((ele = body.querySelector('.story-card .story_link'))) { prefix = 'Fimfiction'; suffix = ': ' + ele.title; } if (prefix !== '') prefix += ' '; ele = document.createElement('p'); ele.innerText = `[${prefix}embed${suffix}]`; embed.parentElement.replaceChild(ele, embed); } // headings for (const heading of body.querySelectorAll('h1, h2, h3, h4, h5, h6')) { /* eslint-disable no-fallthrough */ /* Yes. They are all meant to fallthrough */ let prefix = ''; switch (heading.tagName) { case 'H6': prefix += '#'; case 'H5': prefix += '#'; case 'H4': prefix += '#'; case 'H3': prefix += '#'; case 'H2': prefix += '#'; case 'H1': prefix += '#'; } /* eslint-enable no-fallthrough */ heading.insertAdjacentText('afterbegin', prefix + ' '); heading.insertAdjacentText('beforeend', '\n'); heading.normalize(); } // bullet list for (const listItem of body.querySelectorAll('ul>li')) { listItem.insertAdjacentText('beforebegin', ' - '); listItem.insertAdjacentText('beforeend', '\n'); listItem.normalize(); } // numbered list for (const list of body.querySelectorAll('ol')) { let i = 1; for (const listItem of list.children) { listItem.insertAdjacentText('beforebegin', ` ${i++}. `); listItem.insertAdjacentText('beforeend', '\n'); listItem.normalize(); } } // linebreak for some elements for (const ele of body.querySelectorAll('blockquote, ul, ol, code')) { ele.insertAdjacentText('beforebegin', '\n'); ele.insertAdjacentText('afterend', '\n'); } // process blockquotes, create a treewalker to iterate over all the text nodes in the comment, // count the number of <blockquote> until comment root, and prepend '>' accordingly let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { const currentNode = walker.currentNode; let node, firstTextNodeInLine; if (!currentNode.parentElement.matches('blockquote *')) continue; node = currentNode; firstTextNodeInLine = true; while (firstTextNodeInLine && node.parentElement.nodeName != 'BLOCKQUOTE') { firstTextNodeInLine = (node == node.parentElement.firstChild || node.previousSibling.nodeName == 'BR'); node = node.parentElement; } if (currentNode.nodeValue == '' || (!currentNode.parentElement.matches('blockquote > *') && !firstTextNodeInLine) || (currentNode != currentNode.parentElement.firstChild && currentNode.previousSibling.nodeName != 'BR' && !currentNode.parentElement.matches('blockquote > ol, blockquote > ul')) ) continue; // Look on my works, and despair. node = currentNode.parentElement; while (node !== body) { if (node.tagName == 'LI') break; if (node.tagName == 'BLOCKQUOTE') { currentNode.nodeValue = '>' + currentNode.nodeValue; } node = node.parentElement; } } // double linkbreak after paragraph for (const paragraph of body.querySelectorAll('p')) { paragraph.insertAdjacentText('afterend', '\n\n'); } // <br> for (const br of body.querySelectorAll('br')) { br.parentElement.replaceChild(document.createTextNode('\n'), br); } const textSegments = []; walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { textSegments.push(walker.currentNode.nodeValue); } // remove leading and trailing white spaces, and remove excessive repeating linebreaks return textSegments.join('').trim() .replace(/(\n{3,})/g, '\n\n') .replace(/[‘’]/g, '\'').replace(/[“”]/g, '"'); // because FUCK smart quotes } function checkNightmode() { return (document.getElementById('stylesheetMain').href.indexOf('light') == -1); } function overlayClickHandler(e) { e.target.classList.add('expanded'); e.target.classList.remove('overlay'); } function applySnippetOverlay(ele) { if (!ele || ele.closest('.comment').classList.contains('preview_active') || ele.classList.contains('overlay') || ele.classList.contains('expanded')) { return; } const overflow = (ele.scrollHeight > ele.clientHeight || ele.scrollWidth > ele.clientWidth); ele.classList.toggle('overlay', overflow); if (overflow) { ele.addEventListener('click', overlayClickHandler, {once: true}); } else { ele.removeEventListener('click', overlayClickHandler); } } const composeComment = (() => { // This function could potentially be very time intensive. // We can save some time by caching the response. const cache = new Map(); document.addEventListener('bookmarkchange', (e) => { const eventType = e.detail.type; if (eventType == 'remove' || eventType == 'change') { const {commentId, category} = e.detail.record; const key = `${commentId}${category}`; cache.delete(key); } else if (eventType == 'dbClear') { cache.clear(); } }); return (entry) => { const {category, commentId, commentListId, commentAuthor, commentAuthorId, commentSnippet: commentBody, commentTimestamp: unixTimestamp} = entry; const key = `${commentId}${category}`; if (cache.has(key)) { const response = cache.get(key); // resetting stylings response.querySelector(`.${SCRIPT_LABEL}-snippet`).classList.remove('expanded'); response.classList.remove('preview_active'); const quoteContainer = response.querySelector('.quote_container'); if (quoteContainer.childElementCount) quoteContainer.firstElementChild.remove(); return response; } const fullTimestamp = new Date(unixTimestamp).toString(); const displayDate = new Intl.DateTimeFormat('en', {month: 'short', day: 'numeric', year: 'numeric'}).format(unixTimestamp); let commentTitle = ' '; let chapterComponent = ''; switch (category) { case 'story': commentTitle += `${(entry.chapterName) ? '- ' : ''}${entry.title} - ${entry.postAuthor}`; chapterComponent = (entry.chapterName) ? ' ' + entry.chapterName : ''; break; case 'group': commentTitle += `${entry.title}`; break; case 'group_forum': commentTitle += `${entry.title} - ${entry.postAuthor}`; break; case 'blog': commentTitle += `${entry.title} - ${entry.postAuthor}`; break; case 'user': commentTitle += `${entry.title}`; break; } let permalink; switch (category) { case 'story': case 'blog': case 'user': case 'group': permalink = `/${category}/${commentListId}/#comment/${commentId}`; break; case 'group_forum': permalink = `/group/${entry.groupId}/_/thread/${commentListId}/#comment/${commentId}`; break; } const ele = composeElement({ tag: 'li', attributes: {class: 'comment', dataCommentId: commentId, dataCommentCategory: category}, children: [{ tag: 'div', attributes: {class: 'corner-stubb', dataCategoryIndicator: category} },{ tag: 'span', attributes: {class: 'date'}, children: [{ tag: 'span', attributes: {title: fullTimestamp}, text: displayDate }] },{ tag: 'div', attributes: {class: 'comment_information'}, children: [{ tag: 'a', attributes: {href: permalink, class: 'subject'}, children: [{ tag: 'span', text: '#' },{ tag: 'span', attributes: {class: 'chapter-component'}, text: chapterComponent },{ tag: 'span', attributes: {class: 'hidden-when-nested'}, text: commentTitle }] },{ tag: 'wbr' },{ tag: 'span', attributes: {class: `${SCRIPT_LABEL}-comment-author-span`}, children: [{ tag: 'span', attributes: {class: `${SCRIPT_LABEL}-divider`}, text: '·' },{ tag: 'a', attributes: {href: `/user/${commentAuthorId}/`, class: `${SCRIPT_LABEL}-comment-author`}, text: commentAuthor }] },{ tag: 'div', attributes: {class: 'buttons'}, children: [{ tag: 'a', attributes: {title: 'Toggle live preview'}, children: [{tag: 'i', attributes: {class: 'fa fa-fw fa-desktop'}}] },{ tag: 'a', attributes: {title: 'Delete bookmark'}, children: [{tag: 'i', attributes: {class: 'fa fa-fw fa-trash-o'}}] }] }] },{ tag: 'div', attributes: {class: `${SCRIPT_LABEL}-snippet-wrapper`}, children: [{tag: 'div', attributes: {class: `${SCRIPT_LABEL}-snippet`}, text: commentBody}] },{ tag: 'div', attributes: {class: `${SCRIPT_LABEL}-preview quote_container`} }] }); cache.set(key, ele); return ele; }; })(); const toggleLivePreview = (() => { const responseCache = new Map(); const makeURL = (commentId, category) => { const ajaxCategory = { story: 'story_comments', group: 'comments_group', group_forum: 'comments_group_thread', blog: 'blog_posts_comments', user: 'comments_user_page' }; return `/ajax/comments/${ajaxCategory[category]}/${commentId}`; }; // remove edited comments from cache document.addEventListener('bookmarkchange', (e) => { const eventType = e.detail.type; if (eventType == 'change' || eventType == 'remove') { const {commentId, category} = event.detail.record; responseCache.delete(makeURL(commentId, category)); } else if (eventType == 'dbClear') { responseCache.clear(); } }); const fetchComment = (commentId, category) => { return new Promise((resolve, reject) => { const cacheMaxAge = 3600; // 3600 seconds == 1 hour const fetchURL = makeURL(commentId, category); const response = responseCache.get(fetchURL); if (response && (Math.floor((Date.now() - response.timestamp) / 1e3) < cacheMaxAge)) { resolve(response.body); } else { window.fetch(fetchURL, {credentials: 'same-origin'}) .then(response => response.json()) .then(obj => { if (obj.hasOwnProperty('error')) { responseCache.delete(fetchURL); reject(obj.error); } else { responseCache.set(fetchURL, {timestamp: Date.now(), body: obj.content}); resolve(obj.content); } }); } }); }; return (commentBody) => { const commentId = Number.parseInt(commentBody.dataset.commentId); const category = commentBody.dataset.commentCategory; const quoteContainer = commentBody.querySelector('.quote_container'); const snippet = commentBody.querySelector(`.${SCRIPT_LABEL}-snippet`); if (quoteContainer.hasAttribute('fetching')) return; if (commentBody.classList.contains('preview_active')) { quoteContainer.firstElementChild.remove(); commentBody.classList.remove('preview_active'); applySnippetOverlay(snippet); } else { quoteContainer.setAttribute('fetching', ''); fetchComment(commentId, category) .then(responseHTML => { quoteContainer.innerHTML = responseHTML; commentBody.classList.add('preview_active'); quoteContainer.removeAttribute('fetching'); if (quoteContainer.querySelector('.comment_data')) { updateBookmarkSnippet(commentId, category, quoteContainer.firstElementChild); } window.App.BindAll(quoteContainer); window.App.DispatchEvent(quoteContainer, 'loadVisibleImages'); }).catch(e => { console.error(e); const btn = commentBody.querySelector('[title="Toggle live preview]"'); btn.classList.add('disabled'); btn.title = 'Could not fetch this comment'; quoteContainer.removeAttribute('fetching'); }); } }; })(); function updateList(pageNumber) { const container = document.getElementById(`${SCRIPT_LABEL}-list-container`); if (!container) return; const sortMethod = container.dataset.sortMethod; const sortDirection = container.dataset.sortDirection; const ITEMS_PER_PAGE = 50; let bookmarkIndexName; switch (sortMethod) { case 'post': bookmarkIndexName = 'commentTimestamp'; break; case 'bookmark-age': bookmarkIndexName = 'bookmarkTimestamp'; break; case 'comment-age': bookmarkIndexName = 'commentTimestamp'; break; } Promise.all([getAllRecords('bookmarkStore', bookmarkIndexName), getAllRecords('articleStore', 'articleTitle')]).then(records => { const [commentRecords, articleRecords] = records; const matchRecord = (record, property, query) => (record[property] && record[property].toLowerCase().indexOf(query.toLowerCase()) != -1); const temp = {}; for (const button of document.getElementById(`${SCRIPT_LABEL}-filter`).querySelectorAll('[data-filter-type]')) { const type = button.dataset.filterType; temp[type] = button.classList.contains('active'); } const categoryFilter = { story: temp['story'], blog: temp['blog'], group_forum: temp['forum'], user: temp['profile'], group: temp['profile'], }; const textField = document.getElementById(`${SCRIPT_LABEL}-search-input`); const searchType = {title: undefined, comment: undefined, author: undefined}; document.querySelectorAll(`#${SCRIPT_LABEL}-search [data-search-type]`).forEach(button => { const fieldName = button.dataset.searchType; searchType[fieldName] = button.classList.contains('active'); }); const queryString = textField.value; const doTextSearch = (new RegExp('\\S').test(queryString) && !Object.values(searchType).every(val => !val)); const keepArticle = []; const matchedComments = commentRecords.map(comment => { // denormalize the title for easier filtering in the next step const article = articleRecords.find(record => (record.commentListId == comment.commentListId && record.category == comment.category)); comment.title = article.title; comment.postAuthor = article.author; return comment; }) .filter(comment => { // find the comments we want to display if (!categoryFilter[comment.category]) return false; return ((!doTextSearch) || (searchType.author && matchRecord(comment, 'commentAuthor', queryString)) || (searchType.comment && matchRecord(comment, 'commentSnippet', queryString)) || (searchType.title && matchRecord(comment, 'chapterName', queryString)) || (searchType.title && matchRecord(comment, 'title', queryString)) || (searchType.title && matchRecord(comment, 'postAuthor', queryString))); }); matchedComments.forEach(({commentListId, category}) => keepArticle.push({commentListId, category})); // keep a record of article entries for displaying in 'post' view const matchedArticles = (!doTextSearch && Object.values(categoryFilter).every(val => val)) ? articleRecords : articleRecords.filter(article => { return (categoryFilter[article.category] && keepArticle.find(({commentListId, category}) => (article.commentListId == commentListId && article.category == category))); }); // pagination const paginationBar = document.getElementById(`${SCRIPT_LABEL}-pagination`); const pageNumberContainer = document.getElementById(`${SCRIPT_LABEL}-numbered-pages`); const totalPageCount = (sortMethod == 'post') ? Math.ceil((matchedArticles.length / ITEMS_PER_PAGE) || 1) : Math.ceil((matchedComments.length / ITEMS_PER_PAGE) || 1); const currentPageNumber = Math.min(totalPageCount, (pageNumber || paginationBar.dataset.pageNumber)); const doPagination = (totalPageCount != 1) && getLocalStorage('comment_pagination'); const startIndex = (doPagination) ? (currentPageNumber - 1) * ITEMS_PER_PAGE : 0; const endIndex = (doPagination) ? currentPageNumber * ITEMS_PER_PAGE : totalPageCount * ITEMS_PER_PAGE; const prevLink = document.getElementById(`${SCRIPT_LABEL}-prev-page`); const nextLink = document.getElementById(`${SCRIPT_LABEL}-next-page`); prevLink.classList.toggle('disabled', currentPageNumber == 1); nextLink.classList.toggle('disabled', currentPageNumber == totalPageCount); prevLink.dataset.goToPage = currentPageNumber - 1; nextLink.dataset.goToPage = currentPageNumber + 1; while (pageNumberContainer.firstChild) pageNumberContainer.firstChild.remove(); paginationBar.classList.toggle('hidden', !doPagination); if (doPagination) { for (let i = 1, dividerL = false, dividerR = false; i <= totalPageCount; ++i) { if (i > 2 && i < currentPageNumber - 3) { if (!dividerL) { pageNumberContainer.appendChild(composeElement({tag: 'span', text: '...'})); dividerL = true; } continue; } if (i < totalPageCount - 1 && i > currentPageNumber + 3) { if (!dividerR) { pageNumberContainer.appendChild(composeElement({tag: 'span', text: '...'})); dividerR = true; } continue; } pageNumberContainer.appendChild(composeElement({ tag: 'a', attributes: { class: `${SCRIPT_LABEL}-pagination-link ${(i == currentPageNumber) ? 'disabled' : ''}`, dataGoToPage: i }, text: i })); } } const frag = document.createDocumentFragment(); if (sortMethod == 'post') { for (let i = startIndex, len = matchedArticles.length; i < endIndex && i < len; ++i) { const article = matchedArticles[i]; const category = article.category; const comments = article.comments; let displayTitle; let displayAuthor; switch (category) { case 'story': case 'blog': case 'group_forum': displayTitle = `${article.title}${(article.author) ? ' - ' : ''}`; displayAuthor = article.author; break; case 'user': case 'group': displayTitle = `${article.title}`; displayAuthor = ''; break; } const articleElement = composeElement({ tag: 'li', attributes: {class: `${SCRIPT_LABEL}-article-container`}, children: [{ tag: 'div', attributes: {class: `${SCRIPT_LABEL}-title-row`, dataCategoryIndicator: category}, children: [{ tag: 'span', children: [{tag: 'i', attributes: {class: 'fa fa-fw expand-indicator'}}] },{ tag: 'span', text: displayTitle },{ tag: 'span', children: [{tag: 'i', text: displayAuthor}] }] },{ tag: 'ul', attributes: {class: `${SCRIPT_LABEL}-comment-container`} }] }); const commentContainer = articleElement.querySelector(`.${SCRIPT_LABEL}-comment-container`); if (sortDirection == 'asc') comments.reverse(); for (let i = 0, len = comments.length; i < len; ++i) { const commentId = comments[i]; const comment = matchedComments.find(record => (record.commentId == commentId && record.category == category)); if (comment) commentContainer.appendChild(composeComment(comment)); } frag.appendChild(articleElement); } } else { if (sortDirection == 'asc') matchedComments.reverse(); for (let i = startIndex, len = matchedComments.length; i < endIndex && i < len; ++i) { frag.appendChild(composeComment(matchedComments[i])); } } // don't collapse comments if (!getLocalStorage('comment_collapse')) { frag.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(ele => { ele.classList.add('expanded'); ele.classList.remove('overlay'); ele.removeEventListener('click', overlayClickHandler); }); } /* finished iterating through all the records, insert elements into document */ // clear the container first while (container.firstChild) container.firstChild.remove(); container.appendChild(frag); container.scrollTop = 0; // attach listeners for snippet expanders, need to be live on the document to calculate if content is overflowing if (!document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden') && getLocalStorage('comment_collapse')) { container.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay); } window.App.binders.forEach(fn => fn(container)); // User card }); } function displayBookmarkPanel() { const menu = document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`); if (!menu) return; menu.classList.toggle('nightmode', checkNightmode()); // apply the nightmode styling menu.classList.remove('hidden'); menu.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay); } function initBookmarkPanel() { const title = 'Comment Bookmarks'; const menu = composeElement({tag: 'div', attributes: {id: `${SCRIPT_LABEL}--pop-up-wrapper`, class: 'hidden'}}); menu.innerHTML = ` <div id="${SCRIPT_LABEL}--pop-up" class="drop-down-pop-up-container"> <div class="drop-down-pop-up"> <h1> <span><i class="fa fa-folder-open"></i> ${title}</span> <a class="close_button"></a> </h1> <div class="drop-down-pop-up-content"> <div class="${SCRIPT_LABEL}--window-content"> <div class="${SCRIPT_LABEL}-flex-wrapper"> <div class="${SCRIPT_LABEL}-options-bar"> <div class="${SCRIPT_LABEL}-options-group ${SCRIPT_LABEL}-flex-grow"> Display setting: <button class="${SCRIPT_LABEL}-options-button" id="${SCRIPT_LABEL}-comment-collapse" type="button" title="Collapse all comment snippets by default">Compact view</button> <button class="${SCRIPT_LABEL}-options-button" id="${SCRIPT_LABEL}-comment-pagination" type="button" title="Display 50 items per page">Pagination</button> </div> <div class="${SCRIPT_LABEL}-options-group"> <button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-stats" type="button" title="View statistics">Stats</button> <button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-export-button" type="button" title="Backup bookmarks to file">Export</button> <button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-import-button" type="button" title="Import and merge bookmarks from backup">Import</button> <button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-clear-button" type="button" title="Delete all stored bookmarks">Clear</button> </div> </div> </div> <div class="${SCRIPT_LABEL}-list"> <div class="${SCRIPT_LABEL}-flex-wrapper"> <div class="${SCRIPT_LABEL}-list-header"> <span>Sort by: </span> <select id="${SCRIPT_LABEL}-sorting"> <option value="post">parent post</option> <option value="comment-age">comment date</option> <option value="bookmark-age">added date</option> </select> <select id="${SCRIPT_LABEL}-sorting-direction"> <option value="asc">newest first</option> <option value="desc">oldest first</option> </select> <span id="${SCRIPT_LABEL}-filter"> <span>Filter by:</span> <button class="${SCRIPT_LABEL}-options-button" title="Story comments" data-filter-type="story">Story</button> <button class="${SCRIPT_LABEL}-options-button" title="Blog comments" data-filter-type="blog">Blog</button> <button class="${SCRIPT_LABEL}-options-button" title="Forum posts" data-filter-type="forum">Forum</button> <button class="${SCRIPT_LABEL}-options-button" title="User and group profile comments" data-filter-type="profile">Profile</button> </span> <span id="${SCRIPT_LABEL}-search"> <span>Search:</span> <input id="${SCRIPT_LABEL}-search-input" type="text" size="15"> <button class="${SCRIPT_LABEL}-options-button active" title="Title, chapter name, or the author of the parent post" data-search-type="title">Title</button> <button class="${SCRIPT_LABEL}-options-button active" title="Comment body" data-search-type="comment">Comment</button> <button class="${SCRIPT_LABEL}-options-button active" title="Author of the comment" data-search-type="author">Commenter</button> </span> </div> </div> <div id="${SCRIPT_LABEL}-pagination" data-page-number="1"> <a id="${SCRIPT_LABEL}-prev-page" class="${SCRIPT_LABEL}-pagination-link">Prev</a> <span id="${SCRIPT_LABEL}-numbered-pages"></span> <a id="${SCRIPT_LABEL}-next-page" class="${SCRIPT_LABEL}-pagination-link">Next</a> </div> <ul id="${SCRIPT_LABEL}-list-container"> </ul> </div> </div> </div> <div class="drop-down-pop-up-footer"> <center> <button class="styled_button" style="padding: 5px 40px;">Close</button> </center> </div> </div> </div>`; // Overlays may need to be added or removed due to change to viewport const resizeHandler = (() => { const interval = 500; let lastCall = 0; return function () { const now = Date.now(); if (now - lastCall < interval) return; menu.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay); lastCall = now; }; })(); const bindClickHandlers = (() => { const callbacks = []; menu.addEventListener('click', e => { for (const callback of callbacks) { const {selector, fn, bubble} = callback; const ele = e.target.closest(selector); if ((bubble && ele) || e.target.matches(selector)) { if (ele.tagName == 'BUTTON') ele.blur(); fn(ele, e); } } }); return (selector, fn, bubble = true) => { callbacks.push({selector, fn, bubble}); }; })(); /* --- attaching event listeners --- */ window.addEventListener('resize', resizeHandler); // pagination links bindClickHandlers(`.${SCRIPT_LABEL}-pagination-link`, link => { if (link.classList.contains('disabled')) return; const pageNumber = Number.parseInt(link.dataset.goToPage); document.getElementById(`${SCRIPT_LABEL}-pagination`).dataset.pageNumber = pageNumber; updateList(pageNumber); }); // article container expand/collapse bindClickHandlers(`.${SCRIPT_LABEL}-title-row`, target => { const container = target.closest(`.${SCRIPT_LABEL}-article-container`); container.classList.toggle('expanded'); container.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay); }); bindClickHandlers('[title="Toggle live preview"]', btn => { const comment = btn.closest('.comment'); toggleLivePreview(comment); }); // comment delete button bindClickHandlers('[title="Delete bookmark"]', btn => { const comment = btn.closest('.comment'); const id = Number.parseInt(comment.dataset.commentId); const cat = comment.dataset.commentCategory; window.ConfirmPrompt('Are you sure you want to delete this bookmark?', () => { deleteBookmark(id, cat) .then(() => { comment.remove(); const ele = document.getElementById('comment_' + id); if (ele && cat == getPageCategory()) toggleOff(id); }) .catch(exception => { if (exception.name == 'InvalidStateError') { console.warn(exception); comment.remove(); const ele = document.getElementById('comment_' + id); if (ele && cat == getPageCategory()) toggleOff(id); } else { throw exception; } }); }); }); // close bookmark panel menu.dataset.pendingListUpdate = '0'; bindClickHandlers(`.close_button, .drop-down-pop-up-footer button, #${SCRIPT_LABEL}--pop-up-wrapper, .subject`, () => { window.removeEventListener('resize', resizeHandler); document.getElementById(`${SCRIPT_LABEL}-list-container`).scrollTop = 0; menu.classList.add('hidden'); if (menu.dataset.pendingListUpdate == '1') { menu.dataset.pendingListUpdate = '0'; updateList(); } else { menu.querySelectorAll('.expanded').forEach(ele => ele.classList.remove('expanded')); menu.querySelectorAll('.preview_active').forEach(ele => ele.classList.remove('preview_active')); menu.querySelectorAll('.quote_container > *').forEach(ele => ele.remove()); } }, false); // display stats bindClickHandlers(`#${SCRIPT_LABEL}-stats`, () => { Promise.all([getAllRecords('bookmarkStore'), getAllRecords('articleStore')]).then(records => { const [bookmarkStore, articleStore] = records; const totalBookmarks = bookmarkStore.length; const totalArticles = articleStore.length; let storyCount = 0; let blogCount = 0; let forumCount = 0; let profileCount = 0; bookmarkStore.forEach(entry => { switch (entry.category) { case ('story'): ++storyCount; break; case ('blog'): ++blogCount; break; case ('group_forum'): ++forumCount; break; case ('user'): case ('group'): ++profileCount; break; } }); const title = 'Bookmark Statistics'; const innerHTML = ` You have bookmarked a total of <b>${totalBookmarks}</b> ${totalBookmarks == 1 ? 'comment' : 'comments'} from <b>${totalArticles}</b> ${totalArticles == 1 ? 'post' : 'posts'}. <br> <br> <ul style="list-style-type: initial; margin-left: 20px;"> <li><b>${storyCount}</b> from stories</li> <li><b>${blogCount}</b> from blog posts</li> <li><b>${forumCount}</b> from forum threads</li> <li><b>${profileCount}</b> from user and group profiles</li> </ul> `; window.StyledAlert(innerHTML, title, {align: 'left'}); }); }); // comment collapse menu.querySelector(`#${SCRIPT_LABEL}-comment-collapse`).classList.toggle('active', getLocalStorage('comment_collapse')); bindClickHandlers(`#${SCRIPT_LABEL}-comment-collapse`, button => { button.classList.toggle('active'); setLocalStorage('comment_collapse', button.classList.contains('active')); updateList(1); }); // pagination toggle menu.querySelector(`#${SCRIPT_LABEL}-comment-pagination`).classList.toggle('active', getLocalStorage('comment_pagination')); bindClickHandlers(`#${SCRIPT_LABEL}-comment-pagination`, button => { button.classList.toggle('active'); setLocalStorage('comment_pagination', button.classList.contains('active')); updateList(1); }); // import/export bindClickHandlers(`#${SCRIPT_LABEL}-export-button`, () => { window.ConfirmPrompt('Export your bookmarks to file?', exportDB); }); bindClickHandlers(`#${SCRIPT_LABEL}-import-button`, () => { window.ConfirmPrompt('This will add all bookmarks from the selected backup to your present collection, proceed?', importDB); }); bindClickHandlers(`#${SCRIPT_LABEL}-clear-button`, () => { window.ConfirmPrompt('This will delete all currently stored bookmarks, proceed?', () => { window.ConfirmPrompt('Are you REALLY sure?', clearDB); }); }); // don't navigate on relative comment links inside live preview bindClickHandlers('.quote_container a[href^="#comment/"]', (target, event) => { event.preventDefault(); event.stopPropagation(); }, false); // comment category filter const filter = getLocalStorage('category_filter'); for (const button of menu.querySelectorAll(`#${SCRIPT_LABEL}-filter [data-filter-type]`)) { const type = button.dataset.filterType; button.classList.toggle('active', filter[type]); } bindClickHandlers(`#${SCRIPT_LABEL}-filter [data-filter-type]`, target => { const filter = getLocalStorage('category_filter'); target.classList.toggle('active'); filter[target.dataset.filterType] = target.classList.contains('active'); setLocalStorage('category_filter', filter); updateList(1); }); // search options bindClickHandlers(`#${SCRIPT_LABEL}-search [data-search-type]`, target => { const textField = document.getElementById(`${SCRIPT_LABEL}-search-input`); target.classList.toggle('active'); if (new RegExp('\\S').test(textField.value)) updateList(1); // only update list if textfield's not empty }); const textField = menu.querySelector(`#${SCRIPT_LABEL}-search-input`); textField.addEventListener('keydown', e => { if (e.key !== 'Escape') return; const value = e.target.value; e.stopPropagation(); e.preventDefault(); if (value !== '') { e.target.value = ''; updateList(1); } else { e.target.blur(); } }); textField.addEventListener('focus', e => { const textField = e.target; textField.setSelectionRange(0, textField.value.length); e.stopPropagation(); e.preventDefault(); }); textField.addEventListener('input', (() => { const delay = 100; // ms let timeoutId = null; return () => { window.clearTimeout(timeoutId); timeoutId = window.setTimeout(() => { updateList(1); }, delay); }; })()); // dropdown menus const sortMethod = getLocalStorage('sort-method'); const sortDirection = getLocalStorage('sort-direction'); menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortMethod = sortMethod; menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortDirection = sortDirection; let select; select = menu.querySelector(`#${SCRIPT_LABEL}-sorting`); select.value = sortMethod; select.addEventListener('change', e => { const sortMethod = e.target.value; setLocalStorage('sort-method', sortMethod); menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortMethod = sortMethod; updateList(1); }); select = menu.querySelector(`#${SCRIPT_LABEL}-sorting-direction`); select.value = sortDirection; select.addEventListener('change', e => { const sortDirection = e.target.value; setLocalStorage('sort-direction', sortDirection); menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortDirection = sortDirection; updateList(1); }); // UI update in response to database change document.addEventListener('bookmarkchange', (e) => { const eventType = e.detail.type; // update the toggle status of any bookmark button on the page, // skip on eventType 'change' because it wouldn't affect button toggle state if (eventType != 'change') { document.querySelectorAll('div.comment').forEach(addButton); } // don't update if the menu is open // reset to display page one on database clear or import if (eventType == 'dbClear' || eventType == 'dbImport') { updateList(1); } else if (menu.classList.contains('hidden')) { updateList(); // keep the page number } else { menu.dataset.pendingListUpdate = '1'; } }); const dispatchBookmarkChangeEvent = (data) => { // only dispatch even when page is visible if (document.hidden) { document.addEventListener('visibilitychange', () => { if (document.visibilityState == 'visible') document.dispatchEvent(new CustomEvent('bookmarkchange', {detail: data})); }, {once: true}); } else { document.dispatchEvent(new CustomEvent('bookmarkchange', {detail: data})); } }; // updateList across othere instances if (BC_SUPPORT) { const bc = new BroadcastChannel(SCRIPT_LABEL); bc.onmessage = (e) => { if (e.data.hasOwnProperty('lastModified')) { const data = e.data.lastModified; dispatchBookmarkChangeEvent(data); } }; } else { // fallback by listening to localStorage changes window.addEventListener('storage', (() => { let store = getLocalStorage('db_last_modified') || setLocalStorage('db_last_modified', {timestamp: 0, data: {}}); let lastModified = store.timestamp; return (e) => { if (e.key !== SCRIPT_LABEL) return; store = getLocalStorage('db_last_modified'); if (lastModified < store.timestamp) { lastModified = store.timestamp; dispatchBookmarkChangeEvent(store.data); } }; })()); } const applyScrolltrap = (() => { const selectors = []; menu.addEventListener('wheel', (e) => { // let ele be the closest ancestor that matches one of the selectors let ele = null; let temp = e.target; do { ele = (selectors.some(selector => temp.matches(selector))) ? temp : null; temp = temp.parentElement; } while (temp !== null && temp !== menu && ele === null); if (!ele) { e.preventDefault(); e.stopPropagation(); return; } const position = ele.scrollTop; const scrollBottomPosition = ele.scrollHeight - ele.clientHeight; const scrollDirection = e.deltaY; if (scrollDirection > 0 && position >= scrollBottomPosition || scrollDirection < 0 && position <= 0) { e.preventDefault(); e.stopPropagation(); } }); return (selector) => { selectors.push(selector); }; })(); // prevent scrolling of background applyScrolltrap(`#${SCRIPT_LABEL}-list-container`); applyScrolltrap(`.${SCRIPT_LABEL}--window-content`); document.querySelector('.content').appendChild(menu); updateList(); } function initUI() { const creationObserver = (function () { const observedElements = []; const callbacks = []; const executeCallback = (fn, node) => { if (observedElements.some(observedNode => observedNode == node)) return; observedElements.push(node); fn(node); }; const obs = new MutationObserver(mutationRecords => { mutationRecords.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType !== Node.ELEMENT_NODE) return; callbacks.forEach(({selector, fn}) => { if (node.matches(selector)) executeCallback(fn, node); node.querySelectorAll(selector).forEach(childNode => executeCallback(fn, childNode)); }); }); }); }); obs.observe(document.body, {childList: true, subtree: true}); return function (selector, fn) { document.querySelectorAll(selector).forEach(node => executeCallback(fn, node)); callbacks.push({selector, fn}); }; })(); // bookmark panel button const menuButton = composeElement({ tag: 'li', children: [{ tag: 'a', attributes: {class: `${SCRIPT_LABEL}-menu-button`}, children: [{ tag: 'i', attributes: {class: 'fa fa-bookmark'} },{ tag: 'span', text: 'Bookmarks' }] }] }); creationObserver('nav.user_toolbar>ul', function (toolbar) { // desktop view const button = menuButton.cloneNode(true); button.addEventListener('click', displayBookmarkPanel); toolbar.insertBefore(button, toolbar.children[toolbar.children.length - 2]); }); creationObserver('.navigation-drawer-list a[href="/search/blog-posts"]', target => { // mobile view const drawer = target.closest('.navigation-drawer-list'); const button = menuButton.cloneNode(true); button.addEventListener('click', displayBookmarkPanel); drawer.insertBefore(button, drawer.firstElementChild); }); creationObserver('.comment_list', list => { list.addEventListener('click', commentButtonHandler); }); creationObserver('div.comment', comment => { comment.removeAttribute(`${SCRIPT_LABEL}-observer-pending`); addButton(comment); }); // override the z-index on the author popup // 10000 because that's the z-index of the top navigation bars when it's in fixed mode creationObserver('.info-card-container', target => { function toggle(card) { if (document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden')) { card.style.zIndex = ''; } else { card.style.zIndex = '10000'; } } new MutationObserver(mutationRecords => { mutationRecords.forEach(mutation => { toggle(mutation.target); }); }).observe(target, {attributeFilter: ['class']}); toggle(target); }); // override the z-index on the stats window creationObserver('#dimmers .dimmer, body>.drop-down-pop-up-container', target => { if (document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden')) return; target.style.zIndex = '10000'; }); // detect comment edit and apply updateBookmarkSnippet creationObserver('.comment_data', target => { const processMutationEvent = (mutation) => { const comment = mutation.target.closest('.comment'); // check for edit button and if element is not hidden if (comment.querySelector('[data-click="toggleEditComment"]') && !mutation.target.classList.contains('hidden')) { const button = comment.querySelector(`.${SCRIPT_LABEL}-bookmark-button`); const commentId = Number.parseInt(button.dataset.commentId); const category = button.dataset.commentCategory; if (button.dataset.bookmarked == '1') { updateBookmarkSnippet(commentId, category, comment); } } }; new MutationObserver(mutationRecords => { mutationRecords.forEach(processMutationEvent); }).observe(target, {attributeFilter: ['class']}); }); initBookmarkPanel(); } initLocalStorage(); onReady(initCSS); onReady(initUI); })();