diff --git a/src/Filtering/Filter.coffee b/src/Filtering/Filter.coffee
index bcbd0e3602..17745f2fc7 100644
--- a/src/Filtering/Filter.coffee
+++ b/src/Filtering/Filter.coffee
@@ -78,10 +78,12 @@ Filter =
else
types = ['subject', 'name', 'filename', 'comment']
+ watch = /(?:^|;)\s*watch/.test filter
+
# Hide the post (default case).
- hide = !(hl or noti)
+ hide = !(hl or noti or watch)
- filter = {isstring, regexp, boards, excludes, mask, hide, stub, hl, top, noti}
+ filter = {isstring, regexp, boards, excludes, mask, hide, stub, hl, top, noti, watch}
if key is 'general'
for type in types
(@filters[type] or= []).push filter
@@ -119,11 +121,12 @@ Filter =
test: (post, hideable=true) ->
return post.filterResults if post.filterResults
- hide = false
- stub = true
- hl = undefined
- top = false
- noti = false
+ hide = false
+ stub = true
+ hl = undefined
+ top = false
+ noti = false
+ watch = false
if QuoteYou.isYou(post)
hideable = false
mask = (if post.isReply then 2 else 1)
@@ -149,14 +152,16 @@ Filter =
top or= filter.top
if filter.noti
noti = true
+ if filter.watch
+ watch = true
if hide
{hide, stub}
else
- {hl, top, noti}
+ {hl, top, noti, watch}
node: ->
return if @isClone
- {hide, stub, hl, top, noti} = Filter.test @, (!@isFetchedQuote and (@isReply or g.VIEW is 'index'))
+ {hide, stub, hl, top, noti, watch} = Filter.test @, (!@isFetchedQuote and (@isReply or g.VIEW is 'index'))
if hide
if @isReply
PostHiding.hide @, stub
@@ -168,6 +173,9 @@ Filter =
$.addClass @nodes.root, hl...
if noti and Unread.posts and (@ID > Unread.lastReadPost) and not QuoteYou.isYou(@)
Unread.openNotification @, ' triggered a notification filter'
+ if watch and !ThreadWatcher.isWatched(@thread)
+ ThreadWatcher.add(@thread)
+
catalog: ->
return unless (url = g.SITE.urls.catalogJSON?(g.BOARD))
diff --git a/src/General/Settings/Filter-guide.html b/src/General/Settings/Filter-guide.html
index 02bba58050..477ef4f4f0 100644
--- a/src/General/Settings/Filter-guide.html
+++ b/src/General/Settings/Filter-guide.html
@@ -43,6 +43,12 @@
Show a desktop notification instead of hiding.
For example: notify;.
+
+ Add thread to thread watcher instead of hiding.
+ For example: watch;
+ If you specify boards with this flag, the boards provided will be periodically scanned in the background for new threads.
+ If you do not specify boards, then it will scan every page you visit for matching threads/comments.
+
Filters in the "General" section apply to multiple fields, by default subject,name,filename,comment.
The fields can be specified with the type option, separated by commas.
diff --git a/src/Monitoring/AutoWatcher.coffee b/src/Monitoring/AutoWatcher.coffee
new file mode 100644
index 0000000000..68aa3e474e
--- /dev/null
+++ b/src/Monitoring/AutoWatcher.coffee
@@ -0,0 +1,89 @@
+AutoWatcher =
+ init: ->
+ return unless Conf['Filter']
+
+ AutoWatcher.periodicScan()
+
+ periodicScan: ->
+ clearTimeout AutoWatcher.timeout
+
+ interval = 5 * $.MINUTE
+
+ now = Date.now()
+
+ unless Conf['autoWatchLastScan'] and (now - interval < Conf['autoWatchLastScan'])
+ AutoWatcher.scan()
+ $.set 'autoWatchLastScan', now
+ AutoWatcher.timeout = setTimeout AutoWatcher.periodicScan, interval
+
+ scan: ->
+ sitesAndBoards = (for own _, filters of Filter.filters
+ Object.keys(filter.boards) for filter in filters when filter.watch and filter.boards
+ ).flat(2).reduce (acc, i) ->
+ [_, k, v] = i.match(/(.*)\/(.*)/)
+ acc[k] ?= []
+ acc[k].push(v)
+ acc
+ , {}
+ for own rawSite, boards of sitesAndBoards
+ break unless site = g.sites[rawSite]
+ for boardID in boards
+ AutoWatcher.fetchCatalog(boardID, site, AutoWatcher.parseCatalog)
+
+ fetchCatalog: (boardID, site, cb) ->
+ return unless url = site.urls['catalogJSON']?({boardID})
+
+ ajax = if site.ID is g.SITE.ID then $.ajax else CrossOrigin.ajax
+
+ onLoadEnd = ->
+ cb.apply @, [site, boardID]
+
+ $.whenModified(
+ url,
+ 'AutoWatcher'
+ onLoadEnd,
+ {timeout: $.MINUTE, ajax}
+ )
+
+ parseCatalog: (site, boardID) ->
+ addedThreads = false
+ rawCatalog = @.response.reduce ((acc, i, idx) ->
+ threads = for thread in i.threads
+ thread.extraData = {
+ page: idx + 1,
+ modified: thread.last_modified,
+ replies: thread.replies,
+ unread: thread.replies
+ }
+ if thread.last_replies
+ thread.extraData.last = thread.last_replies[thread.last_replies.length - 1].no
+ thread
+ acc.concat(threads)
+ ), []
+ for thread in rawCatalog
+ continue if ThreadWatcher.isWatchedRaw(boardID, thread.no)
+ parsedThread = site.Build.parseJSON(thread, {siteID: site.ID, boardID})
+
+ # Hacks for ThreadWatcher
+ parsedThread.isDead = false
+ parsedThread.board = {ID: boardID}
+ Object.assign(parsedThread, thread.extraData)
+
+ # I wish destructuring was actually pattern matching
+ {watch} = Filter.test(parsedThread)
+ continue unless watch
+
+ excerptName = (
+ parsedThread?.info?.subject or
+ parsedThread?.info?.comment.replace(/\n+/g, ' // ') or
+ parsedThread?.file?.name or
+ "No.#{parsedThread.ID}"
+ )
+ excerpt = "/#{boardID}/ - #{excerptName}"
+ excerpt = "#{excerpt[...70]}..." if excerpt.length > 73
+ data = Object.assign(thread.extraData, {excerpt})
+ ThreadWatcher.addRaw(boardID, parsedThread.ID, data, null, true)
+ addedThreads = true
+ # Check to see if we added any threads. If so, trigger a refresh AFTER we're done adding them all, to avoid spamming the API
+ # We already give the ThreadWatcher most of what it needs, this is just to get things like lastPage coloring
+ ThreadWatcher.buttonFetchAll() if addedThreads
diff --git a/src/Monitoring/ThreadWatcher.coffee b/src/Monitoring/ThreadWatcher.coffee
index 187f15dfb9..de77c42bf6 100644
--- a/src/Monitoring/ThreadWatcher.coffee
+++ b/src/Monitoring/ThreadWatcher.coffee
@@ -551,7 +551,7 @@ ThreadWatcher =
data.excerpt = Get.threadExcerpt thread if thread.OP
ThreadWatcher.addRaw boardID, threadID, data, cb
- addRaw: (boardID, threadID, data, cb) ->
+ addRaw: (boardID, threadID, data, cb, skipRefresh = false) ->
oldData = ThreadWatcher.db.get {boardID, threadID, defaultValue: $.dict()}
delete oldData.last
delete oldData.modified
@@ -559,6 +559,7 @@ ThreadWatcher =
ThreadWatcher.db.set {boardID, threadID, val: oldData}, cb
ThreadWatcher.refresh()
thread = {siteID: g.SITE.ID, boardID, threadID, data, force: true}
+ return if skipRefresh
if Conf['Show Page'] and !data.isDead
ThreadWatcher.fetchBoard [thread]
else if ThreadWatcher.unreadEnabled and Conf['Show Unread Count']
diff --git a/src/main/Main.coffee b/src/main/Main.coffee
index 0e79a5d736..2429a5324b 100644
--- a/src/main/Main.coffee
+++ b/src/main/Main.coffee
@@ -72,6 +72,7 @@ Main =
Conf['archives'] = Redirect.archives
Conf['selectedArchives'] = $.dict()
Conf['cooldowns'] = $.dict()
+ Conf['autoWatchLastScan'] = null
Conf['Index Sort'] = $.dict()
Conf["Last Long Reply Thresholds #{i}"] = $.dict() for i in [0...2]
Conf['siteProperties'] = $.dict()
@@ -521,7 +522,7 @@ Main =
unless nodes[i]
(cb() if cb)
return
- setTimeout softTask, 0
+ setTimeout softTask, 0
softTask()
@@ -698,6 +699,7 @@ Main =
['Thread Updater', ThreadUpdater]
['Thread Watcher', ThreadWatcher]
['Thread Watcher (Menu)', ThreadWatcher.menu]
+ ['Auto Watcher', AutoWatcher]
['Mark New IPs', MarkNewIPs]
['Index Navigation', Nav]
['Keybinds', Keybinds]