Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/master'
Browse files Browse the repository at this point in the history
  • Loading branch information
liamwhite committed Jun 18, 2024
2 parents 3e33ab1 + 2858f0c commit c8030c6
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 78 deletions.
161 changes: 161 additions & 0 deletions assets/js/__tests__/imagesclientside.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { filterNode, initImagesClientside } from '../imagesclientside';
import { parseSearch } from '../match_query';
import { matchNone } from '../query/boolean';
import { assertNotNull } from '../utils/assert';
import { $ } from '../utils/dom';

describe('filterNode', () => {
beforeEach(() => {
window.booru.hiddenTagList = [];
window.booru.spoileredTagList = [];
window.booru.ignoredTagList = [];
window.booru.imagesWithDownvotingDisabled = [];

window.booru.hiddenFilter = matchNone();
window.booru.spoileredFilter = matchNone();
});

function makeMediaContainer() {
const element = document.createElement('div');
element.innerHTML = `
<div class="image-container" data-image-id="1" data-image-tags="[1]">
<div class="js-spoiler-info-overlay"></div>
<picture><img src=""/></picture>
</div>
`;
return [ element, assertNotNull($<HTMLDivElement>('.js-spoiler-info-overlay', element)) ];
}

it('should show image media boxes not matching any filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();

filterNode(container);
expect(spoilerOverlay).not.toContainHTML('(Complex Filter)');
expect(spoilerOverlay).not.toContainHTML('(unknown tag)');
expect(window.booru.imagesWithDownvotingDisabled).not.toContain('1');
});

it('should spoiler media boxes spoilered by a tag filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
window.booru.spoileredTagList = [1];

filterNode(container);
expect(spoilerOverlay).toContainHTML('(unknown tag)');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});

it('should spoiler media boxes spoilered by a complex filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
window.booru.spoileredFilter = parseSearch('id:1');

filterNode(container);
expect(spoilerOverlay).toContainHTML('(Complex Filter)');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});

it('should hide media boxes hidden by a tag filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
window.booru.hiddenTagList = [1];

filterNode(container);
expect(spoilerOverlay).toContainHTML('[HIDDEN]');
expect(spoilerOverlay).toContainHTML('(unknown tag)');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});

it('should hide media boxes hidden by a complex filter', () => {
const [ container, spoilerOverlay ] = makeMediaContainer();
window.booru.hiddenFilter = parseSearch('id:1');

filterNode(container);
expect(spoilerOverlay).toContainHTML('[HIDDEN]');
expect(spoilerOverlay).toContainHTML('(Complex Filter)');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});

function makeImageBlock(): HTMLElement[] {
const element = document.createElement('div');
element.innerHTML = `
<div class="image-show-container" data-image-id="1" data-image-tags="[1]">
<div class="image-filtered hidden">
<img src=""/>
<span class="filter-explanation"></span>
</div>
<div class="image-show hidden">
<picture><img src=""/></picture>
</div>
</div>
`;
return [
element,
assertNotNull($<HTMLDivElement>('.image-filtered', element)),
assertNotNull($<HTMLDivElement>('.image-show', element)),
assertNotNull($<HTMLSpanElement>('.filter-explanation', element))
];
}

it('should show image blocks not matching any filter', () => {
const [ container, imageFiltered, imageShow ] = makeImageBlock();

filterNode(container);
expect(imageFiltered).toHaveClass('hidden');
expect(imageShow).not.toHaveClass('hidden');
expect(window.booru.imagesWithDownvotingDisabled).not.toContain('1');
});

it('should spoiler image blocks spoilered by a tag filter', () => {
const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
window.booru.spoileredTagList = [1];

filterNode(container);
expect(imageFiltered).not.toHaveClass('hidden');
expect(imageShow).toHaveClass('hidden');
expect(filterExplanation).toContainHTML('spoilered by');
expect(filterExplanation).toContainHTML('(unknown tag)');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});

it('should spoiler image blocks spoilered by a complex filter', () => {
const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
window.booru.spoileredFilter = parseSearch('id:1');

filterNode(container);
expect(imageFiltered).not.toHaveClass('hidden');
expect(imageShow).toHaveClass('hidden');
expect(filterExplanation).toContainHTML('spoilered by');
expect(filterExplanation).toContainHTML('complex tag expression');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});

it('should hide image blocks hidden by a tag filter', () => {
const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
window.booru.hiddenTagList = [1];

filterNode(container);
expect(imageFiltered).not.toHaveClass('hidden');
expect(imageShow).toHaveClass('hidden');
expect(filterExplanation).toContainHTML('hidden by');
expect(filterExplanation).toContainHTML('(unknown tag)');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});

it('should hide image blocks hidden by a complex filter', () => {
const [ container, imageFiltered, imageShow, filterExplanation ] = makeImageBlock();
window.booru.hiddenFilter = parseSearch('id:1');

filterNode(container);
expect(imageFiltered).not.toHaveClass('hidden');
expect(imageShow).toHaveClass('hidden');
expect(filterExplanation).toContainHTML('hidden by');
expect(filterExplanation).toContainHTML('complex tag expression');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
});

});

describe('initImagesClientside', () => {
it('should initialize the imagesWithDownvotingDisabled array', () => {
initImagesClientside();
expect(window.booru.imagesWithDownvotingDisabled).toEqual([]);
});
});
76 changes: 0 additions & 76 deletions assets/js/imagesclientside.js

This file was deleted.

110 changes: 110 additions & 0 deletions assets/js/imagesclientside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Client-side image filtering/spoilering.
*/

import { assertNotUndefined } from './utils/assert';
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,
runCallback: RunCallback
): boolean {
const hit = (() => {
// Check tags array first to provide more precise filter explanations
const hitTags = imageHitsTags(img, tags);
if (hitTags.length !== 0) {
runCallback(img, hitTags, 'tags');
return true;
}

// No tags matched, try complex filter AST
const hitComplex = imageHitsComplex(img, complex);
if (hitComplex) {
runCallback(img, hitTags, 'complex');
return true;
}

// Nothing matched at all, image can be shown
return false;
})();

if (hit) {
// Disallow negative interaction on image which is not visible
window.booru.imagesWithDownvotingDisabled.push(assertNotUndefined(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] <i>(Complex Filter)</i>';
hideThumb(img, bannerImage(tagsHit), bannerText);
}

function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? displayTags(tagsHit)
: '<i>(Complex Filter)</i>';
spoilerThumb(img, bannerImage(tagsHit), bannerText);
}

function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, 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 <code>${escapeHtml(tagsHit[0].name)}</code>, 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<Document, 'querySelectorAll'>) {
const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
const { hiddenFilter, spoileredFilter } = window.booru;

// Image thumb boxes with vote and fave buttons on them
$$<HTMLDivElement>('.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
$$<HTMLDivElement>('.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();
}
4 changes: 2 additions & 2 deletions assets/js/utils/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit c8030c6

Please sign in to comment.