diff --git a/CHANGELOG.md b/CHANGELOG.md index bd43be2d1..733165a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +Index navigation improvements: + - You can now refresh the index page you are on with the refresh shortcut in the header bar or the same keybind for refreshing threads. + - You can now switch between paged and all-threads index modes via the "Index Navigation" header sub-menu:
+ ![index navigation](img/changelog/3.12.0/0.png) + - Threads in the index can now be sorted by: + - Bump order + - Last reply + - Creation date + - Reply count + - File count + - Navigating across index pages is now instantaneous. + Added a keybind to open the catalog search field on index pages. ### 3.11.5 - *2013-10-03* diff --git a/Gruntfile.coffee b/Gruntfile.coffee index e7226450a..9f48729d7 100644 --- a/Gruntfile.coffee +++ b/Gruntfile.coffee @@ -1,11 +1,17 @@ module.exports = (grunt) -> + importHTML = (filename) -> + "\"\"\"#{grunt.file.read("html/#{filename}.html").replace(/^\s+|\s+$ grunt.config 'pkg' + get: -> + pkg = grunt.config 'pkg' + pkg.importHTML = importHTML + pkg enumerable: true ) coffee: @@ -17,6 +23,7 @@ module.exports = (grunt) -> 'src/General/Header.coffee' 'src/General/Notice.coffee' 'src/General/Settings.coffee' + 'src/General/Index.coffee' 'src/General/Get.coffee' 'src/General/Build.coffee' # Features --> @@ -78,9 +85,10 @@ module.exports = (grunt) -> stdout: true stderr: true failOnError: true + checkout: + command: 'git checkout <%= pkg.meta.mainBranch %>' commit: command: """ - git checkout <%= pkg.meta.mainBranch %> git commit -am "Release <%= pkg.meta.name %> v<%= pkg.version %>." git tag -a <%= pkg.version %> -m "<%= pkg.meta.name %> v<%= pkg.version %>." git tag -af stable-v3 -m "<%= pkg.meta.name %> v<%= pkg.version %>." @@ -144,9 +152,9 @@ module.exports = (grunt) -> ] grunt.registerTask 'release', ['shell:commit', 'shell:push', 'build-crx', 'compress:crx'] - grunt.registerTask 'patch', ['bump', 'updcl:3', 'release'] - grunt.registerTask 'minor', ['bump:minor', 'updcl:2', 'release'] - grunt.registerTask 'major', ['bump:major', 'updcl:1', 'release'] + grunt.registerTask 'patch', ['shell:checkout', 'bump', 'updcl:3', 'release'] + grunt.registerTask 'minor', ['shell:checkout', 'bump:minor', 'updcl:2', 'release'] + grunt.registerTask 'major', ['shell:checkout', 'bump:major', 'updcl:1', 'release'] grunt.registerTask 'updcl', 'Update the changelog', (headerLevel) -> headerPrefix = new Array(+headerLevel + 1).join '#' diff --git a/css/style.css b/css/style.css index a67c82043..4bebe551d 100644 --- a/css/style.css +++ b/css/style.css @@ -362,6 +362,15 @@ a[href="javascript:;"] { overflow: hidden; } +/* Index */ +:root.index-loading .board, +:root.index-loading .pagelist { + display: none; +} +.summary { + text-decoration: none; +} + /* Announcement Hiding */ :root.hide-announcement #globalMessage, :root.hide-announcement-enabled #toggleMsgBtn { diff --git a/html/General/Index-pagelist.html b/html/General/Index-pagelist.html new file mode 100644 index 000000000..e6923bb7a --- /dev/null +++ b/html/General/Index-pagelist.html @@ -0,0 +1,11 @@ + +
+ diff --git a/html/General/Settings-section-Filter-guide.html b/html/General/Settings-section-Filter-guide.html index ae73a66da..1656d0cb8 100644 --- a/html/General/Settings-section-Filter-guide.html +++ b/html/General/Settings-section-Filter-guide.html @@ -23,7 +23,7 @@ For example: highlight; or highlight:wallpaper;.
  • - Highlighted OPs will have their threads put on top of board pages by default.
    + Highlighted OPs will have their threads put on top of the board index by default.
    For example: top:yes; or top:no;.
  • diff --git a/html/Monitoring/ThreadUpdater.html b/html/Monitoring/ThreadUpdater.html index 04a6a17b2..32b13b497 100644 --- a/html/Monitoring/ThreadUpdater.html +++ b/html/Monitoring/ThreadUpdater.html @@ -13,5 +13,5 @@
    - +
    diff --git a/img/changelog/3.12.0/0.png b/img/changelog/3.12.0/0.png new file mode 100644 index 000000000..cb17e29c0 Binary files /dev/null and b/img/changelog/3.12.0/0.png differ diff --git a/lib/$.coffee b/lib/$.coffee index 8b2202304..53ede1802 100644 --- a/lib/$.coffee +++ b/lib/$.coffee @@ -45,7 +45,7 @@ $.ajax = do -> type or= form and 'post' or 'get' r.open type, url, !sync if whenModified - r.setRequestHeader 'If-Modified-Since', lastModified[url] or '0' + r.setRequestHeader 'If-Modified-Since', lastModified[url] if url of lastModified $.on r, 'load', -> lastModified[url] = r.getResponseHeader 'Last-Modified' $.extend r, options $.extend r.upload, upCallbacks @@ -104,7 +104,7 @@ $.rm = do -> (el) -> el.parentNode?.removeChild el $.rmAll = (root) -> # jsperf.com/emptify-element - while node = root.firstChild + for node in [root.childNodes...] # HTMLSelectElement.remove !== Element.remove root.removeChild node return diff --git a/src/Filtering/Filter.coffee b/src/Filtering/Filter.coffee index 1f1387dba..eee0dfae9 100644 --- a/src/Filtering/Filter.coffee +++ b/src/Filtering/Filter.coffee @@ -110,13 +110,8 @@ Filter = # Highlight $.addClass @nodes.root, result.class - if !@isReply and result.top and g.VIEW is 'index' - # Put the highlighted OPs' thread on top of the board page... - thisThread = @nodes.root.parentNode - # ...before the first non highlighted thread. - if firstThread = $ 'div[class="postContainer opContainer"]' - unless firstThread is @nodes.root - $.before firstThread.parentNode, [thisThread, thisThread.nextElementSibling] + if !@isReply and result.top + @thread.isOnTop = true name: (post) -> if 'name' of post.info diff --git a/src/Filtering/ThreadHiding.coffee b/src/Filtering/ThreadHiding.coffee index 21bd4c211..80c11b1d2 100644 --- a/src/Filtering/ThreadHiding.coffee +++ b/src/Filtering/ThreadHiding.coffee @@ -4,6 +4,7 @@ ThreadHiding = @db = new DataBoard 'hiddenThreads' @syncCatalog() + $.on d, 'IndexRefresh', @onrefresh Thread.callbacks.push name: 'Thread Hiding' cb: @node @@ -14,6 +15,15 @@ ThreadHiding = return unless Conf['Thread Hiding'] $.prepend @OP.nodes.root, ThreadHiding.makeButton @, 'hide' + onrefresh: -> + for threadID, thread of g.BOARD.threads when thread.isHidden + root = thread.OP.nodes.root.parentNode + if thread.stub + $.prepend root, thread.stub + else + threadRoot.nextElementSibling.hidden = true + return + syncCatalog: -> # Sync hidden threads from the catalog into the index. hiddenThreads = ThreadHiding.db.get diff --git a/src/General/Build.coffee b/src/General/Build.coffee index 783a774f5..c621afa3c 100644 --- a/src/General/Build.coffee +++ b/src/General/Build.coffee @@ -60,6 +60,10 @@ Build = isOP = postID is threadID staticPath = '//static.4chan.org/image/' + gifIcon = if window.devicePixelRatio >= 2 + '@2x.gif' + else + '.gif' if email emailStart = '' @@ -82,21 +86,21 @@ Build = capcodeClass = " capcodeAdmin" capcodeStart = " ## Admin" - capcode = " " when 'mod' capcodeClass = " capcodeMod" capcodeStart = " ## Mod" - capcode = " " when 'developer' capcodeClass = " capcodeDeveloper" capcodeStart = " ## Developer" - capcode = " " else @@ -114,11 +118,11 @@ Build = if file?.isDeleted fileHTML = if isOP "
    " + - "File deleted." + + "File deleted." + "
    " else "
    " + - "File deleted." + + "File deleted." + "
    " else if file ext = file.name[-3..] @@ -178,11 +182,16 @@ Build = '' sticky = if isSticky - " Sticky" + " Sticky" else '' closed = if isClosed - " Closed" + " Closed" + else + '' + + replyLink = if isOP and g.VIEW is 'index' + "   [
    Reply]" else '' @@ -222,7 +231,7 @@ Build = "" + emailStart + "#{name or ''}" + tripcode + - capcodeStart + emailEnd + capcode + userID + flag + sticky + closed + + capcodeStart + emailEnd + capcode + userID + flag + ' ' + "#{date} " + "" + @@ -233,6 +242,7 @@ Build = else "/#{boardID}/res/#{threadID}#q#{postID}" }' title='Quote this post'>#{postID}" + + sticky + closed + replyLink + '' + '' + @@ -248,3 +258,29 @@ Build = quote.href = "/#{boardID}/res/#{href}" # Fix pathnames container + + summary: (boardID, threadID, posts, files) -> + text = [] + text.push "#{posts} post#{if posts > 1 then 's' else ''}" + text.push "and #{files} image repl#{if files > 1 then 'ies' else 'y'}" if files + text.push 'omitted.' + $.el 'a', + className: 'summary' + textContent: text.join ' ' + href: "/#{boardID}/res/#{threadID}" + thread: (board, data) -> + Build.spoilerRange[board] = data.custom_spoiler + + if (OP = board.posts[data.no]) and root = OP.nodes.root.parentNode + $.rmAll root + else + root = $.el 'div', + className: 'thread' + id: "t#{data.no}" + + nodes = [if OP then OP.nodes.root else Build.postFromObject data, board.ID] + if data.omitted_posts + nodes.push Build.summary board.ID, data.no, data.omitted_posts, data.omitted_images + + $.add root, nodes + root diff --git a/src/General/Config.coffee b/src/General/Config.coffee index 1421ff806..715822858 100644 --- a/src/General/Config.coffee +++ b/src/General/Config.coffee @@ -9,7 +9,6 @@ Config = 'Time Formatting': [true, 'Localize and format timestamps.'] 'Relative Post Dates': [false, 'Display dates like "3 minutes ago". Tooltip shows the timestamp.'] 'File Info Formatting': [true, 'Reformat the file information.'] - 'Comment Expansion': [true, 'Add buttons to expand too long comments.'] 'Thread Expansion': [true, 'Add buttons to expand threads.'] 'Index Navigation': [false, 'Add buttons to navigate between threads.'] 'Reply Navigation': [false, 'Add buttons to navigate to top / bottom of thread.'] @@ -140,6 +139,9 @@ Config = #//archive.installgentoo.net/%board/image/%MD5;text:View same on installgentoo /%board/ """ 'Custom CSS': false + Index: + 'Index Mode': 'paged' + 'Index Sort': 'bump' Header: 'Header auto-hide': false 'Bottom header': false @@ -170,9 +172,9 @@ Config = 'Eqn tags': ['Alt+e', 'Insert eqn tags.'] 'Math tags': ['Alt+m', 'Insert math tags.'] 'Submit QR': ['Alt+s', 'Submit post.'] - # Thread related + # Index/Thread related + 'Update': ['r', 'Refresh the index/thread.'] 'Watch': ['w', 'Watch thread.'] - 'Update': ['r', 'Update the thread.'] # Images 'Expand image': ['Shift+e', 'Expand selected image.'] 'Expand images': ['e', 'Expand all images.'] diff --git a/src/General/Header.coffee b/src/General/Header.coffee index e16001638..10359d471 100644 --- a/src/General/Header.coffee +++ b/src/General/Header.coffee @@ -2,12 +2,11 @@ Header = init: -> headerEl = $.el 'div', id: 'header' - innerHTML: """ - <%= grunt.file.read('html/General/Header.html').replace(/>\s+<').trim() %> - """ + innerHTML: <%= importHTML('General/Header') %> @bar = $ '#header-bar', headerEl @toggle = $ '#toggle-header-bar', @bar + @noticesRoot = $ '#notifications', headerEl @menu = new UI.Menu 'header' menuButton = $.el 'a', @@ -244,32 +243,48 @@ Header = $('input[name=boardnav]', settings).focus() hashScroll: -> - return unless (hash = @location.hash[1..]) and post = $.id hash + hash = @location.hash[1..] + return unless /^p\d+$/.test(hash) and post = $.id hash return if (Get.postFromRoot post).isHidden - Header.scrollToPost post - scrollToPost: (post) -> - {top} = post.getBoundingClientRect() + Header.scrollTo post + scrollTo: (root, down, needed) -> + if down + x = Header.getBottomOf root + window.scrollBy 0, -x unless needed and x >= 0 + else + x = Header.getTopOf root + window.scrollBy 0, x unless needed and x >= 0 + scrollToIfNeeded: (root, down) -> + Header.scrollTo root, down, true + getTopOf: (root) -> + {top} = root.getBoundingClientRect() unless Conf['Bottom header'] headRect = Header.toggle.getBoundingClientRect() - top -= headRect.top + headRect.height - window.scrollBy 0, top + top -= headRect.top + headRect.height + top + getBottomOf: (root) -> + {clientHeight} = doc + bottom = clientHeight - root.getBoundingClientRect().bottom + if Conf['Bottom header'] + headRect = Header.toggle.getBoundingClientRect() + bottom -= clientHeight - headRect.bottom + headRect.height + bottom addShortcut: (el, index) -> shortcut = $.el 'span', className: 'shortcut' + shortcut.dataset.index = index $.add shortcut, el shortcuts = $ '#shortcuts', Header.bar - nodes = [shortcuts.childNodes...] - nodes.splice index, 0, shortcut - $.add shortcuts, nodes + $.add shortcuts, [shortcuts.childNodes...].concat(shortcut).sort (a, b) -> a.dataset.index - b.dataset.index menuToggle: (e) -> Header.menu.toggle e, @, g createNotification: (e) -> {type, content, lifetime, cb} = e.detail - notif = new Notice type, content, lifetime - cb notif if cb + notice = new Notice type, content, lifetime + cb notice if cb areNotificationsEnabled: false enableDesktopNotifications: -> diff --git a/src/General/Index.coffee b/src/General/Index.coffee new file mode 100644 index 000000000..5e0713f62 --- /dev/null +++ b/src/General/Index.coffee @@ -0,0 +1,281 @@ +Index = + init: -> + return if g.VIEW isnt 'index' or g.BOARD.ID is 'f' + + @button = $.el 'a', + className: 'index-refresh-shortcut fa fa-refresh' + title: 'Refresh Index' + href: 'javascript:;' + $.on @button, 'click', @update + Header.addShortcut @button, 1 + + modeEntry = + el: $.el 'span', textContent: 'Index mode' + subEntries: [ + { el: $.el 'label', innerHTML: ' Paged' } + { el: $.el 'label', innerHTML: ' All threads' } + ] + for label in modeEntry.subEntries + input = label.el.firstChild + input.checked = Conf['Index Mode'] is input.value + $.on input, 'change', $.cb.value + $.on input, 'change', @cb.mode + + sortEntry = + el: $.el 'span', textContent: 'Sort by' + subEntries: [ + { el: $.el 'label', innerHTML: ' Bump order' } + { el: $.el 'label', innerHTML: ' Last reply' } + { el: $.el 'label', innerHTML: ' Creation date' } + { el: $.el 'label', innerHTML: ' Reply count' } + { el: $.el 'label', innerHTML: ' File count' } + ] + for label in sortEntry.subEntries + input = label.el.firstChild + input.checked = Conf['Index Sort'] is input.value + $.on input, 'change', $.cb.value + $.on input, 'change', @cb.sort + + $.event 'AddMenuEntry', + type: 'header' + el: $.el 'span', + textContent: 'Index Navigation' + order: 90 + subEntries: [modeEntry, sortEntry] + + $.addClass doc, 'index-loading' + @update() + @root = $.el 'div', className: 'board' + @pagelist = $.el 'div', + className: 'pagelist' + hidden: true + innerHTML: <%= importHTML('General/Index-pagelist') %> + @currentPage = @getCurrentPage() + $.on window, 'popstate', @cb.popstate + $.on @pagelist, 'click', @cb.pageNav + $.asap (-> $('.pagelist', doc) or d.readyState isnt 'loading'), -> + $.replace $('.board'), Index.root + $.replace $('.pagelist'), Index.pagelist + $.rmClass doc, 'index-loading' + + cb: + mode: -> + Index.togglePagelist() + Index.buildIndex() + sort: -> + Index.sort() + Index.buildIndex() + popstate: (e) -> + pageNum = Index.getCurrentPage() + Index.pageLoad pageNum if Index.currentPage isnt pageNum + pageNav: (e) -> + return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0 + switch e.target.nodeName + when 'BUTTON' + a = e.target.parentNode + when 'A' + a = e.target + else + return + e.preventDefault() + Index.pageNav +a.pathname.split('/')[2] + + scrollToIndex: -> + Header.scrollToIfNeeded Index.root + + getCurrentPage: -> + +window.location.pathname.split('/')[2] + pageNav: (pageNum) -> + return if Index.currentPage is pageNum + history.pushState null, '', if pageNum is 0 then './' else pageNum + Index.pageLoad pageNum + pageLoad: (pageNum) -> + Index.currentPage = pageNum + return if Conf['Index Mode'] isnt 'paged' + Index.buildIndex() + Index.setPage() + Index.scrollToIndex() + + togglePagelist: -> + Index.pagelist.hidden = Conf['Index Mode'] isnt 'paged' + buildPagelist: -> + pagesRoot = $ '.pages', Index.pagelist + if pagesRoot.childElementCount isnt Index.pagesNum + nodes = [] + for i in [0..Index.pagesNum - 1] + a = $.el 'a', + textContent: i + href: if i then i else './' + nodes.push $.tn('['), a, $.tn '] ' + $.rmAll pagesRoot + $.add pagesRoot, nodes + Index.setPage() + Index.togglePagelist() + setPage: -> + pageNum = Index.getCurrentPage() + pagesRoot = $ '.pages', Index.pagelist + # Previous/Next buttons + prev = pagesRoot.previousSibling.firstChild + next = pagesRoot.nextSibling.firstChild + href = Math.max pageNum - 1, 0 + prev.href = if href is 0 then './' else href + prev.firstChild.disabled = href is pageNum + href = Math.min pageNum + 1, Index.pagesNum - 1 + next.href = if href is 0 then './' else href + next.firstChild.disabled = href is pageNum + # current page + if strong = $ 'strong', pagesRoot + return if +strong.textContent is pageNum + $.replace strong, strong.firstChild + else + strong = $.el 'strong' + a = pagesRoot.children[pageNum] + $.before a, strong + $.add strong, a + + update: -> + return unless navigator.onLine + Index.req?.abort() + Index.notice?.close() + Index.notice = new Notice 'info', 'Refreshing index...' + Index.req = $.ajax "//api.4chan.org/#{g.BOARD}/catalog.json", + onabort: Index.load + onloadend: Index.load + , + whenModified: true + $.addClass Index.button, 'fa-spin' + load: (e) -> + $.rmClass Index.button, 'fa-spin' + {req, notice} = Index + delete Index.req + delete Index.notice + + if e.type is 'abort' + req.onloadend = null + notice.close() + return + + try + Index.parse JSON.parse req.response if req.status is 200 + catch err + c.error 'Index failure:', err.stack + # network error or non-JSON content for example. + notice.setType 'error' + notice.el.lastElementChild.textContent = 'Index refresh failed.' + setTimeout notice.close, 2 * $.SECOND + return + + notice.setType 'success' + notice.el.lastElementChild.textContent = 'Index refreshed!' + setTimeout notice.close, $.SECOND + + Index.scrollToIndex() + parse: (pages) -> + Index.parseThreadList pages + Index.buildThreads() + Index.sort() + Index.buildIndex() + Index.buildPagelist() + parseThreadList: (pages) -> + Index.pagesNum = pages.length + Index.threadsNumPerPage = pages[0].threads.length + Index.liveThreadData = pages.reduce ((arr, next) -> arr.concat next.threads), [] + Index.liveThreadIDs = Index.liveThreadData.map (data) -> data.no + for threadID, thread of g.BOARD.threads when thread.ID not in Index.liveThreadIDs + thread.collect() + return + buildThreads: -> + Index.nodes = [] + threads = [] + posts = [] + for threadData in Index.liveThreadData + threadRoot = Build.thread g.BOARD, threadData + Index.nodes.push threadRoot, $.el 'hr' + if thread = g.BOARD.threads[threadData.no] + thread.setStatus 'Sticky', !!threadData.sticky + thread.setStatus 'Closed', !!threadData.closed + else + thread = new Thread threadData.no, g.BOARD + threads.push thread + # postRoots = $$ '.thread > .postContainer', threadRoot + # for postRoot in postRoots when postRoot.id.match(/\d+/)[0] not of thread.posts + OPRoot = $ '.opContainer', threadRoot + continue if OPRoot.id.match(/\d+/)[0] of thread.posts + try + posts.push new Post OPRoot, thread, g.BOARD + catch err + # Skip posts that we failed to parse. + Main.handleErrors + message: "Parsing of Post No.#{postRoot.id.match /\d+/} failed. Post will be skipped." + error: err + + # Add the threads and
    s in a container to make sure all features work. + $.nodes Index.nodes + Main.callbackNodes Thread, threads + Main.callbackNodes Post, posts + buildReplies: (threadRoots) -> + posts = [] + for threadRoot in threadRoots by 2 + thread = Get.threadFromRoot threadRoot + i = Index.liveThreadIDs.indexOf thread.ID + continue unless lastReplies = Index.liveThreadData[i].last_replies + nodes = [] + for data in lastReplies + if post = thread.posts[data.no] + nodes.push post.nodes.root + continue + nodes.push node = Build.postFromObject data, thread.board.ID + try + posts.push new Post node, thread, thread.board + catch err + # Skip posts that we failed to parse. + errors = [] unless errors + errors.push + message: "Parsing of Post No.#{postRoot.id.match /\d+/} failed. Post will be skipped." + error: err + $.add threadRoot, nodes + + Main.handleErrors errors if errors + Main.callbackNodes Post, posts + sort: -> + switch Conf['Index Sort'] + when 'bump' + sortedThreadIDs = Index.liveThreadIDs + when 'lastreply' + sortedThreadIDs = [Index.liveThreadData...].sort((a, b) -> + a = a.last_replies[a.last_replies.length - 1] if 'last_replies' of a + b = b.last_replies[b.last_replies.length - 1] if 'last_replies' of b + b.no - a.no + ).map (data) -> data.no + when 'birth' + sortedThreadIDs = [Index.liveThreadIDs...].sort (a, b) -> b - a + when 'replycount' + sortedThreadIDs = [Index.liveThreadData...].sort((a, b) -> b.replies - a.replies).map (data) -> data.no + when 'filecount' + sortedThreadIDs = [Index.liveThreadData...].sort((a, b) -> b.images - a.images).map (data) -> data.no + Index.sortedNodes = [] + for threadID in sortedThreadIDs + i = Index.liveThreadIDs.indexOf(threadID) * 2 + Index.sortedNodes.push Index.nodes[i], Index.nodes[i + 1] + # Put the sticky threads on top of the index. + offset = 0 + for threadRoot, i in Index.sortedNodes by 2 when Get.threadFromRoot(threadRoot).isSticky + Index.sortedNodes.splice offset++ * 2, 0, Index.sortedNodes.splice(i, 2)... + return unless Conf['Filter'] + # Put the highlighted thread &
    on top of the index + # while keeping the original order they appear in. + offset = 0 + for threadRoot, i in Index.sortedNodes by 2 when Get.threadFromRoot(threadRoot).isOnTop + Index.sortedNodes.splice offset++ * 2, 0, Index.sortedNodes.splice(i, 2)... + return + buildIndex: -> + if Conf['Index Mode'] is 'paged' + pageNum = Index.getCurrentPage() + nodesPerPage = Index.threadsNumPerPage * 2 + nodes = Index.sortedNodes[nodesPerPage * pageNum ... nodesPerPage * (pageNum + 1)] + else + nodes = Index.sortedNodes + $.rmAll Index.root + Index.buildReplies nodes + $.event 'IndexRefresh' + $.add Index.root, nodes diff --git a/src/General/Main.coffee b/src/General/Main.coffee index 9410365c0..e0b9abc8b 100644 --- a/src/General/Main.coffee +++ b/src/General/Main.coffee @@ -69,6 +69,7 @@ Main = initFeature 'Polyfill', Polyfill initFeature 'Header', Header initFeature 'Settings', Settings + initFeature 'Index Generator', Index initFeature 'Announcement Hiding', PSAHiding initFeature 'Fourchan thingies', Fourchan initFeature 'Custom CSS', CustomCSS @@ -105,7 +106,6 @@ Main = initFeature 'Reveal Spoilers', RevealSpoilers initFeature 'Auto-GIF', AutoGIF initFeature 'Image Hover', ImageHover - initFeature 'Comment Expansion', ExpandComment initFeature 'Thread Expansion', ExpandThread initFeature 'Thread Excerpt', ThreadExcerpt initFeature 'Favicon', Favicon @@ -169,26 +169,21 @@ Main = # Something might have gone wrong! Main.initStyle() - if board = $ '.board' - threads = [] - posts = [] - - for threadRoot in $$ '.board > .thread', board - thread = new Thread +threadRoot.id[1..], g.BOARD - threads.push thread - for postRoot in $$ '.thread > .postContainer', threadRoot - try - posts.push new Post postRoot, thread, g.BOARD - catch err - # Skip posts that we failed to parse. - unless errors - errors = [] - errors.push - message: "Parsing of Post No.#{postRoot.id.match(/\d+/)} failed. Post will be skipped." - error: err + if g.VIEW is 'thread' and threadRoot = $ '.thread' + thread = new Thread +threadRoot.id[1..], g.BOARD + posts = [] + for postRoot in $$ '.thread > .postContainer', threadRoot + try + posts.push new Post postRoot, thread, g.BOARD + catch err + # Skip posts that we failed to parse. + errors = [] unless errors + errors.push + message: "Parsing of Post No.#{postRoot.id.match /\d+/} failed. Post will be skipped." + error: err Main.handleErrors errors if errors - Main.callbackNodes Thread, threads + Main.callbackNodes Thread, [thread] Main.callbackNodes Post, posts if $.hasClass d.body, 'fourchan_x' @@ -207,7 +202,7 @@ Main = try localStorage.getItem '4chan-settings' catch err - new Notice 'warning', 'Cookies need to be enabled on 4chan for <%= meta.name %> to properly function.', 30 + new Notice 'warning', 'Cookies need to be enabled on 4chan for <%= meta.name %> to operate properly.', 30 Main.disableReports = true $.event '4chanXInitFinished' diff --git a/src/General/Notice.coffee b/src/General/Notice.coffee index 60b46ff77..90c2e504b 100644 --- a/src/General/Notice.coffee +++ b/src/General/Notice.coffee @@ -19,7 +19,7 @@ class Notice $.on d, 'visibilitychange', @add return $.off d, 'visibilitychange', @add - $.add $.id('notifications'), @el + $.add Header.noticesRoot, @el @el.clientHeight # force reflow @el.style.opacity = 1 setTimeout @close, @timeout * $.SECOND if @timeout diff --git a/src/General/Post.coffee b/src/General/Post.coffee index 27811afa5..901405495 100644 --- a/src/General/Post.coffee +++ b/src/General/Post.coffee @@ -193,6 +193,13 @@ class Post quotelink.textContent = quotelink.textContent.replace '\u00A0(Dead)', '' $.rmClass quotelink, 'deadlink' return + + collect: -> + @kill() + delete g.posts[@fullID] + delete @thread.posts[@] + delete @board.posts[@] + addClone: (context) -> new Clone @, context rmClone: (index) -> diff --git a/src/General/Settings.coffee b/src/General/Settings.coffee index 17cf7595d..4c0499e4c 100644 --- a/src/General/Settings.coffee +++ b/src/General/Settings.coffee @@ -61,9 +61,7 @@ Settings = return if Settings.dialog $.event 'CloseMenu' - html = """ - <%= grunt.file.read('html/General/Settings.html').replace(/>\s+<').trim() %> - """ + html = <%= importHTML('General/Settings') %> Settings.dialog = overlay = $.el 'div', id: 'overlay' @@ -113,9 +111,7 @@ Settings = section.scrollTop = 0 main: (section) -> - section.innerHTML = """ - <%= grunt.file.read('html/General/Settings-section-Main.html').replace(/>\s+<').trim() %> - """ + section.innerHTML = <%= importHTML('General/Settings-section-Main') %> $.on $('.export', section), 'click', Settings.export $.on $('.import', section), 'click', Settings.import $.on $('input', section), 'change', Settings.onImport @@ -142,7 +138,7 @@ Settings = return div = $.el 'div', - innerHTML: ": Clear manually-hidden threads and posts on all boards. Refresh the page to apply." + innerHTML: ": Clear manually-hidden threads and posts on all boards. Reload the page to apply." button = $ 'button', div hiddenNum = 0 $.get 'hiddenThreads', boards: {}, (item) -> @@ -205,7 +201,7 @@ Settings = try data = JSON.parse e.target.result Settings.loadSettings data - if confirm 'Import successful. Refresh now?' + if confirm 'Import successful. Reload now?' window.location.reload() catch err output.textContent = 'Import failed due to an error.' @@ -290,9 +286,7 @@ Settings = data filter: (section) -> - section.innerHTML = """ - <%= grunt.file.read('html/General/Settings-section-Filter.html').replace(/>\s+<').trim() %> - """ + section.innerHTML = <%= importHTML('General/Settings-section-Filter') %> select = $ 'select', section $.on select, 'change', Settings.selectFilter Settings.selectFilter.call select @@ -309,32 +303,24 @@ Settings = $.on ta, 'change', $.cb.value $.add div, ta return - div.innerHTML = """ - <%= grunt.file.read('html/General/Settings-section-Filter-guide.html').replace(/>\s+<').trim() %> - """ + div.innerHTML = <%= importHTML('General/Settings-section-Filter-guide') %> qr: (section) -> - section.innerHTML = """ - <%= grunt.file.read('html/General/Settings-section-QR.html').replace(/>\s+<').trim() %> - """ + section.innerHTML = <%= importHTML('General/Settings-section-QR') %> ta = $ 'textarea', section $.get 'QR.personas', Conf['QR.personas'], (item) -> ta.value = item['QR.personas'] $.on ta, 'change', $.cb.value sauce: (section) -> - section.innerHTML = """ - <%= grunt.file.read('html/General/Settings-section-Sauce.html').replace(/>\s+<').trim() %> - """ + section.innerHTML = <%= importHTML('General/Settings-section-Sauce') %> ta = $ 'textarea', section $.get 'sauces', Conf['sauces'], (item) -> ta.value = item['sauces'] $.on ta, 'change', $.cb.value rice: (section) -> - section.innerHTML = """ - <%= grunt.file.read('html/General/Settings-section-Rice.html').replace(/>\s+<').trim() %> - """ + section.innerHTML = <%= importHTML('General/Settings-section-Rice') %> items = {} inputs = {} for name in ['boardnav', 'time', 'backlink', 'fileInfo', 'favicon', 'usercss'] @@ -395,9 +381,7 @@ Settings = CustomCSS.update() archives: (section) -> - section.innerHTML = """ - <%= grunt.file.read('html/General/Settings-section-Archives.html').replace(/>\s+<').trim() %> - """ + section.innerHTML = <%= importHTML('General/Settings-section-Archives') %> showLastUpdateTime = (time) -> $('time', section).textContent = new Date(time).toLocaleString() @@ -470,9 +454,7 @@ Settings = $.set 'selectedArchives', selectedArchives keybinds: (section) -> - section.innerHTML = """ - <%= grunt.file.read('html/General/Settings-section-Keybinds.html').replace(/>\s+<').trim() %> - """ + section.innerHTML = <%= importHTML('General/Settings-section-Keybinds') %> tbody = $ 'tbody', section items = {} inputs = {} diff --git a/src/General/Thread.coffee b/src/General/Thread.coffee index 1f15bf714..a02f4bc72 100644 --- a/src/General/Thread.coffee +++ b/src/General/Thread.coffee @@ -3,11 +3,41 @@ class Thread toString: -> @ID constructor: (@ID, @board) -> - @fullID = "#{@board}.#{@ID}" - @posts = {} + @fullID = "#{@board}.#{@ID}" + @posts = {} + @isSticky = false + @isClosed = false + @postLimit = false + @fileLimit = false g.threads[@fullID] = board.threads[@] = @ + setStatus: (type, status) -> + name = "is#{type}" + return if @[name] is status + @[name] = status + return unless @OP + typeLC = type.toLowerCase() + unless status + $.rm $ ".#{typeLC}Icon", @OP.nodes.info + return + icon = $.el 'img', + src: "//static.4chan.org/image/#{typeLC}#{if window.devicePixelRatio >= 2 then '@2x' else ''}.gif" + alt: type + title: type + className: "#{typeLC}Icon" + root = if type is 'Closed' and @isSticky + $ '.stickyIcon', @OP.nodes.info + else + $ '[title="Quote this post"]', @OP.nodes.info + $.after root, [$.tn(' '), icon] + kill: -> @isDead = true @timeOfDeath = Date.now() + + collect: -> + for postID, post in @posts + post.collect() + delete g.threads[@fullID] + delete @board.threads[@] diff --git a/src/Images/ImageExpand.coffee b/src/Images/ImageExpand.coffee index 17b8066d3..60382bb73 100644 --- a/src/Images/ImageExpand.coffee +++ b/src/Images/ImageExpand.coffee @@ -7,7 +7,7 @@ ImageExpand = title: 'Expand All Images' href: 'javascript:;' $.on @EAI, 'click', ImageExpand.cb.toggleAll - Header.addShortcut @EAI, 2 + Header.addShortcut @EAI, 3 Post.callbacks.push name: 'Image Expansion' @@ -45,7 +45,7 @@ ImageExpand = continue unless file and file.isImage and doc.contains post.nodes.root if ImageExpand.on and (!Conf['Expand spoilers'] and file.isSpoiler or - Conf['Expand from here'] and file.thumb.getBoundingClientRect().top < 0) + Conf['Expand from here'] and Header.getTopOf(file.thumb) < 0) continue $.queueTask func, post return @@ -60,13 +60,10 @@ ImageExpand = # Scroll back to the thumbnail when contracting the image # to avoid being left miles away from the relevant post. - rect = post.nodes.root.getBoundingClientRect() - if rect.top < 0 - y = rect.top - unless Conf['Bottom header'] - headRect = Header.toggle.getBoundingClientRect() - y -= headRect.top + headRect.height - if rect.left < 0 + top = Header.getTopOf post.nodes.root + if top < 0 + y = top + if post.nodes.root.getBoundingClientRect().left < 0 x = -window.scrollX window.scrollBy x, y if x or y ImageExpand.contract post @@ -104,13 +101,12 @@ ImageExpand = $.addClass post.nodes.root, 'expanded-image' $.rmClass post.file.thumb, 'expanding' return - prev = post.nodes.root.getBoundingClientRect() + {bottom} = post.nodes.root.getBoundingClientRect() $.queueTask -> $.addClass post.nodes.root, 'expanded-image' $.rmClass post.file.thumb, 'expanding' - return unless prev.top + prev.height <= 0 - curr = post.nodes.root.getBoundingClientRect() - window.scrollBy 0, curr.height - prev.height + curr.top - prev.top + return unless bottom <= 0 + window.scrollBy 0, post.nodes.root.getBoundingClientRect().bottom - bottom error: -> post = Get.postFromNode @ diff --git a/src/Miscellaneous/ExpandComment.coffee b/src/Miscellaneous/ExpandComment.coffee deleted file mode 100644 index 103c891e5..000000000 --- a/src/Miscellaneous/ExpandComment.coffee +++ /dev/null @@ -1,71 +0,0 @@ -ExpandComment = - init: -> - return if g.VIEW isnt 'index' or !Conf['Comment Expansion'] - - Post.callbacks.push - name: 'Comment Expansion' - cb: @node - node: -> - if a = $ '.abbr > a:not([onclick])', @nodes.comment - $.on a, 'click', ExpandComment.cb - cb: (e) -> - e.preventDefault() - ExpandComment.expand Get.postFromNode @ - expand: (post) -> - if post.nodes.longComment and !post.nodes.longComment.parentNode - $.replace post.nodes.shortComment, post.nodes.longComment - post.nodes.comment = post.nodes.longComment - return - return unless a = $ '.abbr > a', post.nodes.comment - a.textContent = "Post No.#{post} Loading..." - $.cache "//api.4chan.org#{a.pathname}.json", -> ExpandComment.parse @, a, post - contract: (post) -> - return unless post.nodes.shortComment - a = $ '.abbr > a', post.nodes.shortComment - a.textContent = 'here' - $.replace post.nodes.longComment, post.nodes.shortComment - post.nodes.comment = post.nodes.shortComment - parse: (req, a, post) -> - {status} = req - if status not in [200, 304] - a.textContent = "Error #{req.statusText} (#{status})" - return - - posts = JSON.parse(req.response).posts - if spoilerRange = posts[0].custom_spoiler - Build.spoilerRange[g.BOARD] = spoilerRange - - for postObj in posts - break if postObj.no is post.ID - if postObj.no isnt post.ID - a.textContent = "Post No.#{post} not found." - return - - {comment} = post.nodes - clone = comment.cloneNode false - clone.innerHTML = postObj.com - for quote in $$ '.quotelink', clone - href = quote.getAttribute 'href' - continue if href[0] is '/' # Cross-board quote, or board link - quote.href = "/#{post.board}/res/#{href}" # Fix pathnames - post.nodes.shortComment = comment - $.replace comment, clone - post.nodes.comment = post.nodes.longComment = clone - post.parseComment() - post.parseQuotes() - if Conf['Resurrect Quotes'] - Quotify.node.call post - if Conf['Quote Previewing'] - QuotePreview.node.call post - if Conf['Quote Inlining'] - QuoteInline.node.call post - if Conf['Mark OP Quotes'] - QuoteOP.node.call post - if Conf['Mark Cross-thread Quotes'] - QuoteCT.node.call post - if g.BOARD.ID is 'g' - Fourchan.code.call post - if g.BOARD.ID is 'sci' - Fourchan.math.call post - if Conf['Linkify'] - Linkify.node.call post diff --git a/src/Miscellaneous/ExpandThread.coffee b/src/Miscellaneous/ExpandThread.coffee index fe2305bdc..d9efaa2b0 100644 --- a/src/Miscellaneous/ExpandThread.coffee +++ b/src/Miscellaneous/ExpandThread.coffee @@ -1,19 +1,26 @@ ExpandThread = init: -> return if g.VIEW isnt 'index' or !Conf['Thread Expansion'] - + @statuses = {} + $.on d, 'IndexRefresh', @onIndexRefresh Thread.callbacks.push name: 'Thread Expansion' cb: @node node: -> - return unless span = $.x 'following-sibling::span[contains(@class,"summary")][1]', @OP.nodes.root - [posts, files] = span.textContent.match /\d+/g - a = $.el 'a', - textContent: ExpandThread.text '+', posts, files - className: 'summary' - href: 'javascript:;' + ExpandThread.setButton @ + + setButton: (thread) -> + return unless a = $.x 'following-sibling::a[contains(@class,"summary")][1]', thread.OP.nodes.root + a.textContent = ExpandThread.text '+', a.textContent.match(/\d+/g)... $.on a, 'click', ExpandThread.cbToggle - $.replace span, a + + onIndexRefresh: -> + for threadID, status of ExpandThread.statuses + status.req?.abort() + delete ExpandThread.statuses[threadID] + for threadID, thread of g.BOARD.threads + ExpandThread.setButton thread + return text: (status, posts, files) -> text = [status] @@ -22,95 +29,77 @@ ExpandThread = text.push if status is '-' then 'shown' else 'omitted' text.join(' ') + '.' - cbToggle: -> + cbToggle: (e) -> + return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0 + e.preventDefault() ExpandThread.toggle Get.threadFromNode @ toggle: (thread) -> threadRoot = thread.OP.nodes.root.parentNode - a = $ '.summary', threadRoot - - switch thread.isExpanded - when false, undefined - for post in $$ '.thread > .postContainer', threadRoot - ExpandComment.expand Get.postFromRoot post - unless a - thread.isExpanded = true - return - thread.isExpanded = 'loading' - [posts, files] = a.textContent.match /\d+/g - a.textContent = ExpandThread.text '...', posts, files - $.cache "//api.4chan.org/#{thread.board}/res/#{thread}.json", -> - ExpandThread.parse @, thread, a - - when 'loading' - thread.isExpanded = false - return unless a - [posts, files] = a.textContent.match /\d+/g - a.textContent = ExpandThread.text '+', posts, files - - when true - thread.isExpanded = false - #goddamit moot - num = if thread.isSticky - 1 - else switch g.BOARD.ID - # XXX boards config - when 'b', 'vg' then 3 - when 't' then 1 - else 5 - posts = $$ ".thread > .replyContainer", threadRoot - for post in [thread.OP.nodes.root].concat posts[-num..] - ExpandComment.contract Get.postFromRoot post - return unless a - postsCount = 0 - filesCount = 0 - for reply in posts[...-num] - if Conf['Quote Inlining'] - # rm clones - inlined.click() while inlined = $ '.inlined', reply - postsCount++ - filesCount++ if 'file' of Get.postFromRoot reply - $.rm reply - a.textContent = ExpandThread.text '+', postsCount, filesCount - return - - parse: (req, thread, a) -> - return if a.textContent[0] is '+' - if req.status not in [200, 304] - a.textContent = "Error #{req.statusText} (#{req.status})" - $.off a, 'click', ExpandThread.cbToggle + return unless a = $ '.summary', threadRoot + if thread.ID of ExpandThread.statuses + ExpandThread.contract thread, a, threadRoot + else + ExpandThread.expand thread, a, threadRoot + expand: (thread, a, threadRoot) -> + ExpandThread.statuses[thread] = status = {} + a.textContent = ExpandThread.text '...', a.textContent.match(/\d+/g)... + status.req = $.cache "//api.4chan.org/#{thread.board}/res/#{thread}.json", -> + delete status.req + ExpandThread.parse @, thread, a + contract: (thread, a, threadRoot) -> + status = ExpandThread.statuses[thread] + delete ExpandThread.statuses[thread] + if status.req + status.req.abort() + a.textContent = ExpandThread.text '+', a.textContent.match(/\d+/g)... if a return - thread.isExpanded = true + num = if thread.isSticky + 1 + else switch g.BOARD.ID + # XXX boards config + when 'b', 'vg' then 3 + when 't' then 1 + else 5 + postsCount = 0 + filesCount = 0 + for reply in $$('.thread > .replyContainer', threadRoot)[...-num] + # rm clones + inlined.click() while inlined = $ '.inlined', reply if Conf['Quote Inlining'] + postsCount++ + filesCount++ if 'file' of Get.postFromRoot reply + $.rm reply + a.textContent = ExpandThread.text '+', postsCount, filesCount + parse: (req, thread, a) -> + if req.status not in [200, 304] + a.textContent = "Error #{req.statusText} (#{req.status})" + return - {posts} = JSON.parse req.response - if spoilerRange = posts.shift().custom_spoiler - Build.spoilerRange[thread.board] = spoilerRange + data = JSON.parse(req.response).posts + Build.spoilerRange[thread.board] = data.shift().custom_spoiler - postsObj = [] + posts = [] postsRoot = [] filesCount = 0 - for reply in posts - if post = thread.posts[reply.no] + for postData in data + if post = thread.posts[postData.no] filesCount++ if 'file' of post postsRoot.push post.nodes.root continue - root = Build.postFromObject reply, thread.board.ID + root = Build.postFromObject postData, thread.board.ID post = new Post root, thread, thread.board - link = $ 'a[title="Highlight this post"]', root - link.href = "res/#{thread}#p#{post}" - link.nextSibling.href = "res/#{thread}#q#{post}" filesCount++ if 'file' of post - postsObj.push post + posts.push post postsRoot.push root - Main.callbackNodes Post, postsObj + Main.callbackNodes Post, posts $.after a, postsRoot - postsCount = postsRoot.length + postsCount = postsRoot.length a.textContent = ExpandThread.text '-', postsCount, filesCount # Enable 4chan features. if Conf['Enable 4chan\'s Extension'] - $.globalEval "Parser.parseThread(#{thread.ID}, 1, #{postsCount})" + $.globalEval "Parser.parseThread(#{thread}, 1, #{postsCount})" else Fourchan.parseThread thread.ID, 1, postsCount diff --git a/src/Miscellaneous/Fourchan.coffee b/src/Miscellaneous/Fourchan.coffee index 64b1f4562..6f290d662 100644 --- a/src/Miscellaneous/Fourchan.coffee +++ b/src/Miscellaneous/Fourchan.coffee @@ -6,8 +6,9 @@ Fourchan = if board is 'g' $.globalEval """ window.addEventListener('prettyprint', function(e) { - var pre = e.detail; - pre.innerHTML = prettyPrintOne(pre.innerHTML); + window.dispatchEvent(new CustomEvent('prettyprint:cb', { + detail: prettyPrintOne(e.detail) + })); }, false); """ Post.callbacks.push @@ -32,9 +33,11 @@ Fourchan = cb: @math code: -> return if @isClone + apply = (e) -> pre.innerHTML = e.detail + $.on window, 'prettyprint:cb', apply for pre in $$ '.prettyprint:not(.prettyprinted)', @nodes.comment - $.event 'prettyprint', pre, window - $.addClass pre, 'prettyprinted' + $.event 'prettyprint', pre.innerHTML, window + $.off window, 'prettyprint:cb', apply return math: -> return if @isClone or !$ '.math', @nodes.comment diff --git a/src/Miscellaneous/Keybinds.coffee b/src/Miscellaneous/Keybinds.coffee index a06a6043b..00ee147cd 100644 --- a/src/Miscellaneous/Keybinds.coffee +++ b/src/Miscellaneous/Keybinds.coffee @@ -7,7 +7,7 @@ Keybinds = init = -> $.off d, '4chanXInitFinished', init - $.on d, 'keydown', Keybinds.keydown + $.on d, 'keydown', Keybinds.keydown for node in $$ '[accesskey]' node.removeAttribute 'accesskey' return @@ -58,11 +58,15 @@ Keybinds = Keybinds.tags 'math', target when Conf['Submit QR'] QR.submit() if QR.nodes and !QR.status() - # Thread related + # Index/Thread related + when Conf['Update'] + switch g.VIEW + when 'thread' + ThreadUpdater.update() + when 'index' + Index.update() when Conf['Watch'] ThreadWatcher.toggle thread - when Conf['Update'] - ThreadUpdater.update() # Images when Conf['Expand image'] Keybinds.img threadRoot @@ -70,15 +74,18 @@ Keybinds = Keybinds.img threadRoot, true # Board Navigation when Conf['Front page'] - window.location = "/#{g.BOARD}/0#delform" + if g.VIEW is 'index' + Index.pageNav 0 + else + window.location = "/#{g.BOARD}/" when Conf['Open front page'] - $.open "/#{g.BOARD}/#delform" + $.open "/#{g.BOARD}/" when Conf['Next page'] - if form = $ '.next form' - window.location = form.action + return unless g.VIEW is 'index' and Conf['Index Mode'] is 'paged' + $('.next button', Index.pagelist).click() when Conf['Previous page'] - if form = $ '.prev form' - window.location = form.action + return unless g.VIEW is 'index' and Conf['Index Mode'] is 'paged' + $('.prev button', Index.pagelist).click() when Conf['Search form'] $.id('search-btn').click() # Thread Navigation @@ -176,43 +183,31 @@ Keybinds = location.href = url hl: (delta, thread) -> + postEl = $ '.reply.highlight', thread + unless delta - if postEl = $ '.reply.highlight', thread - $.rmClass postEl, 'highlight' + $.rmClass postEl, 'highlight' if postEl return - if Conf['Bottom header'] - topMargin = 0 - else - headRect = Header.toggle.getBoundingClientRect() - topMargin = headRect.top + headRect.height - if postEl = $ '.reply.highlight', thread - $.rmClass postEl, 'highlight' - rect = postEl.getBoundingClientRect() - if rect.bottom >= topMargin and rect.top <= doc.clientHeight # We're at least partially visible + + if postEl + {height} = postEl.getBoundingClientRect() + if Header.getTopOf(postEl) >= -height and Header.getBottomOf(postEl) >= -height # We're at least partially visible root = postEl.parentNode axe = if delta is +1 'following' else 'preceding' - next = $.x "#{axe}-sibling::div[contains(@class,'replyContainer')][1]/child::div[contains(@class,'reply')]", root - unless next - @focus postEl - return - return unless g.VIEW is 'thread' or $.x('ancestor::div[parent::div[@class="board"]]', next) is thread - rect = next.getBoundingClientRect() - if rect.top < 0 or rect.bottom > doc.clientHeight - if delta is -1 - window.scrollBy 0, rect.top - topMargin - else - next.scrollIntoView false + return unless next = $.x "#{axe}-sibling::div[contains(@class,'replyContainer')][1]/child::div[contains(@class,'reply')]", root + Header.scrollToIfNeeded next, delta is +1 @focus next + $.rmClass postEl, 'highlight' return + $.rmClass postEl, 'highlight' replies = $$ '.reply', thread replies.reverse() if delta is -1 for reply in replies - rect = reply.getBoundingClientRect() - if delta is +1 and rect.top >= topMargin or delta is -1 and rect.bottom <= doc.clientHeight + if delta is +1 and Header.getTopOf(reply) > 0 or delta is -1 and Header.getBottomOf(reply) > 0 @focus reply return diff --git a/src/Miscellaneous/Nav.coffee b/src/Miscellaneous/Nav.coffee index 2b7e23354..3620080f0 100644 --- a/src/Miscellaneous/Nav.coffee +++ b/src/Miscellaneous/Nav.coffee @@ -38,29 +38,24 @@ Nav = else Nav.scroll +1 - getThread: (full) -> - if Conf['Bottom header'] - topMargin = 0 - else - headRect = Header.toggle.getBoundingClientRect() - topMargin = headRect.top + headRect.height - threads = $$('.thread').filter (thread) -> - thread = Get.threadFromRoot thread - !(thread.isHidden and !thread.stub) - for thread, i in threads - rect = thread.getBoundingClientRect() - if rect.bottom > topMargin # not scrolled past - return if full then [threads, thread, i, rect, topMargin] else thread + getThread: -> + for threadRoot in $$ '.thread' + thread = Get.threadFromRoot threadRoot + continue if thread.isHidden and !thread.stub + if Header.getTopOf(threadRoot) >= -threadRoot.getBoundingClientRect().height # not scrolled past + return threadRoot return $ '.board' scroll: (delta) -> - [threads, thread, i, rect, topMargin] = Nav.getThread true - top = rect.top - topMargin - - # unless we're not at the beginning of the current thread - # (and thus wanting to move to beginning) - # or we're above the first thread and don't want to skip it - if (delta is -1 and top > -5) or (delta is +1 and top < 5) - top = threads[i + delta]?.getBoundingClientRect().top - topMargin - - window.scrollBy 0, top + thread = Nav.getThread() + axe = if delta is +1 + 'following' + else + 'preceding' + if next = $.x "#{axe}-sibling::div[contains(@class,'thread')][1]", thread + # Unless we're not at the beginning of the current thread, + # and thus wanting to move to beginning, + # or we're above the first thread and don't want to skip it. + top = Header.getTopOf thread + thread = next if delta is +1 and top < 5 or delta is -1 and top > -5 + Header.scrollTo thread diff --git a/src/Monitoring/Favicon.coffee b/src/Monitoring/Favicon.coffee index 806134034..6085a6f2c 100644 --- a/src/Monitoring/Favicon.coffee +++ b/src/Monitoring/Favicon.coffee @@ -45,5 +45,5 @@ Favicon = Favicon.unread = Favicon.unreadNSFW Favicon.unreadY = Favicon.unreadNSFWY - dead: 'data:image/gif;base64,<%= grunt.file.read("img/favicons/dead.gif", {encoding: "base64"}) %>' - logo: 'data:image/png;base64,<%= grunt.file.read("img/icon128.png", {encoding: "base64"}) %>' + dead: 'data:image/gif;base64,<%= grunt.file.read("img/favicons/dead.gif", {encoding: "base64"}) %>' + logo: 'data:image/png;base64,<%= grunt.file.read("img/icon128.png", {encoding: "base64"}) %>' diff --git a/src/Monitoring/ThreadStats.coffee b/src/Monitoring/ThreadStats.coffee index f80154cae..89d1be0c8 100644 --- a/src/Monitoring/ThreadStats.coffee +++ b/src/Monitoring/ThreadStats.coffee @@ -1,9 +1,7 @@ ThreadStats = init: -> return if g.VIEW isnt 'thread' or !Conf['Thread Stats'] - @dialog = UI.dialog 'thread-stats', 'bottom: 0; left: 0;', """ - <%= grunt.file.read('html/Monitoring/ThreadStats.html').replace(/>\s+<').trim() %> - """ + @dialog = UI.dialog 'thread-stats', 'bottom: 0; left: 0;', <%= importHTML('Monitoring/ThreadStats') %> @postCountEl = $ '#post-count', @dialog @fileCountEl = $ '#file-count', @dialog diff --git a/src/Monitoring/ThreadUpdater.coffee b/src/Monitoring/ThreadUpdater.coffee index 677db825d..0ef1d04bf 100644 --- a/src/Monitoring/ThreadUpdater.coffee +++ b/src/Monitoring/ThreadUpdater.coffee @@ -2,14 +2,19 @@ ThreadUpdater = init: -> return if g.VIEW isnt 'thread' or !Conf['Thread Updater'] + @button = $.el 'a', + className: 'thread-refresh-shortcut fa fa-refresh' + title: 'Refresh Thread' + href: 'javascript:;' + $.on @button, 'click', @update + Header.addShortcut @button, 1 + html = '' for name, conf of Config.updater.checkbox checked = if Conf[name] then 'checked' else '' html += "
    " - html = """ - <%= grunt.file.read('html/Monitoring/ThreadUpdater.html').replace(/>\s+<').trim() %> - """ + html = <%= importHTML('Monitoring/ThreadUpdater') %> @dialog = UI.dialog 'updater', 'bottom: 0; right: 0;', html @timer = $ '#update-timer', @dialog @@ -81,6 +86,7 @@ ThreadUpdater = ThreadUpdater.interval = @value = val $.cb.value.call @ if e load: (e) -> + $.rmClass ThreadUpdater.button, 'fa-spin' {req} = ThreadUpdater delete ThreadUpdater.req if e.type isnt 'loadend' # timeout or abort @@ -143,9 +149,10 @@ ThreadUpdater = update: -> return unless navigator.onLine + $.addClass ThreadUpdater.button, 'fa-spin' ThreadUpdater.count() ThreadUpdater.set 'timer', '...' - ThreadUpdater.req.abort() if ThreadUpdater.req + ThreadUpdater.req?.abort() url = "//api.4chan.org/#{ThreadUpdater.thread.board}/res/#{ThreadUpdater.thread}.json" ThreadUpdater.req = $.ajax url, onabort: ThreadUpdater.cb.load @@ -155,38 +162,27 @@ ThreadUpdater = , whenModified: true - updateThreadStatus: (title, OP) -> - titleLC = title.toLowerCase() - return if ThreadUpdater.thread["is#{title}"] is !!OP[titleLC] - unless ThreadUpdater.thread["is#{title}"] = !!OP[titleLC] - message = if title is 'Sticky' - 'The thread is not a sticky anymore.' + updateThreadStatus: (type, status) -> + return unless hasChanged = ThreadUpdater.thread["is#{type}"] isnt status + ThreadUpdater.thread.setStatus type, status + change = if type is 'Sticky' + if status + 'now a sticky' else - 'The thread is not closed anymore.' - new Notice 'info', message, 30 - $.rm $ ".#{titleLC}Icon", ThreadUpdater.thread.OP.nodes.info - return - message = if title is 'Sticky' - 'The thread is now a sticky.' + 'not a sticky anymore' else - 'The thread is now closed.' - new Notice 'info', message, 30 - icon = $.el 'img', - src: "//static.4chan.org/image/#{titleLC}.gif" - alt: title - title: title - className: "#{titleLC}Icon" - root = $ '[title="Quote this post"]', ThreadUpdater.thread.OP.nodes.info - if title is 'Closed' - root = $('.stickyIcon', ThreadUpdater.thread.OP.nodes.info) or root - $.after root, [$.tn(' '), icon] + if status + 'now closed' + else + 'not closed anymore' + new Notice 'info', "The thread is #{change}.", 30 parse: (postObjects) -> OP = postObjects[0] Build.spoilerRange[ThreadUpdater.thread.board] = OP.custom_spoiler - ThreadUpdater.updateThreadStatus 'Sticky', OP - ThreadUpdater.updateThreadStatus 'Closed', OP + ThreadUpdater.updateThreadStatus 'Sticky', !!OP.sticky + ThreadUpdater.updateThreadStatus 'Closed', !!OP.closed ThreadUpdater.thread.postLimit = !!OP.bumplimit ThreadUpdater.thread.fileLimit = !!OP.imagelimit @@ -250,15 +246,14 @@ ThreadUpdater = ThreadUpdater.lastPost = posts[count - 1].ID Main.callbackNodes Post, posts - scroll = Conf['Auto Scroll'] and ThreadUpdater.scrollBG() and - ThreadUpdater.root.getBoundingClientRect().bottom - doc.clientHeight < 25 + scroll = Conf['Auto Scroll'] and ThreadUpdater.scrollBG() and Header.getBottomOf(ThreadUpdater.root) > -25 $.add ThreadUpdater.root, nodes sendEvent() if scroll if Conf['Bottom Scroll'] window.scrollTo 0, d.body.clientHeight else - Header.scrollToPost nodes[0] + Header.scrollTo nodes[0] # Enable 4chan features. threadID = ThreadUpdater.thread.ID diff --git a/src/Monitoring/ThreadWatcher.coffee b/src/Monitoring/ThreadWatcher.coffee index 54bfb9435..3b021d5a7 100644 --- a/src/Monitoring/ThreadWatcher.coffee +++ b/src/Monitoring/ThreadWatcher.coffee @@ -3,15 +3,17 @@ ThreadWatcher = return if !Conf['Thread Watcher'] @db = new DataBoard 'watchedThreads', @refresh, true - @dialog = UI.dialog 'thread-watcher', 'top: 50px; left: 0px;', """ - <%= grunt.file.read('html/Monitoring/ThreadWatcher.html').replace(/>\s+<').trim() %> - """ + @dialog = UI.dialog 'thread-watcher', 'top: 50px; left: 0px;', <%= importHTML('Monitoring/ThreadWatcher') %> @status = $ '#watcher-status', @dialog @list = @dialog.lastElementChild $.on d, 'QRPostSuccessful', @cb.post - $.on d, 'ThreadUpdate', @cb.threadUpdate if g.VIEW is 'thread' $.on d, '4chanXInitFinished', @ready + switch g.VIEW + when 'index' + $.on d, 'IndexRefresh', @cb.onIndexRefresh + when 'thread' + $.on d, 'ThreadUpdate', @cb.onThreadRefresh now = Date.now() if (@db.data.lastChecked or 0) < now - 2 * $.HOUR @@ -69,7 +71,17 @@ ThreadWatcher = $.set 'AutoWatch', threadID else if Conf['Auto Watch Reply'] ThreadWatcher.add board.threads[threadID] - threadUpdate: (e) -> + onIndexRefresh: -> + {db} = ThreadWatcher + boardID = g.BOARD.ID + for threadID, data of db.data.boards[boardID] when not data.isDead and threadID not of g.BOARD.threads + if Conf['Auto Prune'] + ThreadWatcher.db.delete {boardID, threadID} + else + data.isDead = true + ThreadWatcher.db.set {boardID, threadID, val: data} + ThreadWatcher.refresh() + onThreadRefresh: (e) -> {thread} = e.detail return unless e.detail[404] and ThreadWatcher.db.get {boardID: thread.board.ID, threadID: thread.ID} # Update 404 status. @@ -100,7 +112,7 @@ ThreadWatcher = ThreadWatcher.status.textContent = status return if @status isnt 404 if Conf['Auto Prune'] - ThreadWatcher.rm boardID, threadID + ThreadWatcher.db.delete {boardID, threadID} else data.isDead = true ThreadWatcher.db.set {boardID, threadID, val: data} diff --git a/src/Monitoring/Unread.coffee b/src/Monitoring/Unread.coffee index fc7e2940e..8e13c463e 100644 --- a/src/Monitoring/Unread.coffee +++ b/src/Monitoring/Unread.coffee @@ -42,18 +42,16 @@ Unread = while root = $.x 'preceding-sibling::div[contains(@class,"replyContainer")][1]', post.nodes.root break unless (post = Get.postFromRoot root).isHidden return unless root - onload = -> root.scrollIntoView false if checkPosition root + down = true else # Scroll to the last read post. posts = Object.keys Unread.thread.posts {root} = Unread.thread.posts[posts[posts.length - 1]].nodes - onload = -> Header.scrollToPost root if checkPosition root - checkPosition = (target) -> - # Scroll to the target unless we scrolled past it. - target.getBoundingClientRect().bottom > doc.clientHeight # Prevent the browser to scroll back to # the previous scroll location on page load. - $.on window, 'load', onload + $.on window, 'load', -> + # Scroll to the target unless we scrolled past it. + Header.scrollTo root, down if Header.getBottomOf(root) < 0 sync: -> lastReadPost = Unread.db.get @@ -102,7 +100,7 @@ Unread = body: post.info.comment icon: Favicon.logo notif.onclick = -> - Header.scrollToPost post.nodes.root + Header.scrollToIfNeeded post.nodes.root, true window.focus() notif.onshow = -> setTimeout -> @@ -132,9 +130,8 @@ Unread = read: (e) -> return if d.hidden or !Unread.posts.length - height = doc.clientHeight for post, i in Unread.posts - break if post.nodes.root.getBoundingClientRect().bottom > height # post is not completely read + break if Header.getBottomOf(post.nodes.root) < -1 # post is not completely read return unless i Unread.lastReadPost = Unread.posts.splice(0, i)[i - 1].ID diff --git a/src/Posting/QR.coffee b/src/Posting/QR.coffee index d0be4c445..7a8f829ea 100644 --- a/src/Posting/QR.coffee +++ b/src/Posting/QR.coffee @@ -26,7 +26,7 @@ QR = $.event 'CloseMenu' QR.open() QR.nodes.com.focus() - Header.addShortcut sc, 1 + Header.addShortcut sc, 2 $.on d, 'QRGetSelectedPost', ({detail: cb}) -> cb QR.selected @@ -39,11 +39,15 @@ QR = $.on d, 'dragover', QR.dragOver $.on d, 'drop', QR.dropFile $.on d, 'dragstart dragend', QR.drag - $.on d, 'ThreadUpdate', -> - if g.DEAD - QR.abort() - else - QR.status() + switch g.VIEW + when 'index' + $.on d, 'IndexRefresh', QR.generatePostableThreadsList + when 'thread' + $.on d, 'ThreadUpdate', -> + if g.DEAD + QR.abort() + else + QR.status() QR.persist() if Conf['Persistent QR'] @@ -697,7 +701,7 @@ QR = imgContainer = $.el 'div', className: 'captcha-img' - title: 'Reload' + title: 'Reload reCAPTCHA' innerHTML: '' input = $.el 'input', className: 'captcha-input field' @@ -796,10 +800,27 @@ QR = return e.preventDefault() + generatePostableThreadsList: -> + return unless QR.nodes + list = QR.nodes.thread + options = [list.firstChild] + for thread of g.BOARD.threads + options.push $.el 'option', + value: thread + textContent: "Thread No.#{thread}" + val = list.value + $.rmAll list + $.add list, options + list.value = val + return unless list.value + # Fix the value if the option disappeared. + list.value = if g.VIEW is 'thread' + g.THREADID + else + 'new' + dialog: -> - dialog = UI.dialog 'qr', 'top:0;right:0;', """ - <%= grunt.file.read('html/Posting/QR.html').replace(/>\s+<').trim() %> - """ + dialog = UI.dialog 'qr', 'top:0;right:0;', <%= importHTML('Posting/QR') %> QR.nodes = nodes = el: dialog @@ -867,12 +888,6 @@ QR = nodes.flag.dataset.default = '0' $.add nodes.form, nodes.flag - # Make a list of threads. - for thread of g.BOARD.threads - $.add nodes.thread, $.el 'option', - value: thread - textContent: "Thread No.#{thread}" - <% if (type === 'userscript') { %> # XXX Firefox lacks focusin/focusout support. for elm in $$ '*', QR.nodes.el @@ -906,6 +921,7 @@ QR = $.set 'QR Size', @style.cssText <% } %> + QR.generatePostableThreadsList() QR.persona.init() new QR.post true QR.status()