diff --git a/assets/js/imagesclientside.js b/assets/js/imagesclientside.js
deleted file mode 100644
index 7dc3fb31..00000000
--- a/assets/js/imagesclientside.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * Client-side image filtering/spoilering.
- */
-
-import { $$, escapeHtml } from './utils/dom';
-import { setupInteractions } from './interactions';
-import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image';
-import { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag';
-
-function runFilter(img, test, runCallback) {
- if (!test || test.length === 0) return false;
-
- runCallback(img, test);
-
- // I don't like this.
- window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId);
-
- return true;
-}
-
-// ---
-
-function filterThumbSimple(img, tagsHit) { hideThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `[HIDDEN] ${displayTags(tagsHit)}`); }
-function spoilerThumbSimple(img, tagsHit) { spoilerThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, displayTags(tagsHit)); }
-function filterThumbComplex(img) { hideThumb(img, window.booru.hiddenTag, '[HIDDEN] (Complex Filter)'); }
-function spoilerThumbComplex(img) { spoilerThumb(img, window.booru.hiddenTag, '(Complex Filter)'); }
-
-function filterBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}
, which is hidden by `); }
-function spoilerBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}
, which is spoilered by `); }
-function filterBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in '); }
-function spoilerBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in '); }
-
-// ---
-
-function thumbTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterThumbSimple); }
-function thumbComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex); }
-function thumbTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerThumbSimple); }
-function thumbComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerThumbComplex); }
-
-function blockTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterBlockSimple); }
-function blockComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterBlockComplex); }
-function blockTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerBlockSimple); }
-function blockComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerBlockComplex); }
-
-// ---
-
-function filterNode(node = document) {
- const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
- const { hiddenFilter, spoileredFilter } = window.booru;
-
- // Image thumb boxes with vote and fave buttons on them
- $$('.image-container', node)
- .filter(img => !thumbTagFilter(hiddenTags, img))
- .filter(img => !thumbComplexFilter(hiddenFilter, img))
- .filter(img => !thumbTagSpoiler(spoileredTags, img))
- .filter(img => !thumbComplexSpoiler(spoileredFilter, img))
- .forEach(img => showThumb(img));
-
- // Individual image pages and images in posts/comments
- $$('.image-show-container', node)
- .filter(img => !blockTagFilter(hiddenTags, img))
- .filter(img => !blockComplexFilter(hiddenFilter, img))
- .filter(img => !blockTagSpoiler(spoileredTags, img))
- .filter(img => !blockComplexSpoiler(spoileredFilter, img))
- .forEach(img => showBlock(img));
-}
-
-function initImagesClientside() {
- window.booru.imagesWithDownvotingDisabled = [];
- // This fills the imagesWithDownvotingDisabled array
- filterNode(document);
- // Once the array is populated, we can initialize interactions
- setupInteractions();
-}
-
-export { initImagesClientside, filterNode };
diff --git a/assets/js/imagesclientside.ts b/assets/js/imagesclientside.ts
new file mode 100644
index 00000000..61e1d1ac
--- /dev/null
+++ b/assets/js/imagesclientside.ts
@@ -0,0 +1,109 @@
+/**
+ * Client-side image filtering/spoilering.
+ */
+
+import { $$, escapeHtml } from './utils/dom';
+import { setupInteractions } from './interactions';
+import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image';
+import { TagData, getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag';
+import { AstMatcher } from './query/types';
+
+type CallbackType = 'tags' | 'complex';
+type RunCallback = (img: HTMLDivElement, tags: TagData[], type: CallbackType) => void;
+
+function run(
+ img: HTMLDivElement,
+ tags: TagData[],
+ complex: AstMatcher,
+ callback: RunCallback,
+): boolean {
+ const hit = (() => {
+ // Check tags array first to provide more precise filter explanations
+ const hitTags = imageHitsTags(img, tags);
+ if (hitTags.length !== 0) {
+ callback(img, hitTags, 'tags');
+ return true;
+ }
+
+ // No tags matched, try complex filter AST
+ const hitComplex = imageHitsComplex(img, complex);
+ if (hitComplex) {
+ callback(img, hitTags, 'complex');
+ return true;
+ }
+
+ // Nothing matched at all, image can be shown
+ return false;
+ })();
+
+ if (hit && img.dataset.imageId) {
+ // Disallow negative interaction on image which is not visible
+ window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId);
+ }
+
+ return hit;
+}
+
+function bannerImage(tagsHit: TagData[]) {
+ if (tagsHit.length > 0) {
+ return tagsHit[0].spoiler_image_uri || window.booru.hiddenTag;
+ }
+
+ return window.booru.hiddenTag;
+}
+
+// TODO: this approach is not suitable for translations because it depends on
+// markup embedded in the page adjacent to this text
+
+/* eslint-disable indent */
+
+function hideThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
+ const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}`
+ : '[HIDDEN] (Complex Filter)';
+ hideThumb(img, bannerImage(tagsHit), bannerText);
+}
+
+function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
+ const bannerText = type === 'tags' ? displayTags(tagsHit)
+ : '(Complex Filter)';
+ spoilerThumb(img, bannerImage(tagsHit), bannerText);
+}
+
+function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
+ const bannerText = type === 'tags' ? `This image is tagged ${escapeHtml(tagsHit[0].name)}
, which is hidden by `
+ : 'This image was hidden by a complex tag expression in ';
+ spoilerBlock(img, bannerImage(tagsHit), bannerText);
+}
+
+function spoilerBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
+ const bannerText = type === 'tags' ? `This image is tagged ${escapeHtml(tagsHit[0].name)}
, which is spoilered by `
+ : 'This image was spoilered by a complex tag expression in ';
+ spoilerBlock(img, bannerImage(tagsHit), bannerText);
+}
+
+/* eslint-enable indent */
+
+export function filterNode(node: Pick) {
+ const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
+ const { hiddenFilter, spoileredFilter } = window.booru;
+
+ // Image thumb boxes with vote and fave buttons on them
+ $$('.image-container', node)
+ .filter(img => !run(img, hiddenTags, hiddenFilter, hideThumbTyped))
+ .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerThumbTyped))
+ .forEach(img => showThumb(img));
+
+ // Individual image pages and images in posts/comments
+ $$('.image-show-container', node)
+ .filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped))
+ .filter(img => !run(img, spoileredTags, spoileredFilter, spoilerBlockTyped))
+ .forEach(img => showBlock(img));
+}
+
+export function initImagesClientside() {
+ window.booru.imagesWithDownvotingDisabled = [];
+ // This fills the imagesWithDownvotingDisabled array
+ filterNode(document);
+ // Once the array is populated, we can initialize interactions
+ setupInteractions();
+}
diff --git a/assets/js/utils/tag.ts b/assets/js/utils/tag.ts
index ea104213..26c6bdf9 100644
--- a/assets/js/utils/tag.ts
+++ b/assets/js/utils/tag.ts
@@ -28,13 +28,13 @@ function sortTags(hidden: boolean, a: TagData, b: TagData): number {
return a.spoiler_image_uri ? -1 : 1;
}
-export function getHiddenTags() {
+export function getHiddenTags(): TagData[] {
return unique(window.booru.hiddenTagList)
.map(tagId => getTag(tagId))
.sort(sortTags.bind(null, true));
}
-export function getSpoileredTags() {
+export function getSpoileredTags(): TagData[] {
if (window.booru.spoilerType === 'off') return [];
return unique(window.booru.spoileredTagList)