feat: implement word/phrase filters (#1990)
* feat: implement word filters * fix: more progress on word filters * fix: more progress * fix: more work * fix: more work * fix: more progress * fix: tweaks * fix: basic crud stuff * fix: more work * test: add tests * test: more test * fix: handle filter expiry correctly * fix: implement more efficient word filter logic * fix: better required labels * test: fix test
This commit is contained in:
parent
3271344c76
commit
4adc8ff748
|
@ -39,6 +39,7 @@
|
||||||
"build-vercel-json": "node -r esm bin/build-vercel-json.js"
|
"build-vercel-json": "node -r esm bin/build-vercel-json.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formatjs/intl-listformat": "^5.0.10",
|
||||||
"@formatjs/intl-locale": "^2.4.14",
|
"@formatjs/intl-locale": "^2.4.14",
|
||||||
"@formatjs/intl-pluralrules": "^4.0.6",
|
"@formatjs/intl-pluralrules": "^4.0.6",
|
||||||
"@formatjs/intl-relativetimeformat": "^8.0.4",
|
"@formatjs/intl-relativetimeformat": "^8.0.4",
|
||||||
|
|
|
@ -4,8 +4,8 @@ import './routes/_utils/historyEvents'
|
||||||
import './routes/_utils/loadingMask'
|
import './routes/_utils/loadingMask'
|
||||||
import './routes/_utils/forceOnline'
|
import './routes/_utils/forceOnline'
|
||||||
import { mark, stop } from './routes/_utils/marks'
|
import { mark, stop } from './routes/_utils/marks'
|
||||||
import { loadPolyfills } from './routes/_utils/loadPolyfills'
|
import { loadPolyfills } from './routes/_utils/polyfills/loadPolyfills'
|
||||||
import { loadNonCriticalPolyfills } from './routes/_utils/loadNonCriticalPolyfills'
|
import { loadNonCriticalPolyfills } from './routes/_utils/polyfills/loadNonCriticalPolyfills'
|
||||||
|
|
||||||
mark('loadPolyfills')
|
mark('loadPolyfills')
|
||||||
loadPolyfills().then(() => {
|
loadPolyfills().then(() => {
|
||||||
|
|
|
@ -214,9 +214,11 @@ export default {
|
||||||
thirtyMinutes: '30 minutes',
|
thirtyMinutes: '30 minutes',
|
||||||
oneHour: '1 hour',
|
oneHour: '1 hour',
|
||||||
sixHours: '6 hours',
|
sixHours: '6 hours',
|
||||||
|
twelveHours: '12 hours',
|
||||||
oneDay: '1 day',
|
oneDay: '1 day',
|
||||||
threeDays: '3 days',
|
threeDays: '3 days',
|
||||||
sevenDays: '7 days',
|
sevenDays: '7 days',
|
||||||
|
never: 'Never',
|
||||||
addEmoji: 'Insert emoji',
|
addEmoji: 'Insert emoji',
|
||||||
addMedia: 'Add media (images, video, audio)',
|
addMedia: 'Add media (images, video, audio)',
|
||||||
addPoll: 'Add poll',
|
addPoll: 'Add poll',
|
||||||
|
@ -625,5 +627,28 @@ export default {
|
||||||
showingOfflineContent: 'Internet request failed. Showing offline content.',
|
showingOfflineContent: 'Internet request failed. Showing offline content.',
|
||||||
youAreOffline: 'You seem to be offline. You can still read toots while offline.',
|
youAreOffline: 'You seem to be offline. You can still read toots while offline.',
|
||||||
// Snackbar UI
|
// Snackbar UI
|
||||||
updateAvailable: 'App update available.'
|
updateAvailable: 'App update available.',
|
||||||
|
// Word/phrase filters
|
||||||
|
wordFilters: 'Word filters',
|
||||||
|
noFilters: 'You don\'t have any word filters.',
|
||||||
|
wordOrPhrase: 'Word or phrase',
|
||||||
|
contexts: 'Contexts',
|
||||||
|
addFilter: 'Add filter',
|
||||||
|
editFilter: 'Edit filter',
|
||||||
|
filterHome: 'Home and lists',
|
||||||
|
filterNotifications: 'Notifications',
|
||||||
|
filterPublic: 'Public timelines',
|
||||||
|
filterThread: 'Conversations',
|
||||||
|
filterAccount: 'Profiles',
|
||||||
|
filterUnknown: 'Unknown',
|
||||||
|
expireAfter: 'Expire after',
|
||||||
|
whereToFilter: 'Where to filter',
|
||||||
|
irreversible: 'Irreversible',
|
||||||
|
wholeWord: 'Whole word',
|
||||||
|
save: 'Save',
|
||||||
|
updatedFilter: 'Updated filter',
|
||||||
|
createdFilter: 'Created filter',
|
||||||
|
failedToModifyFilter: 'Failed to modify filter: {error}',
|
||||||
|
deletedFilter: 'Deleted filter',
|
||||||
|
required: 'Required'
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,9 +31,9 @@ async function insertUpdatesIntoTimeline (instanceName, timelineName, updates) {
|
||||||
console.log('itemSummariesToAdd', JSON.parse(JSON.stringify(itemSummariesToAdd)))
|
console.log('itemSummariesToAdd', JSON.parse(JSON.stringify(itemSummariesToAdd)))
|
||||||
console.log('updates.map(timelineItemToSummary)', JSON.parse(JSON.stringify(updates.map(timelineItemToSummary))))
|
console.log('updates.map(timelineItemToSummary)', JSON.parse(JSON.stringify(updates.map(timelineItemToSummary))))
|
||||||
console.log('concat(itemSummariesToAdd, updates.map(timelineItemToSummary))',
|
console.log('concat(itemSummariesToAdd, updates.map(timelineItemToSummary))',
|
||||||
JSON.parse(JSON.stringify(concat(itemSummariesToAdd, updates.map(timelineItemToSummary)))))
|
JSON.parse(JSON.stringify(concat(itemSummariesToAdd, updates.map(item => timelineItemToSummary(item, instanceName))))))
|
||||||
const newItemSummariesToAdd = uniqBy(
|
const newItemSummariesToAdd = uniqBy(
|
||||||
concat(itemSummariesToAdd, updates.map(timelineItemToSummary)),
|
concat(itemSummariesToAdd, updates.map(item => timelineItemToSummary(item, instanceName))),
|
||||||
_ => _.id
|
_ => _.id
|
||||||
)
|
)
|
||||||
if (!isEqual(itemSummariesToAdd, newItemSummariesToAdd)) {
|
if (!isEqual(itemSummariesToAdd, newItemSummariesToAdd)) {
|
||||||
|
@ -78,7 +78,7 @@ async function insertUpdatesIntoThreads (instanceName, updates) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const newItemSummariesToAdd = uniqBy(
|
const newItemSummariesToAdd = uniqBy(
|
||||||
concat(itemSummariesToAdd, validUpdates.map(timelineItemToSummary)),
|
concat(itemSummariesToAdd, validUpdates.map(item => timelineItemToSummary(item, instanceName))),
|
||||||
_ => _.id
|
_ => _.id
|
||||||
)
|
)
|
||||||
if (!isEqual(itemSummariesToAdd, newItemSummariesToAdd)) {
|
if (!isEqual(itemSummariesToAdd, newItemSummariesToAdd)) {
|
||||||
|
|
63
src/routes/_actions/filters.js
Normal file
63
src/routes/_actions/filters.js
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { store } from '../_store/store'
|
||||||
|
import { createFilter, getFilters, updateFilter, deleteFilter as doDeleteFilter } from '../_api/filters'
|
||||||
|
import { cacheFirstUpdateAfter, cacheFirstUpdateOnlyIfNotInCache } from '../_utils/sync'
|
||||||
|
import { database } from '../_database/database'
|
||||||
|
import { isEqual } from 'lodash-es'
|
||||||
|
import { toast } from '../_components/toast/toast'
|
||||||
|
import { formatIntl } from '../_utils/formatIntl'
|
||||||
|
import { emit } from '../_utils/eventBus'
|
||||||
|
|
||||||
|
async function syncFilters (instanceName, syncMethod) {
|
||||||
|
const { loggedInInstances } = store.get()
|
||||||
|
const accessToken = loggedInInstances[instanceName].access_token
|
||||||
|
|
||||||
|
await syncMethod(
|
||||||
|
() => getFilters(instanceName, accessToken),
|
||||||
|
() => database.getFilters(instanceName),
|
||||||
|
filters => database.setFilters(instanceName, filters),
|
||||||
|
filters => {
|
||||||
|
const { instanceFilters } = store.get()
|
||||||
|
if (!isEqual(instanceFilters[instanceName], filters)) { // avoid re-render if nothing changed
|
||||||
|
instanceFilters[instanceName] = filters
|
||||||
|
store.set({ instanceFilters })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFiltersForInstance (instanceName) {
|
||||||
|
await syncFilters(instanceName, cacheFirstUpdateAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupFiltersForInstance (instanceName) {
|
||||||
|
await syncFilters(instanceName, cacheFirstUpdateOnlyIfNotInCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrUpdateFilter (instanceName, filter) {
|
||||||
|
const { loggedInInstances } = store.get()
|
||||||
|
const accessToken = loggedInInstances[instanceName].access_token
|
||||||
|
try {
|
||||||
|
if (filter.id) {
|
||||||
|
await updateFilter(instanceName, accessToken, filter)
|
||||||
|
/* no await */ toast.say('intl.updatedFilter')
|
||||||
|
} else {
|
||||||
|
await createFilter(instanceName, accessToken, filter)
|
||||||
|
/* no await */ toast.say('intl.createdFilter')
|
||||||
|
}
|
||||||
|
emit('wordFiltersChanged', instanceName)
|
||||||
|
} catch (err) {
|
||||||
|
/* no await */ toast.say(formatIntl('intl.failedToModifyFilter', err.message || ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFilter (instanceName, id) {
|
||||||
|
const { loggedInInstances } = store.get()
|
||||||
|
const accessToken = loggedInInstances[instanceName].access_token
|
||||||
|
try {
|
||||||
|
await doDeleteFilter(instanceName, accessToken, id)
|
||||||
|
/* no await */ toast.say('intl.deletedFilter')
|
||||||
|
emit('wordFiltersChanged', instanceName)
|
||||||
|
} catch (err) {
|
||||||
|
/* no await */ toast.say(formatIntl('intl.failedToModifyFilter', err.message || ''))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import { mark, stop } from '../../_utils/marks'
|
import { mark, stop } from '../../_utils/marks'
|
||||||
import { deleteStatus } from '../deleteStatuses'
|
import { deleteStatus } from '../deleteStatuses'
|
||||||
import { addStatusOrNotification } from '../addStatusOrNotification'
|
import { addStatusOrNotification } from '../addStatusOrNotification'
|
||||||
|
import { emit } from '../../_utils/eventBus'
|
||||||
|
|
||||||
const KNOWN_EVENTS = ['update', 'delete', 'notification', 'conversation']
|
const KNOWN_EVENTS = ['update', 'delete', 'notification', 'conversation', 'filters_changed']
|
||||||
|
|
||||||
export function processMessage (instanceName, timelineName, message) {
|
export function processMessage (instanceName, timelineName, message) {
|
||||||
let { event, payload } = (message || {})
|
let { event, payload } = (message || {})
|
||||||
|
@ -36,6 +37,9 @@ export function processMessage (instanceName, timelineName, message) {
|
||||||
// It will add new DMs as new conversations instead of updating existing threads
|
// It will add new DMs as new conversations instead of updating existing threads
|
||||||
addStatusOrNotification(instanceName, timelineName, payload.last_status)
|
addStatusOrNotification(instanceName, timelineName, payload.last_status)
|
||||||
break
|
break
|
||||||
|
case 'filters_changed':
|
||||||
|
emit('wordFiltersChanged', instanceName)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
stop('processMessage')
|
stop('processMessage')
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,7 +98,7 @@ async function fetchTimelineItemsFromNetwork (instanceName, accessToken, timelin
|
||||||
async function addPagedTimelineItems (instanceName, timelineName, items) {
|
async function addPagedTimelineItems (instanceName, timelineName, items) {
|
||||||
console.log('addPagedTimelineItems, length:', items.length)
|
console.log('addPagedTimelineItems, length:', items.length)
|
||||||
mark('addPagedTimelineItemSummaries')
|
mark('addPagedTimelineItemSummaries')
|
||||||
const newSummaries = items.map(timelineItemToSummary)
|
const newSummaries = items.map(item => timelineItemToSummary(item, instanceName))
|
||||||
await addPagedTimelineItemSummaries(instanceName, timelineName, newSummaries)
|
await addPagedTimelineItemSummaries(instanceName, timelineName, newSummaries)
|
||||||
stop('addPagedTimelineItemSummaries')
|
stop('addPagedTimelineItemSummaries')
|
||||||
}
|
}
|
||||||
|
@ -154,7 +154,7 @@ async function fetchTimelineItems (instanceName, accessToken, timelineName, onli
|
||||||
async function addTimelineItems (instanceName, timelineName, items, stale) {
|
async function addTimelineItems (instanceName, timelineName, items, stale) {
|
||||||
console.log('addTimelineItems, length:', items.length)
|
console.log('addTimelineItems, length:', items.length)
|
||||||
mark('addTimelineItemSummaries')
|
mark('addTimelineItemSummaries')
|
||||||
const newSummaries = items.map(timelineItemToSummary)
|
const newSummaries = items.map(item => timelineItemToSummary(item, instanceName))
|
||||||
addTimelineItemSummaries(instanceName, timelineName, newSummaries, stale)
|
addTimelineItemSummaries(instanceName, timelineName, newSummaries, stale)
|
||||||
stop('addTimelineItemSummaries')
|
stop('addTimelineItemSummaries')
|
||||||
}
|
}
|
||||||
|
|
22
src/routes/_api/filters.js
Normal file
22
src/routes/_api/filters.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { get, DEFAULT_TIMEOUT, post, WRITE_TIMEOUT, put, del } from '../_utils/ajax'
|
||||||
|
import { auth, basename } from './utils'
|
||||||
|
|
||||||
|
export function getFilters (instanceName, accessToken) {
|
||||||
|
const url = `${basename(instanceName)}/api/v1/filters`
|
||||||
|
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFilter (instanceName, accessToken, filter) {
|
||||||
|
const url = `${basename(instanceName)}/api/v1/filters`
|
||||||
|
return post(url, filter, auth(accessToken), { timeout: WRITE_TIMEOUT })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFilter (instanceName, accessToken, filter) {
|
||||||
|
const url = `${basename(instanceName)}/api/v1/filters/${filter.id}`
|
||||||
|
return put(url, filter, auth(accessToken), { timeout: WRITE_TIMEOUT })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFilter (instanceName, accessToken, id) {
|
||||||
|
const url = `${basename(instanceName)}/api/v1/filters/${id}`
|
||||||
|
return del(url, auth(accessToken), { timeout: WRITE_TIMEOUT })
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const importShowWordFilterDialog = () => import(
|
||||||
|
/* webpackChunkName: 'showWordFilterDialog' */ '../creators/showWordFilterDialog'
|
||||||
|
).then(mod => mod.default)
|
|
@ -66,7 +66,7 @@
|
||||||
import { doubleRAF } from '../../../_utils/doubleRAF'
|
import { doubleRAF } from '../../../_utils/doubleRAF'
|
||||||
import { convertCustomEmojiToEmojiPickerFormat } from '../../../_utils/convertCustomEmojiToEmojiPickerFormat'
|
import { convertCustomEmojiToEmojiPickerFormat } from '../../../_utils/convertCustomEmojiToEmojiPickerFormat'
|
||||||
import { supportsFocusVisible } from '../../../_utils/supportsFocusVisible'
|
import { supportsFocusVisible } from '../../../_utils/supportsFocusVisible'
|
||||||
import { importFocusVisible } from '../../../_utils/asyncPolyfills'
|
import { importFocusVisible } from '../../../_utils/polyfills/asyncPolyfills'
|
||||||
import { emojiPickerI18n, emojiPickerDataSource, emojiPickerLocale } from '../../../_static/emojiPickerIntl'
|
import { emojiPickerI18n, emojiPickerDataSource, emojiPickerLocale } from '../../../_static/emojiPickerIntl'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<form class="confirmation-dialog-form">
|
<form class="confirmation-dialog-form">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<div class="confirmation-dialog-form-flex">
|
<div class="confirmation-dialog-form-flex">
|
||||||
<button type="button" on:click="onPositive()">
|
<button type="button" disabled={confirmationButtonDisabled} on:click="onPositive()">
|
||||||
{positiveText || 'OK'}
|
{positiveText || 'OK'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" on:click="onNegative()">
|
<button type="button" on:click="onNegative()">
|
||||||
|
|
254
src/routes/_components/dialog/components/WordFilterDialog.html
Normal file
254
src/routes/_components/dialog/components/WordFilterDialog.html
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
<GenericConfirmationDialog
|
||||||
|
{id}
|
||||||
|
{label}
|
||||||
|
{title}
|
||||||
|
{positiveText}
|
||||||
|
{confirmationButtonDisabled}
|
||||||
|
on:positive="save()">
|
||||||
|
<div class="word-filter-dialog">
|
||||||
|
<div class="word-filter-keyword">
|
||||||
|
<label for="word-filter-word-or-phrase" class="word-filter-keyword-label">
|
||||||
|
<span>{intl.wordOrPhrase}</span>
|
||||||
|
<!-- no need for aria-label="Required", the input is already marked as required -->
|
||||||
|
<span aria-hidden="true" class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
on:input="onWordOrPhraseChange(event)"
|
||||||
|
ref:wordInput
|
||||||
|
id="word-filter-word-or-phrase"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="word-filter-expire-after" ref:expireSelectWrapper>
|
||||||
|
<span class="word-filter-label-like word-filter-expire-label">{intl.expireAfter}</span>
|
||||||
|
<Select className="word-filter-expiry-select"
|
||||||
|
options={expiryOptions}
|
||||||
|
defaultValue={expiryDefaultValue}
|
||||||
|
on:change="onExpiryChange(event)"
|
||||||
|
label="{intl.expireAfter}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="word-filter-where-to-filter">
|
||||||
|
<span class="word-filter-label-like" id="word-filter-where-to-filter-label">
|
||||||
|
<span>{intl.whereToFilter}</span>
|
||||||
|
<span aria-label="{intl.required}" class="required">*</span>
|
||||||
|
</span>
|
||||||
|
<ul class="word-filter-radio-list" aria-describedby="word-filter-where-to-filter-label" ref:contextCheckboxes>
|
||||||
|
{#each filterContexts as context}
|
||||||
|
<li>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="where-to-filter"
|
||||||
|
value={context.value}
|
||||||
|
on:change="onContextChange(event)"
|
||||||
|
id="where-to-filter-{context.value}">
|
||||||
|
<label for="where-to-filter-{context.value}">{context.label}</label>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="word-filter-irreversible">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="irreversible"
|
||||||
|
ref:irreversibleCheckbox
|
||||||
|
id="word-filter-irreversible">
|
||||||
|
<label for="word-filter-irreversible">{intl.irreversible}</label>
|
||||||
|
</div>
|
||||||
|
<div class="word-filter-whole">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="irreversible"
|
||||||
|
ref:wholeWordCheckbox
|
||||||
|
id="word-filter-whole">
|
||||||
|
<label for="word-filter-whole">{intl.wholeWord}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GenericConfirmationDialog>
|
||||||
|
<style>
|
||||||
|
.word-filter-dialog {
|
||||||
|
padding: 20px 40px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "keyword expire"
|
||||||
|
"context context"
|
||||||
|
"irreversible whole";
|
||||||
|
grid-row-gap: 20px;
|
||||||
|
grid-column-gap: 10px;
|
||||||
|
}
|
||||||
|
.word-filter-label-like {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
.word-filter-radio-list {
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.word-filter-keyword {
|
||||||
|
grid-area: keyword;
|
||||||
|
}
|
||||||
|
.word-filter-expire-after {
|
||||||
|
grid-area: expire;
|
||||||
|
}
|
||||||
|
.word-filter-where-to-filter {
|
||||||
|
grid-area: context;
|
||||||
|
}
|
||||||
|
.word-filter-irreversible {
|
||||||
|
grid-area: irreversible;
|
||||||
|
}
|
||||||
|
.word-filter-whole {
|
||||||
|
grid-area: whole;
|
||||||
|
}
|
||||||
|
.word-filter-keyword-label, .word-filter-expire-label {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.required {
|
||||||
|
color: var(--warn-color);
|
||||||
|
}
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.word-filter-dialog {
|
||||||
|
grid-template-areas: "keyword"
|
||||||
|
"expire"
|
||||||
|
"context"
|
||||||
|
"irreversible"
|
||||||
|
"whole";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
import GenericConfirmationDialog from './GenericConfirmationDialog.html'
|
||||||
|
import Select from '../../Select.html'
|
||||||
|
import { show } from '../helpers/showDialog'
|
||||||
|
import { close } from '../helpers/closeDialog'
|
||||||
|
import { oncreate as onCreateDialog } from '../helpers/onCreateDialog'
|
||||||
|
import { store } from '../../../_store/store'
|
||||||
|
import {
|
||||||
|
WORD_FILTER_CONTEXT_ACCOUNT,
|
||||||
|
WORD_FILTER_CONTEXT_HOME,
|
||||||
|
WORD_FILTER_CONTEXT_NOTIFICATIONS,
|
||||||
|
WORD_FILTER_CONTEXT_PUBLIC,
|
||||||
|
WORD_FILTER_CONTEXT_THREAD,
|
||||||
|
WORD_FILTER_CONTEXTS,
|
||||||
|
WORD_FILTER_EXPIRY_DEFAULT,
|
||||||
|
WORD_FILTER_EXPIRY_OPTIONS
|
||||||
|
} from '../../../_static/wordFilters'
|
||||||
|
import { createOrUpdateFilter } from '../../../_actions/filters'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async oncreate () {
|
||||||
|
onCreateDialog.call(this)
|
||||||
|
this.syncFilterToDom(this.get().filter)
|
||||||
|
this.computeConfirmationButtonDisabled()
|
||||||
|
},
|
||||||
|
store: () => store,
|
||||||
|
data: () => ({
|
||||||
|
filter: undefined,
|
||||||
|
instanceName: undefined,
|
||||||
|
positiveText: 'intl.save',
|
||||||
|
expiryOptions: WORD_FILTER_EXPIRY_OPTIONS,
|
||||||
|
expiryDefaultValue: WORD_FILTER_EXPIRY_DEFAULT,
|
||||||
|
filterContexts: [
|
||||||
|
{
|
||||||
|
label: 'intl.filterHome',
|
||||||
|
value: WORD_FILTER_CONTEXT_HOME
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'intl.filterNotifications',
|
||||||
|
value: WORD_FILTER_CONTEXT_NOTIFICATIONS
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'intl.filterPublic',
|
||||||
|
value: WORD_FILTER_CONTEXT_PUBLIC
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'intl.filterThread',
|
||||||
|
value: WORD_FILTER_CONTEXT_THREAD
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'intl.filterAccount',
|
||||||
|
value: WORD_FILTER_CONTEXT_ACCOUNT
|
||||||
|
}
|
||||||
|
],
|
||||||
|
confirmationButtonDisabled: false
|
||||||
|
}),
|
||||||
|
methods: {
|
||||||
|
show,
|
||||||
|
close,
|
||||||
|
onWordOrPhraseChange (event) {
|
||||||
|
this.computeConfirmationButtonDisabled()
|
||||||
|
},
|
||||||
|
onExpiryChange (event) {
|
||||||
|
|
||||||
|
},
|
||||||
|
onContextChange (event) {
|
||||||
|
this.computeConfirmationButtonDisabled()
|
||||||
|
},
|
||||||
|
async save () {
|
||||||
|
const filter = this.syncDomToFilter()
|
||||||
|
const { instanceName } = this.get()
|
||||||
|
await createOrUpdateFilter(instanceName, filter)
|
||||||
|
},
|
||||||
|
computeConfirmationButtonDisabled () {
|
||||||
|
const confirmationButtonDisabled = !(this.refs.wordInput.value.replace(/\s+/g, '') &&
|
||||||
|
[...this.refs.contextCheckboxes.querySelectorAll('input')].some(checkbox => checkbox.checked))
|
||||||
|
this.set({ confirmationButtonDisabled })
|
||||||
|
},
|
||||||
|
syncFilterToDom (filter) {
|
||||||
|
if (!filter) {
|
||||||
|
filter = {
|
||||||
|
phrase: '',
|
||||||
|
expires_at: WORD_FILTER_EXPIRY_DEFAULT,
|
||||||
|
context: [...WORD_FILTER_CONTEXTS],
|
||||||
|
irreversible: false,
|
||||||
|
whole_word: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refs.wordInput.value = filter.phrase
|
||||||
|
|
||||||
|
let expiresAtValue = 0
|
||||||
|
if (filter.expires_at) {
|
||||||
|
const now = Date.now()
|
||||||
|
expiresAtValue = WORD_FILTER_EXPIRY_OPTIONS.filter(_ => _.value)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// expires_at is an absolute timestamp, so sort by whichever one is closest given the current datetime
|
||||||
|
const aAbsoluteTime = now + (a.value * 1000)
|
||||||
|
const bAbsoluteTime = now + (b.value * 1000)
|
||||||
|
const aDelta = Math.abs(new Date(filter.expires_at).getTime() - aAbsoluteTime)
|
||||||
|
const bDelta = Math.abs(new Date(filter.expires_at).getTime() - bAbsoluteTime)
|
||||||
|
return aDelta < bDelta ? -1 : 1
|
||||||
|
})[0].value
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refs.expireSelectWrapper.querySelector('select').value = expiresAtValue
|
||||||
|
for (const checkbox of [...this.refs.contextCheckboxes.querySelectorAll('input')]) {
|
||||||
|
checkbox.checked = filter.context.includes(checkbox.value)
|
||||||
|
}
|
||||||
|
this.refs.irreversibleCheckbox.checked = !!filter.irreversible
|
||||||
|
this.refs.wholeWordCheckbox.checked = !!filter.whole_word
|
||||||
|
},
|
||||||
|
syncDomToFilter () {
|
||||||
|
const existingFilter = this.get().filter
|
||||||
|
const filter = {
|
||||||
|
id: existingFilter && existingFilter.id
|
||||||
|
}
|
||||||
|
filter.phrase = this.refs.wordInput.value
|
||||||
|
const select = this.refs.expireSelectWrapper.querySelector('select')
|
||||||
|
const selectValue = parseInt(select.value, 10)
|
||||||
|
// When creating a new filter or updating a filter, `expires_in` is the number of seconds from now
|
||||||
|
// that the filter expires. When reading, it's `expires_at` which is a string ISO timestamp.
|
||||||
|
// Also, if you added a timeout for a filter, you can't change it to Never for some reason.
|
||||||
|
filter.expires_in = selectValue || null
|
||||||
|
filter.context = [...this.refs.contextCheckboxes.querySelectorAll('input')]
|
||||||
|
.filter(input => input.checked)
|
||||||
|
.map(input => input.value)
|
||||||
|
filter.irreversible = this.refs.irreversibleCheckbox.checked
|
||||||
|
filter.whole_word = this.refs.wholeWordCheckbox.checked
|
||||||
|
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
GenericConfirmationDialog,
|
||||||
|
Select
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,12 @@
|
||||||
|
import WordFilterDialog from '../components/WordFilterDialog.html'
|
||||||
|
import { showDialog } from './showDialog'
|
||||||
|
|
||||||
|
export default function showReportDialog ({ filter, instanceName }) {
|
||||||
|
const label = filter ? 'intl.editFilter' : 'intl.addFilter'
|
||||||
|
return showDialog(WordFilterDialog, {
|
||||||
|
label,
|
||||||
|
title: label,
|
||||||
|
filter,
|
||||||
|
instanceName
|
||||||
|
})
|
||||||
|
}
|
|
@ -15,17 +15,9 @@
|
||||||
{/each}
|
{/each}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<GenericInstanceSettingsStyle/>
|
||||||
.generic-instance-settings {
|
|
||||||
background: var(--form-bg);
|
|
||||||
border: 1px solid var(--main-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: block;
|
|
||||||
padding: 20px;
|
|
||||||
line-height: 2em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script>
|
<script>
|
||||||
|
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
|
||||||
import { store } from '../../../_store/store'
|
import { store } from '../../../_store/store'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -43,6 +35,9 @@
|
||||||
this.store.setInstanceSetting(instanceName, target.name, target.checked)
|
this.store.setInstanceSetting(instanceName, target.name, target.checked)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
store: () => store
|
store: () => store,
|
||||||
|
components: {
|
||||||
|
GenericInstanceSettingsStyle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
<style>
|
||||||
|
:global(.generic-instance-settings) {
|
||||||
|
background: var(--form-bg);
|
||||||
|
border: 1px solid var(--main-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 2em;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,4 +1,4 @@
|
||||||
<div class="acct-current-user">
|
<div class="generic-instance-settings acct-current-user">
|
||||||
<div class="acct-avatar">
|
<div class="acct-avatar">
|
||||||
<Avatar account={verifyCredentials} size="big" />
|
<Avatar account={verifyCredentials} size="big" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,12 +10,10 @@
|
||||||
<AccountDisplayName account={verifyCredentials} />
|
<AccountDisplayName account={verifyCredentials} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<GenericInstanceSettingsStyle />
|
||||||
<style>
|
<style>
|
||||||
.acct-current-user {
|
.acct-current-user {
|
||||||
background: var(--form-bg);
|
line-height: 1.4;
|
||||||
border: 1px solid var(--main-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 20px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
|
@ -40,12 +38,14 @@
|
||||||
import ExternalLink from '../../ExternalLink.html'
|
import ExternalLink from '../../ExternalLink.html'
|
||||||
import Avatar from '../../Avatar.html'
|
import Avatar from '../../Avatar.html'
|
||||||
import AccountDisplayName from '../../profile/AccountDisplayName.html'
|
import AccountDisplayName from '../../profile/AccountDisplayName.html'
|
||||||
|
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Avatar,
|
Avatar,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
AccountDisplayName
|
AccountDisplayName,
|
||||||
|
GenericInstanceSettingsStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<div class="push-notifications">
|
<div class="generic-instance-settings">
|
||||||
{#if pushNotificationsSupport === false}
|
{#if pushNotificationsSupport === false}
|
||||||
<p>{intl.browserDoesNotSupportPush}</p>
|
<p>{intl.browserDoesNotSupportPush}</p>
|
||||||
{:elseif $notificationPermission === "denied"}
|
{:elseif $notificationPermission === "denied"}
|
||||||
|
@ -23,15 +23,8 @@
|
||||||
{/each}
|
{/each}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<GenericInstanceSettingsStyle/>
|
||||||
<style>
|
<style>
|
||||||
.push-notifications {
|
|
||||||
background: var(--form-bg);
|
|
||||||
border: 1px solid var(--main-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: block;
|
|
||||||
padding: 20px;
|
|
||||||
line-height: 2em;
|
|
||||||
}
|
|
||||||
form[disabled="true"] {
|
form[disabled="true"] {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
@ -40,6 +33,7 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
|
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
|
||||||
import { store } from '../../../_store/store'
|
import { store } from '../../../_store/store'
|
||||||
import { importShowTextConfirmationDialog } from '../../dialog/asyncDialogs/importShowTextConfirmationDialog.js'
|
import { importShowTextConfirmationDialog } from '../../dialog/asyncDialogs/importShowTextConfirmationDialog.js'
|
||||||
import { logOutOfInstance } from '../../../_actions/instances'
|
import { logOutOfInstance } from '../../../_actions/instances'
|
||||||
|
@ -118,6 +112,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
GenericInstanceSettingsStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<form class="theme-chooser" aria-label="{intl.chooseTheme}">
|
<form class="generic-instance-settings" aria-label="{intl.chooseTheme}">
|
||||||
<div class="theme-groups">
|
<div class="theme-groups">
|
||||||
{#each themeGroups as themeGroup}
|
{#each themeGroups as themeGroup}
|
||||||
<div class="theme-group">
|
<div class="theme-group">
|
||||||
|
@ -24,15 +24,8 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<GenericInstanceSettingsStyle/>
|
||||||
<style>
|
<style>
|
||||||
.theme-chooser {
|
|
||||||
background: var(--form-bg);
|
|
||||||
border: 1px solid var(--main-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: block;
|
|
||||||
padding: 20px;
|
|
||||||
line-height: 2em;
|
|
||||||
}
|
|
||||||
.theme-groups {
|
.theme-groups {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
@ -89,6 +82,7 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
|
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
|
||||||
import { changeTheme } from '../../../_actions/instances'
|
import { changeTheme } from '../../../_actions/instances'
|
||||||
import { store } from '../../../_store/store'
|
import { store } from '../../../_store/store'
|
||||||
import { themes } from '../../../_static/themes'
|
import { themes } from '../../../_static/themes'
|
||||||
|
@ -134,6 +128,9 @@
|
||||||
const { selectedTheme, instanceName } = this.get()
|
const { selectedTheme, instanceName } = this.get()
|
||||||
changeTheme(instanceName, selectedTheme)
|
changeTheme(instanceName, selectedTheme)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
GenericInstanceSettingsStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
<div class="generic-instance-settings word-filters">
|
||||||
|
{#if filters.length}
|
||||||
|
<table class="word-filters-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{intl.wordOrPhrase}</th>
|
||||||
|
<th>{intl.contexts}</th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each formattedFilters as filter (filter.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{filter.phrase}</td>
|
||||||
|
<td>{filter.formattedContexts}</td>
|
||||||
|
<td>
|
||||||
|
<IconButton label="{intl.edit}" href="#fa-pencil" on:click="edit(filter)" clickListener={true} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<IconButton label="{intl.delete}" href="#fa-trash" on:click="del(filter)" clickListener={true} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{:else}
|
||||||
|
<p class="word-filters-p">{intl.noFilters}</p>
|
||||||
|
{/if}
|
||||||
|
<button type="button" on:click="add()">{intl.addFilter}</button>
|
||||||
|
</div>
|
||||||
|
<GenericInstanceSettingsStyle />
|
||||||
|
<style>
|
||||||
|
.word-filters-table {
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
p.word-filters-p, .word-filters-table {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
|
||||||
|
import IconButton from '../../IconButton.html'
|
||||||
|
import { store } from '../../../_store/store'
|
||||||
|
import { LOCALE } from '../../../_static/intl'
|
||||||
|
import { importShowWordFilterDialog } from '../../dialog/asyncDialogs/importShowWordFilterDialog'
|
||||||
|
import { deleteFilter } from '../../../_actions/filters'
|
||||||
|
|
||||||
|
const listFormat = new Intl.ListFormat(LOCALE, { style: 'long', type: 'conjunction' })
|
||||||
|
|
||||||
|
export default {
|
||||||
|
store: () => store,
|
||||||
|
computed: {
|
||||||
|
filters: ({ instanceName, $instanceFilters }) => $instanceFilters[instanceName] || [],
|
||||||
|
formattedFilters: ({ filters }) => filters.map(filter => ({
|
||||||
|
...filter,
|
||||||
|
formattedContexts: listFormat.format(filter.context.map(context => {
|
||||||
|
switch (context) {
|
||||||
|
case 'home':
|
||||||
|
return 'intl.filterHome'
|
||||||
|
case 'notifications':
|
||||||
|
return 'intl.filterNotifications'
|
||||||
|
case 'public':
|
||||||
|
return 'intl.filterPublic'
|
||||||
|
case 'thread':
|
||||||
|
return 'intl.filterThread'
|
||||||
|
case 'account':
|
||||||
|
return 'intl.filterAccount'
|
||||||
|
default:
|
||||||
|
return 'intl.filterUnknown'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async add () {
|
||||||
|
const { instanceName } = this.get()
|
||||||
|
const showWordFilterDialog = await importShowWordFilterDialog()
|
||||||
|
await showWordFilterDialog({ instanceName })
|
||||||
|
},
|
||||||
|
async edit (filter) {
|
||||||
|
const { instanceName } = this.get()
|
||||||
|
const showWordFilterDialog = await importShowWordFilterDialog()
|
||||||
|
await showWordFilterDialog({ instanceName, filter })
|
||||||
|
},
|
||||||
|
async del (filter) {
|
||||||
|
const { instanceName } = this.get()
|
||||||
|
await deleteFilter(instanceName, filter.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
GenericInstanceSettingsStyle,
|
||||||
|
IconButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -63,3 +63,11 @@ export async function getFollowRequestCount (instanceName) {
|
||||||
export async function setFollowRequestCount (instanceName, value) {
|
export async function setFollowRequestCount (instanceName, value) {
|
||||||
return setMetaProperty(instanceName, 'followRequestCount', value)
|
return setMetaProperty(instanceName, 'followRequestCount', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFilters (instanceName) {
|
||||||
|
return getMetaProperty(instanceName, 'filters')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFilters (instanceName, value) {
|
||||||
|
return setMetaProperty(instanceName, 'filters', value)
|
||||||
|
}
|
||||||
|
|
|
@ -4,15 +4,16 @@
|
||||||
{#if verifyCredentials}
|
{#if verifyCredentials}
|
||||||
<h2>{intl.loggedInAs}</h2>
|
<h2>{intl.loggedInAs}</h2>
|
||||||
<InstanceUserProfile {verifyCredentials} />
|
<InstanceUserProfile {verifyCredentials} />
|
||||||
|
<h2>{intl.theme}</h2>
|
||||||
|
<ThemeSettings {instanceName} />
|
||||||
<h2>{intl.homeTimelineFilters}</h2>
|
<h2>{intl.homeTimelineFilters}</h2>
|
||||||
<HomeTimelineFilterSettings {instanceName} />
|
<HomeTimelineFilterSettings {instanceName} />
|
||||||
<h2>{intl.notificationFilters}</h2>
|
<h2>{intl.notificationFilters}</h2>
|
||||||
<NotificationFilterSettings {instanceName} />
|
<NotificationFilterSettings {instanceName} />
|
||||||
|
<h2>{intl.wordFilters}</h2>
|
||||||
|
<WordFilterSettings {instanceName} />
|
||||||
<h2>{intl.pushNotifications}</h2>
|
<h2>{intl.pushNotifications}</h2>
|
||||||
<PushNotificationSettings {instanceName} />
|
<PushNotificationSettings {instanceName} />
|
||||||
<h2>{intl.theme}</h2>
|
|
||||||
<ThemeSettings {instanceName} />
|
|
||||||
|
|
||||||
<InstanceActions {instanceName} />
|
<InstanceActions {instanceName} />
|
||||||
{/if}
|
{/if}
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
|
@ -32,12 +33,17 @@
|
||||||
import PushNotificationSettings from '../../../_components/settings/instance/PushNotificationSettings.html'
|
import PushNotificationSettings from '../../../_components/settings/instance/PushNotificationSettings.html'
|
||||||
import ThemeSettings from '../../../_components/settings/instance/ThemeSettings.html'
|
import ThemeSettings from '../../../_components/settings/instance/ThemeSettings.html'
|
||||||
import InstanceActions from '../../../_components/settings/instance/InstanceActions.html'
|
import InstanceActions from '../../../_components/settings/instance/InstanceActions.html'
|
||||||
|
import WordFilterSettings from '../../../_components/settings/instance/WordFilterSettings.html'
|
||||||
import { updateVerifyCredentialsForInstance } from '../../../_actions/instances'
|
import { updateVerifyCredentialsForInstance } from '../../../_actions/instances'
|
||||||
|
import { updateFiltersForInstance } from '../../../_actions/filters'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async oncreate () {
|
async oncreate () {
|
||||||
const { instanceName } = this.get()
|
const { instanceName } = this.get()
|
||||||
await updateVerifyCredentialsForInstance(instanceName)
|
await Promise.all([
|
||||||
|
updateVerifyCredentialsForInstance(instanceName),
|
||||||
|
updateFiltersForInstance(instanceName)
|
||||||
|
])
|
||||||
},
|
},
|
||||||
store: () => store,
|
store: () => store,
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -51,7 +57,8 @@
|
||||||
ThemeSettings,
|
ThemeSettings,
|
||||||
InstanceActions,
|
InstanceActions,
|
||||||
HomeTimelineFilterSettings,
|
HomeTimelineFilterSettings,
|
||||||
NotificationFilterSettings
|
NotificationFilterSettings,
|
||||||
|
WordFilterSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -55,7 +55,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
border: 2px solid red;
|
border: 2px solid var(--warn-color);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
|
|
48
src/routes/_static/wordFilters.js
Normal file
48
src/routes/_static/wordFilters.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
export const WORD_FILTER_CONTEXT_HOME = 'home'
|
||||||
|
export const WORD_FILTER_CONTEXT_NOTIFICATIONS = 'notifications'
|
||||||
|
export const WORD_FILTER_CONTEXT_PUBLIC = 'public'
|
||||||
|
export const WORD_FILTER_CONTEXT_THREAD = 'thread'
|
||||||
|
export const WORD_FILTER_CONTEXT_ACCOUNT = 'account'
|
||||||
|
|
||||||
|
export const WORD_FILTER_CONTEXTS = [
|
||||||
|
WORD_FILTER_CONTEXT_HOME,
|
||||||
|
WORD_FILTER_CONTEXT_NOTIFICATIONS,
|
||||||
|
WORD_FILTER_CONTEXT_PUBLIC,
|
||||||
|
WORD_FILTER_CONTEXT_THREAD,
|
||||||
|
WORD_FILTER_CONTEXT_ACCOUNT
|
||||||
|
]
|
||||||
|
|
||||||
|
// Someday we can maybe replace this with Intl.DurationFormat
|
||||||
|
// https://github.com/tc39/proposal-intl-duration-format
|
||||||
|
export const WORD_FILTER_EXPIRY_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
label: 'intl.never'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 1800,
|
||||||
|
label: 'intl.thirtyMinutes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 3600,
|
||||||
|
label: 'intl.oneHour'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 21600,
|
||||||
|
label: 'intl.sixHours'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 43200,
|
||||||
|
label: 'intl.twelveHours'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 86400,
|
||||||
|
label: 'intl.oneDay'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 604800,
|
||||||
|
label: 'intl.sevenDays'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const WORD_FILTER_EXPIRY_DEFAULT = 0
|
28
src/routes/_store/computations/badgeComputations.js
Normal file
28
src/routes/_store/computations/badgeComputations.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { get } from '../../_utils/lodash-lite'
|
||||||
|
|
||||||
|
export function badgeComputations (store) {
|
||||||
|
store.compute('numberOfNotifications',
|
||||||
|
['filteredTimelineNotificationItemSummaries', 'disableNotificationBadge'],
|
||||||
|
(filteredTimelineNotificationItemSummaries, disableNotificationBadge) => (
|
||||||
|
(!disableNotificationBadge && filteredTimelineNotificationItemSummaries)
|
||||||
|
? filteredTimelineNotificationItemSummaries.length
|
||||||
|
: 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
store.compute('hasNotifications',
|
||||||
|
['numberOfNotifications', 'currentPage'],
|
||||||
|
(numberOfNotifications, currentPage) => (
|
||||||
|
currentPage !== 'notifications' && !!numberOfNotifications
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.compute('numberOfFollowRequests',
|
||||||
|
['followRequestCounts', 'currentInstance'],
|
||||||
|
(followRequestCounts, currentInstance) => get(followRequestCounts, [currentInstance], 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.compute('hasFollowRequests',
|
||||||
|
['numberOfFollowRequests'],
|
||||||
|
(numberOfFollowRequests) => !!numberOfFollowRequests
|
||||||
|
)
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ export function instanceComputations (store) {
|
||||||
computeForInstance(store, 'currentInstanceInfo', 'instanceInfos', null)
|
computeForInstance(store, 'currentInstanceInfo', 'instanceInfos', null)
|
||||||
computeForInstance(store, 'pinnedPage', 'pinnedPages', '/local')
|
computeForInstance(store, 'pinnedPage', 'pinnedPages', '/local')
|
||||||
computeForInstance(store, 'lists', 'instanceLists', [])
|
computeForInstance(store, 'lists', 'instanceLists', [])
|
||||||
|
computeForInstance(store, 'filters', 'instanceFilters', [])
|
||||||
computeForInstance(store, 'currentStatusModifications', 'statusModifications', null)
|
computeForInstance(store, 'currentStatusModifications', 'statusModifications', null)
|
||||||
computeForInstance(store, 'currentCustomEmoji', 'customEmoji', [])
|
computeForInstance(store, 'currentCustomEmoji', 'customEmoji', [])
|
||||||
computeForInstance(store, 'currentComposeData', 'composeData', {})
|
computeForInstance(store, 'currentComposeData', 'composeData', {})
|
||||||
|
|
|
@ -1,10 +1,18 @@
|
||||||
// like loggedInObservers.js, these can be lazy-loaded once the user is actually logged in
|
// like loggedInObservers.js, these can be lazy-loaded once the user is actually logged in
|
||||||
import { timelineComputations } from './timelineComputations'
|
import { timelineComputations } from './timelineComputations'
|
||||||
import { autosuggestComputations } from './autosuggestComputations'
|
import { autosuggestComputations } from './autosuggestComputations'
|
||||||
|
|
||||||
import { store } from '../store'
|
import { store } from '../store'
|
||||||
|
import { wordFilterComputations } from './wordFilterComputations'
|
||||||
|
import { badgeComputations } from './badgeComputations'
|
||||||
|
import { timelineFilterComputations } from './timelineFilterComputations'
|
||||||
|
import { mark, stop } from '../../_utils/marks'
|
||||||
|
|
||||||
export function loggedInComputations () {
|
export function loggedInComputations () {
|
||||||
|
mark('loggedInComputations')
|
||||||
|
wordFilterComputations(store)
|
||||||
timelineComputations(store)
|
timelineComputations(store)
|
||||||
|
timelineFilterComputations(store)
|
||||||
|
badgeComputations(store)
|
||||||
autosuggestComputations(store)
|
autosuggestComputations(store)
|
||||||
|
stop('loggedInComputations')
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,5 @@
|
||||||
import { get } from '../../_utils/lodash-lite'
|
import { get } from '../../_utils/lodash-lite'
|
||||||
import { getFirstIdFromItemSummaries, getLastIdFromItemSummaries } from '../../_utils/getIdFromItemSummaries'
|
import { getFirstIdFromItemSummaries, getLastIdFromItemSummaries } from '../../_utils/getIdFromItemSummaries'
|
||||||
import {
|
|
||||||
HOME_REBLOGS,
|
|
||||||
HOME_REPLIES,
|
|
||||||
NOTIFICATION_REBLOGS,
|
|
||||||
NOTIFICATION_FOLLOWS,
|
|
||||||
NOTIFICATION_FAVORITES,
|
|
||||||
NOTIFICATION_POLLS,
|
|
||||||
NOTIFICATION_MENTIONS
|
|
||||||
} from '../../_static/instanceSettings'
|
|
||||||
import { createFilterFunction } from '../../_utils/createFilterFunction'
|
|
||||||
import { mark, stop } from '../../_utils/marks'
|
import { mark, stop } from '../../_utils/marks'
|
||||||
|
|
||||||
function computeForTimeline (store, key, defaultValue) {
|
function computeForTimeline (store, key, defaultValue) {
|
||||||
|
@ -21,31 +11,6 @@ function computeForTimeline (store, key, defaultValue) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute just the boolean, e.g. 'showPolls', so that we can use that boolean as
|
|
||||||
// the input to the timelineFilterFunction computations. This should reduce the need to
|
|
||||||
// re-compute the timelineFilterFunction over and over.
|
|
||||||
function computeTimelineFilter (store, computationName, timelinesToSettingsKeys) {
|
|
||||||
store.compute(
|
|
||||||
computationName,
|
|
||||||
['currentInstance', 'instanceSettings', 'currentTimeline'],
|
|
||||||
(currentInstance, instanceSettings, currentTimeline) => {
|
|
||||||
const settingsKey = timelinesToSettingsKeys[currentTimeline]
|
|
||||||
return settingsKey ? get(instanceSettings, [currentInstance, settingsKey], true) : true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ditto for notifications, which we always have to keep track of due to the notification count.
|
|
||||||
function computeNotificationFilter (store, computationName, key) {
|
|
||||||
store.compute(
|
|
||||||
computationName,
|
|
||||||
['currentInstance', 'instanceSettings'],
|
|
||||||
(currentInstance, instanceSettings) => {
|
|
||||||
return get(instanceSettings, [currentInstance, key], true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timelineComputations (store) {
|
export function timelineComputations (store) {
|
||||||
mark('timelineComputations')
|
mark('timelineComputations')
|
||||||
computeForTimeline(store, 'timelineItemSummaries', null)
|
computeForTimeline(store, 'timelineItemSummaries', null)
|
||||||
|
@ -78,98 +43,5 @@ export function timelineComputations (store) {
|
||||||
store.compute('lastTimelineItemId', ['timelineItemSummaries'], (timelineItemSummaries) => (
|
store.compute('lastTimelineItemId', ['timelineItemSummaries'], (timelineItemSummaries) => (
|
||||||
getLastIdFromItemSummaries(timelineItemSummaries)
|
getLastIdFromItemSummaries(timelineItemSummaries)
|
||||||
))
|
))
|
||||||
|
|
||||||
computeTimelineFilter(store, 'timelineShowReblogs', { home: HOME_REBLOGS, notifications: NOTIFICATION_REBLOGS })
|
|
||||||
computeTimelineFilter(store, 'timelineShowReplies', { home: HOME_REPLIES })
|
|
||||||
computeTimelineFilter(store, 'timelineShowFollows', { notifications: NOTIFICATION_FOLLOWS })
|
|
||||||
computeTimelineFilter(store, 'timelineShowFavs', { notifications: NOTIFICATION_FAVORITES })
|
|
||||||
computeTimelineFilter(store, 'timelineShowMentions', { notifications: NOTIFICATION_MENTIONS })
|
|
||||||
computeTimelineFilter(store, 'timelineShowPolls', { notifications: NOTIFICATION_POLLS })
|
|
||||||
|
|
||||||
computeNotificationFilter(store, 'timelineNotificationShowReblogs', NOTIFICATION_REBLOGS)
|
|
||||||
computeNotificationFilter(store, 'timelineNotificationShowFollows', NOTIFICATION_FOLLOWS)
|
|
||||||
computeNotificationFilter(store, 'timelineNotificationShowFavs', NOTIFICATION_FAVORITES)
|
|
||||||
computeNotificationFilter(store, 'timelineNotificationShowMentions', NOTIFICATION_MENTIONS)
|
|
||||||
computeNotificationFilter(store, 'timelineNotificationShowPolls', NOTIFICATION_POLLS)
|
|
||||||
|
|
||||||
store.compute(
|
|
||||||
'timelineFilterFunction',
|
|
||||||
[
|
|
||||||
'timelineShowReblogs', 'timelineShowReplies', 'timelineShowFollows',
|
|
||||||
'timelineShowFavs', 'timelineShowMentions', 'timelineShowPolls'
|
|
||||||
],
|
|
||||||
(showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls) => (
|
|
||||||
createFilterFunction(showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute(
|
|
||||||
'timelineNotificationFilterFunction',
|
|
||||||
[
|
|
||||||
'timelineNotificationShowReblogs', 'timelineNotificationShowFollows',
|
|
||||||
'timelineNotificationShowFavs', 'timelineNotificationShowMentions',
|
|
||||||
'timelineNotificationShowPolls'
|
|
||||||
],
|
|
||||||
(showReblogs, showFollows, showFavs, showMentions, showPolls) => (
|
|
||||||
createFilterFunction(showReblogs, true, showFollows, showFavs, showMentions, showPolls)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute(
|
|
||||||
'filteredTimelineItemSummaries',
|
|
||||||
['timelineItemSummaries', 'timelineFilterFunction'],
|
|
||||||
(timelineItemSummaries, timelineFilterFunction) => {
|
|
||||||
return timelineItemSummaries && timelineItemSummaries.filter(timelineFilterFunction)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute(
|
|
||||||
'filteredTimelineItemSummariesToAdd',
|
|
||||||
['timelineItemSummariesToAdd', 'timelineFilterFunction'],
|
|
||||||
(timelineItemSummariesToAdd, timelineFilterFunction) => {
|
|
||||||
return timelineItemSummariesToAdd && timelineItemSummariesToAdd.filter(timelineFilterFunction)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute('timelineNotificationItemSummaries',
|
|
||||||
['timelineData_timelineItemSummariesToAdd', 'timelineFilterFunction', 'currentInstance'],
|
|
||||||
(root, timelineFilterFunction, currentInstance) => (
|
|
||||||
get(root, [currentInstance, 'notifications'])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute(
|
|
||||||
'filteredTimelineNotificationItemSummaries',
|
|
||||||
['timelineNotificationItemSummaries', 'timelineNotificationFilterFunction'],
|
|
||||||
(timelineNotificationItemSummaries, timelineNotificationFilterFunction) => (
|
|
||||||
timelineNotificationItemSummaries && timelineNotificationItemSummaries.filter(timelineNotificationFilterFunction)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute('numberOfNotifications',
|
|
||||||
['filteredTimelineNotificationItemSummaries', 'disableNotificationBadge'],
|
|
||||||
(filteredTimelineNotificationItemSummaries, disableNotificationBadge) => (
|
|
||||||
(!disableNotificationBadge && filteredTimelineNotificationItemSummaries)
|
|
||||||
? filteredTimelineNotificationItemSummaries.length
|
|
||||||
: 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute('hasNotifications',
|
|
||||||
['numberOfNotifications', 'currentPage'],
|
|
||||||
(numberOfNotifications, currentPage) => (
|
|
||||||
currentPage !== 'notifications' && !!numberOfNotifications
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute('numberOfFollowRequests',
|
|
||||||
['followRequestCounts', 'currentInstance'],
|
|
||||||
(followRequestCounts, currentInstance) => get(followRequestCounts, [currentInstance], 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
store.compute('hasFollowRequests',
|
|
||||||
['numberOfFollowRequests'],
|
|
||||||
(numberOfFollowRequests) => !!numberOfFollowRequests
|
|
||||||
)
|
|
||||||
stop('timelineComputations')
|
stop('timelineComputations')
|
||||||
}
|
}
|
||||||
|
|
139
src/routes/_store/computations/timelineFilterComputations.js
Normal file
139
src/routes/_store/computations/timelineFilterComputations.js
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import {
|
||||||
|
HOME_REBLOGS,
|
||||||
|
HOME_REPLIES,
|
||||||
|
NOTIFICATION_FAVORITES,
|
||||||
|
NOTIFICATION_FOLLOWS, NOTIFICATION_MENTIONS, NOTIFICATION_POLLS,
|
||||||
|
NOTIFICATION_REBLOGS
|
||||||
|
} from '../../_static/instanceSettings'
|
||||||
|
import {
|
||||||
|
WORD_FILTER_CONTEXT_ACCOUNT,
|
||||||
|
WORD_FILTER_CONTEXT_HOME,
|
||||||
|
WORD_FILTER_CONTEXT_NOTIFICATIONS,
|
||||||
|
WORD_FILTER_CONTEXT_PUBLIC, WORD_FILTER_CONTEXT_THREAD
|
||||||
|
} from '../../_static/wordFilters'
|
||||||
|
import { createFilterFunction } from '../../_utils/createFilterFunction'
|
||||||
|
import { get } from '../../_utils/lodash-lite'
|
||||||
|
|
||||||
|
// Compute just the boolean, e.g. 'showPolls', so that we can use that boolean as
|
||||||
|
// the input to the timelineFilterFunction computations. This should reduce the need to
|
||||||
|
// re-compute the timelineFilterFunction over and over.
|
||||||
|
function computeTimelineFilter (store, computationName, timelinesToSettingsKeys) {
|
||||||
|
store.compute(
|
||||||
|
computationName,
|
||||||
|
['currentInstance', 'instanceSettings', 'currentTimeline'],
|
||||||
|
(currentInstance, instanceSettings, currentTimeline) => {
|
||||||
|
const settingsKey = timelinesToSettingsKeys[currentTimeline]
|
||||||
|
return settingsKey ? get(instanceSettings, [currentInstance, settingsKey], true) : true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ditto for notifications, which we always have to keep track of due to the notification count.
|
||||||
|
function computeNotificationFilter (store, computationName, key) {
|
||||||
|
store.compute(
|
||||||
|
computationName,
|
||||||
|
['currentInstance', 'instanceSettings'],
|
||||||
|
(currentInstance, instanceSettings) => {
|
||||||
|
return get(instanceSettings, [currentInstance, key], true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineFilterComputations (store) {
|
||||||
|
computeTimelineFilter(store, 'timelineShowReblogs', { home: HOME_REBLOGS, notifications: NOTIFICATION_REBLOGS })
|
||||||
|
computeTimelineFilter(store, 'timelineShowReplies', { home: HOME_REPLIES })
|
||||||
|
computeTimelineFilter(store, 'timelineShowFollows', { notifications: NOTIFICATION_FOLLOWS })
|
||||||
|
computeTimelineFilter(store, 'timelineShowFavs', { notifications: NOTIFICATION_FAVORITES })
|
||||||
|
computeTimelineFilter(store, 'timelineShowMentions', { notifications: NOTIFICATION_MENTIONS })
|
||||||
|
computeTimelineFilter(store, 'timelineShowPolls', { notifications: NOTIFICATION_POLLS })
|
||||||
|
|
||||||
|
computeNotificationFilter(store, 'timelineNotificationShowReblogs', NOTIFICATION_REBLOGS)
|
||||||
|
computeNotificationFilter(store, 'timelineNotificationShowFollows', NOTIFICATION_FOLLOWS)
|
||||||
|
computeNotificationFilter(store, 'timelineNotificationShowFavs', NOTIFICATION_FAVORITES)
|
||||||
|
computeNotificationFilter(store, 'timelineNotificationShowMentions', NOTIFICATION_MENTIONS)
|
||||||
|
computeNotificationFilter(store, 'timelineNotificationShowPolls', NOTIFICATION_POLLS)
|
||||||
|
|
||||||
|
store.compute(
|
||||||
|
'timelineWordFilterContext',
|
||||||
|
['currentTimeline'],
|
||||||
|
(currentTimeline) => {
|
||||||
|
if (!currentTimeline) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentTimeline === 'home' || currentTimeline.startsWith('list/')) {
|
||||||
|
return WORD_FILTER_CONTEXT_HOME
|
||||||
|
}
|
||||||
|
if (currentTimeline === 'notifications' || currentTimeline.startsWith('notifications/')) {
|
||||||
|
return WORD_FILTER_CONTEXT_NOTIFICATIONS
|
||||||
|
}
|
||||||
|
if (currentTimeline === 'federated' || currentTimeline === 'local' || currentTimeline.startsWith('tag/')) {
|
||||||
|
return WORD_FILTER_CONTEXT_PUBLIC
|
||||||
|
}
|
||||||
|
if (currentTimeline.startsWith('account/')) {
|
||||||
|
return WORD_FILTER_CONTEXT_ACCOUNT
|
||||||
|
}
|
||||||
|
if (currentTimeline.startsWith('status/')) {
|
||||||
|
return WORD_FILTER_CONTEXT_THREAD
|
||||||
|
}
|
||||||
|
// return undefined otherwise
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// This one is based on whatever the current timeline is
|
||||||
|
store.compute(
|
||||||
|
'timelineFilterFunction',
|
||||||
|
[
|
||||||
|
'timelineShowReblogs', 'timelineShowReplies', 'timelineShowFollows',
|
||||||
|
'timelineShowFavs', 'timelineShowMentions', 'timelineShowPolls',
|
||||||
|
'timelineWordFilterContext'
|
||||||
|
],
|
||||||
|
(showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls, wordFilterContext) => (
|
||||||
|
createFilterFunction(showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls, wordFilterContext)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reason there is a completely separate flow just for notifications is that we need to
|
||||||
|
// know which notifications are filtered at all times so that the little number badge is correct.
|
||||||
|
store.compute(
|
||||||
|
'timelineNotificationFilterFunction',
|
||||||
|
[
|
||||||
|
'timelineNotificationShowReblogs', 'timelineNotificationShowFollows',
|
||||||
|
'timelineNotificationShowFavs', 'timelineNotificationShowMentions',
|
||||||
|
'timelineNotificationShowPolls'
|
||||||
|
],
|
||||||
|
(showReblogs, showFollows, showFavs, showMentions, showPolls) => (
|
||||||
|
createFilterFunction(showReblogs, true, showFollows, showFavs, showMentions, showPolls, WORD_FILTER_CONTEXT_NOTIFICATIONS)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.compute(
|
||||||
|
'filteredTimelineItemSummaries',
|
||||||
|
['timelineItemSummaries', 'timelineFilterFunction'],
|
||||||
|
(timelineItemSummaries, timelineFilterFunction) => {
|
||||||
|
return timelineItemSummaries && timelineItemSummaries.filter(timelineFilterFunction)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
store.compute(
|
||||||
|
'filteredTimelineItemSummariesToAdd',
|
||||||
|
['timelineItemSummariesToAdd', 'timelineFilterFunction'],
|
||||||
|
(timelineItemSummariesToAdd, timelineFilterFunction) => {
|
||||||
|
return timelineItemSummariesToAdd && timelineItemSummariesToAdd.filter(timelineFilterFunction)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
store.compute('timelineNotificationItemSummaries',
|
||||||
|
['timelineData_timelineItemSummariesToAdd', 'timelineFilterFunction', 'currentInstance'],
|
||||||
|
(root, timelineFilterFunction, currentInstance) => (
|
||||||
|
get(root, [currentInstance, 'notifications'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.compute(
|
||||||
|
'filteredTimelineNotificationItemSummaries',
|
||||||
|
['timelineNotificationItemSummaries', 'timelineNotificationFilterFunction'],
|
||||||
|
(timelineNotificationItemSummaries, timelineNotificationFilterFunction) => (
|
||||||
|
timelineNotificationItemSummaries && timelineNotificationItemSummaries.filter(timelineNotificationFilterFunction)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
21
src/routes/_store/computations/wordFilterComputations.js
Normal file
21
src/routes/_store/computations/wordFilterComputations.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { createRegexFromFilter } from '../../_utils/createRegexFromFilter'
|
||||||
|
|
||||||
|
export function wordFilterComputations (store) {
|
||||||
|
// unexpiredInstanceFilters is calculated based on `now` and `instanceFilters`,
|
||||||
|
// but it's computed with observers rather than compute() to avoid excessive recalcs
|
||||||
|
store.compute(
|
||||||
|
'currentFilters',
|
||||||
|
['unexpiredInstanceFilters', 'currentInstance'],
|
||||||
|
(unexpiredInstanceFilters, currentInstance) => unexpiredInstanceFilters[currentInstance] || []
|
||||||
|
)
|
||||||
|
|
||||||
|
store.compute('unexpiredInstanceFiltersWithRegexes', ['unexpiredInstanceFilters'], unexpiredInstanceFilters => {
|
||||||
|
return Object.fromEntries(Object.entries(unexpiredInstanceFilters).map(([instanceName, filters]) => {
|
||||||
|
const filtersWithRegexes = filters.map(filter => ({
|
||||||
|
...filter,
|
||||||
|
regex: createRegexFromFilter(filter)
|
||||||
|
}))
|
||||||
|
return [instanceName, filtersWithRegexes]
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
|
||||||
import { mark, stop } from '../../_utils/marks'
|
import { mark, stop } from '../../_utils/marks'
|
||||||
import { store } from '../store'
|
import { store } from '../store'
|
||||||
import { updateFollowRequestCountIfLockedAccount } from '../../_actions/followRequests'
|
import { updateFollowRequestCountIfLockedAccount } from '../../_actions/followRequests'
|
||||||
|
import { setupFiltersForInstance } from '../../_actions/filters'
|
||||||
|
|
||||||
// stream to watch for home timeline updates and notifications
|
// stream to watch for home timeline updates and notifications
|
||||||
let currentInstanceStream
|
let currentInstanceStream
|
||||||
|
@ -44,6 +45,7 @@ async function refreshInstanceData (instanceName) {
|
||||||
// these are all low-priority
|
// these are all low-priority
|
||||||
scheduleIdleTask(() => setupCustomEmojiForInstance(instanceName))
|
scheduleIdleTask(() => setupCustomEmojiForInstance(instanceName))
|
||||||
scheduleIdleTask(() => setupListsForInstance(instanceName))
|
scheduleIdleTask(() => setupListsForInstance(instanceName))
|
||||||
|
scheduleIdleTask(() => setupFiltersForInstance(instanceName))
|
||||||
scheduleIdleTask(() => updatePushSubscriptionForInstance(instanceName))
|
scheduleIdleTask(() => updatePushSubscriptionForInstance(instanceName))
|
||||||
|
|
||||||
// these are the only critical ones
|
// these are the only critical ones
|
||||||
|
|
|
@ -6,12 +6,14 @@ import { notificationPermissionObservers } from './notificationPermissionObserve
|
||||||
import { customScrollbarObservers } from './customScrollbarObservers'
|
import { customScrollbarObservers } from './customScrollbarObservers'
|
||||||
import { customEmojiObservers } from './customEmojiObservers'
|
import { customEmojiObservers } from './customEmojiObservers'
|
||||||
import { cleanup } from './cleanup'
|
import { cleanup } from './cleanup'
|
||||||
|
import { wordFilterObservers } from './wordFilterObservers'
|
||||||
|
|
||||||
// These observers can be lazy-loaded when the user is actually logged in.
|
// These observers can be lazy-loaded when the user is actually logged in.
|
||||||
// Prevents circular dependencies and reduces the size of main.js
|
// Prevents circular dependencies and reduces the size of main.js
|
||||||
export function loggedInObservers () {
|
export function loggedInObservers () {
|
||||||
instanceObservers()
|
instanceObservers()
|
||||||
timelineObservers()
|
timelineObservers()
|
||||||
|
wordFilterObservers()
|
||||||
notificationObservers()
|
notificationObservers()
|
||||||
autosuggestObservers()
|
autosuggestObservers()
|
||||||
notificationPermissionObservers()
|
notificationPermissionObservers()
|
||||||
|
|
96
src/routes/_store/observers/wordFilterObservers.js
Normal file
96
src/routes/_store/observers/wordFilterObservers.js
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import { on } from '../../_utils/eventBus'
|
||||||
|
import { updateFiltersForInstance } from '../../_actions/filters'
|
||||||
|
import { store } from '../store'
|
||||||
|
import { isEqual } from 'lodash-es'
|
||||||
|
import { computeFilterContextsForStatusOrNotification } from '../../_utils/computeFilterContextsForStatusOrNotification'
|
||||||
|
import { database } from '../../_database/database'
|
||||||
|
import { mark, stop } from '../../_utils/marks'
|
||||||
|
|
||||||
|
export function wordFilterObservers () {
|
||||||
|
if (!process.browser) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
on('wordFiltersChanged', instanceName => {
|
||||||
|
/* no await */ updateFiltersForInstance(instanceName)
|
||||||
|
})
|
||||||
|
|
||||||
|
// compute `unexpiredInstanceFilters` based on `now` and `instanceFilters`. `now` updates every 10 seconds.
|
||||||
|
function updateUnexpiredInstanceFiltersIfUnchanged (now, instanceFilters) {
|
||||||
|
const unexpiredInstanceFilters = Object.fromEntries(Object.entries(instanceFilters).map(([instanceName, filters]) => {
|
||||||
|
const unexpiredFilters = filters.filter(filter => (
|
||||||
|
!filter.expires_at || new Date(filter.expires_at).getTime() >= now
|
||||||
|
))
|
||||||
|
return [instanceName, unexpiredFilters]
|
||||||
|
}))
|
||||||
|
|
||||||
|
// don't force an update/recalc if nothing changed
|
||||||
|
if (!isEqual(store.get().unexpiredInstanceFilters, unexpiredInstanceFilters)) {
|
||||||
|
console.log('updated unexpiredInstanceFilters', unexpiredInstanceFilters)
|
||||||
|
store.set({ unexpiredInstanceFilters })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.observe('now', now => {
|
||||||
|
const { instanceFilters } = store.get()
|
||||||
|
updateUnexpiredInstanceFiltersIfUnchanged(now, instanceFilters)
|
||||||
|
})
|
||||||
|
|
||||||
|
store.observe('instanceFilters', instanceFilters => {
|
||||||
|
const { now } = store.get()
|
||||||
|
updateUnexpiredInstanceFiltersIfUnchanged(now, instanceFilters)
|
||||||
|
})
|
||||||
|
|
||||||
|
store.observe('unexpiredInstanceFiltersWithRegexes', async unexpiredInstanceFiltersWithRegexes => {
|
||||||
|
console.log('unexpiredInstanceFiltersWithRegexes changed, recomputing filterContexts')
|
||||||
|
mark('update timeline item summary filter contexts')
|
||||||
|
// Whenever the filters change, we need to re-compute the filterContexts on the TimelineSummaries.
|
||||||
|
// This is a bit of an odd design, but we do it for perf. See timelineItemToSummary.js for details.
|
||||||
|
let {
|
||||||
|
timelineData_timelineItemSummaries: timelineItemSummaries,
|
||||||
|
timelineData_timelineItemSummariesToAdd: timelineItemSummariesToAdd
|
||||||
|
} = store.get()
|
||||||
|
|
||||||
|
timelineItemSummaries = timelineItemSummaries || {}
|
||||||
|
timelineItemSummariesToAdd = timelineItemSummariesToAdd || {}
|
||||||
|
|
||||||
|
let somethingChanged = false
|
||||||
|
|
||||||
|
await Promise.all(Object.entries(unexpiredInstanceFiltersWithRegexes).map(async ([instanceName, filtersWithRegexes]) => {
|
||||||
|
const timelinesToSummaries = timelineItemSummaries[instanceName] || {}
|
||||||
|
const timelinesToSummariesToAdd = timelineItemSummariesToAdd[instanceName] || {}
|
||||||
|
const summariesToUpdate = [
|
||||||
|
...(Object.values(timelinesToSummaries).flat()),
|
||||||
|
...(Object.values(timelinesToSummariesToAdd).flat())
|
||||||
|
]
|
||||||
|
console.log(`Attempting to update filters for ${summariesToUpdate.length} item summaries`)
|
||||||
|
await Promise.all(summariesToUpdate.map(async summary => {
|
||||||
|
try {
|
||||||
|
const isNotification = summary.type
|
||||||
|
const item = await (isNotification
|
||||||
|
? database.getNotification(instanceName, summary.id)
|
||||||
|
: database.getStatus(instanceName, summary.id)
|
||||||
|
)
|
||||||
|
const newFilterContexts = computeFilterContextsForStatusOrNotification(item, filtersWithRegexes)
|
||||||
|
if (!isEqual(summary.filterContexts, newFilterContexts)) {
|
||||||
|
somethingChanged = true
|
||||||
|
summary.filterContexts = newFilterContexts
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
// not stored in the database anymore, just ignore
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// The previous was an async operation, so the timelinesItemSummaries or timelineItemSummariesToAdd
|
||||||
|
// may have changed. But we need to make sure that the filterContexts are updated in the store
|
||||||
|
// So just force an update here.
|
||||||
|
if (somethingChanged) {
|
||||||
|
console.log('Word filters changed, forcing an update')
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
const { timelineData_timelineItemSummaries, timelineData_timelineItemSummariesToAdd } = store.get()
|
||||||
|
store.set({ timelineData_timelineItemSummaries, timelineData_timelineItemSummariesToAdd })
|
||||||
|
}
|
||||||
|
stop('update timeline item summary filter contexts')
|
||||||
|
}, { init: false })
|
||||||
|
}
|
|
@ -45,9 +45,11 @@ const persistedState = {
|
||||||
|
|
||||||
const nonPersistedState = {
|
const nonPersistedState = {
|
||||||
customEmoji: {},
|
customEmoji: {},
|
||||||
|
unexpiredInstanceFilters: {},
|
||||||
followRequestCounts: {},
|
followRequestCounts: {},
|
||||||
instanceInfos: {},
|
instanceInfos: {},
|
||||||
instanceLists: {},
|
instanceLists: {},
|
||||||
|
instanceFilters: {},
|
||||||
online: !process.browser || navigator.onLine,
|
online: !process.browser || navigator.onLine,
|
||||||
pinnedStatuses: {},
|
pinnedStatuses: {},
|
||||||
polls: {},
|
polls: {},
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { createSearchIndexFromStatusOrNotification } from './createSearchIndexFromStatusOrNotification'
|
||||||
|
import { uniq } from 'lodash-es'
|
||||||
|
|
||||||
|
export function computeFilterContextsForStatusOrNotification (statusOrNotification, filtersWithRegexes) {
|
||||||
|
if (!filtersWithRegexes || !filtersWithRegexes.length) {
|
||||||
|
// avoid computing the search index, just bail out
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
// the searchIndex is really just a string of text
|
||||||
|
const searchIndex = createSearchIndexFromStatusOrNotification(statusOrNotification)
|
||||||
|
const res = filtersWithRegexes && uniq(filtersWithRegexes
|
||||||
|
.filter(({ regex }) => regex.test(searchIndex))
|
||||||
|
.map(_ => _.context)
|
||||||
|
.flat())
|
||||||
|
|
||||||
|
// return undefined instead of a new array to reduce memory usage of TimelineSummary
|
||||||
|
return (res && res.length) ? res : undefined
|
||||||
|
}
|
|
@ -1,14 +1,13 @@
|
||||||
// create a function for filtering timeline item summaries
|
// create a function for filtering timeline item summaries
|
||||||
|
|
||||||
function noFilter () {
|
export const createFilterFunction = (
|
||||||
return true
|
showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls, wordFilterContext
|
||||||
}
|
) => {
|
||||||
|
|
||||||
export function createFilterFunction (showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls) {
|
|
||||||
if (showReblogs && showReplies && showFollows && showFavs && showMentions && showPolls) {
|
|
||||||
return noFilter // fast path for the default setting
|
|
||||||
}
|
|
||||||
return item => {
|
return item => {
|
||||||
|
if (item.filterContexts && item.filterContexts.includes(wordFilterContext)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'poll':
|
case 'poll':
|
||||||
return showPolls
|
return showPolls
|
||||||
|
|
20
src/routes/_utils/createRegexFromFilter.js
Normal file
20
src/routes/_utils/createRegexFromFilter.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// copy-pasta'd from mastodon
|
||||||
|
// https://github.com/tootsuite/mastodon/blob/2ff01f7/app/javascript/mastodon/selectors/index.js#L40-L63
|
||||||
|
const escapeRegExp = string =>
|
||||||
|
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
||||||
|
|
||||||
|
export function createRegexFromFilter (filter) {
|
||||||
|
let expr = escapeRegExp(filter.phrase)
|
||||||
|
|
||||||
|
if (filter.whole_word) {
|
||||||
|
if (/^[\w]/.test(expr)) {
|
||||||
|
expr = `\\b${expr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[\w]$/.test(expr)) {
|
||||||
|
expr = `${expr}\\b`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RegExp(expr, 'i')
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
let domParser
|
||||||
|
|
||||||
|
// copy-pasta'd from
|
||||||
|
// https://github.com/tootsuite/mastodon/blob/2ff01f7/app/javascript/mastodon/actions/importer/normalizer.js#L58-L75
|
||||||
|
export const createSearchIndexFromStatusOrNotification = statusOrNotification => {
|
||||||
|
const status = statusOrNotification.status || statusOrNotification // status on a notification
|
||||||
|
const originalStatus = status.reblog || status
|
||||||
|
domParser = domParser || new DOMParser()
|
||||||
|
const spoilerText = originalStatus.spoiler_text || ''
|
||||||
|
const searchContent = ([spoilerText, originalStatus.content]
|
||||||
|
.concat(
|
||||||
|
(originalStatus.poll && originalStatus.poll.options)
|
||||||
|
? originalStatus.poll.options.map(option => option.title)
|
||||||
|
: []
|
||||||
|
))
|
||||||
|
.join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n')
|
||||||
|
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent
|
||||||
|
}
|
|
@ -7,11 +7,17 @@ if (process.browser) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function on (eventName, component, method) {
|
export function on (eventName, component, method) {
|
||||||
|
if (typeof method === 'undefined') {
|
||||||
|
method = component
|
||||||
|
component = undefined
|
||||||
|
}
|
||||||
const callback = method.bind(component)
|
const callback = method.bind(component)
|
||||||
eventBus.on(eventName, callback)
|
eventBus.on(eventName, callback)
|
||||||
component.on('destroy', () => {
|
if (component) {
|
||||||
eventBus.removeListener(eventName, callback)
|
component.on('destroy', () => {
|
||||||
})
|
eventBus.removeListener(eventName, callback)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emit = eventBus.emit.bind(eventBus)
|
export const emit = eventBus.emit.bind(eventBus)
|
||||||
|
|
|
@ -10,3 +10,7 @@ export const importFocusVisible = () => import(
|
||||||
export const importRelativeTimeFormat = () => import(
|
export const importRelativeTimeFormat = () => import(
|
||||||
/* webpackChunkName: '$polyfill$-relative-time-format' */ './relativeTimeFormatPolyfill'
|
/* webpackChunkName: '$polyfill$-relative-time-format' */ './relativeTimeFormatPolyfill'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const importListFormat = () => import(
|
||||||
|
/* webpackChunkName: '$polyfill$-list-format' */ './listFormatPolyfill'
|
||||||
|
)
|
7
src/routes/_utils/polyfills/listFormatPolyfill.js
Normal file
7
src/routes/_utils/polyfills/listFormatPolyfill.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// Thank you Safari
|
||||||
|
// https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat#browser_compatibility
|
||||||
|
// Also note I'm not going to do anything fancy here for loading the polyfill locale data.
|
||||||
|
// Safari can just get English every time.
|
||||||
|
|
||||||
|
import '@formatjs/intl-listformat/polyfill'
|
||||||
|
import '@formatjs/intl-listformat/locale-data/en'
|
|
@ -1,5 +1,5 @@
|
||||||
import { importFocusVisible } from './asyncPolyfills'
|
import { importFocusVisible } from './asyncPolyfills'
|
||||||
import { supportsFocusVisible } from './supportsFocusVisible'
|
import { supportsFocusVisible } from '../supportsFocusVisible'
|
||||||
|
|
||||||
export function loadNonCriticalPolyfills () {
|
export function loadNonCriticalPolyfills () {
|
||||||
return Promise.all([
|
return Promise.all([
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
importRequestIdleCallback,
|
importRequestIdleCallback,
|
||||||
importRelativeTimeFormat
|
importRelativeTimeFormat,
|
||||||
|
importListFormat
|
||||||
} from './asyncPolyfills'
|
} from './asyncPolyfills'
|
||||||
|
|
||||||
export function loadPolyfills () {
|
export function loadPolyfills () {
|
||||||
|
@ -10,6 +11,7 @@ export function loadPolyfills () {
|
||||||
typeof Intl.RelativeTimeFormat !== 'function' ||
|
typeof Intl.RelativeTimeFormat !== 'function' ||
|
||||||
typeof Intl.Locale !== 'function' ||
|
typeof Intl.Locale !== 'function' ||
|
||||||
typeof Intl.PluralRules !== 'function'
|
typeof Intl.PluralRules !== 'function'
|
||||||
) && importRelativeTimeFormat()
|
) && importRelativeTimeFormat(),
|
||||||
|
typeof Intl.ListFormat !== 'function' && importListFormat()
|
||||||
])
|
])
|
||||||
}
|
}
|
|
@ -1,13 +1,24 @@
|
||||||
|
import { computeFilterContextsForStatusOrNotification } from './computeFilterContextsForStatusOrNotification'
|
||||||
|
import { store } from '../_store/store'
|
||||||
|
|
||||||
class TimelineSummary {
|
class TimelineSummary {
|
||||||
constructor (item) {
|
constructor (item, instanceName) {
|
||||||
this.id = item.id
|
this.id = item.id
|
||||||
this.accountId = item.account.id
|
this.accountId = item.account.id
|
||||||
this.replyId = (item.in_reply_to_id) || undefined
|
this.replyId = (item.in_reply_to_id) || undefined
|
||||||
this.reblogId = (item.reblog && item.reblog.id) || undefined
|
this.reblogId = (item.reblog && item.reblog.id) || undefined
|
||||||
this.type = item.type || undefined
|
this.type = item.type || undefined
|
||||||
|
|
||||||
|
// This is admittedly a weird place to do the filtering logic. But there are a few reasons to do it here:
|
||||||
|
// 1. Avoid computing html-to-text (expensive) for users who don't have any filters (probably most users)
|
||||||
|
// 2. Avoiding keeping the entire html-to-text in memory at all times for all summaries
|
||||||
|
// 3. Filters probably change infrequently. When they do, we can just update the summaries
|
||||||
|
const { unexpiredInstanceFiltersWithRegexes } = store.get()
|
||||||
|
const filtersWithRegexes = unexpiredInstanceFiltersWithRegexes[instanceName]
|
||||||
|
this.filterContexts = computeFilterContextsForStatusOrNotification(item, filtersWithRegexes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function timelineItemToSummary (item) {
|
export function timelineItemToSummary (item, instanceName) {
|
||||||
return new TimelineSummary(item)
|
return new TimelineSummary(item, instanceName)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
:root {
|
:root {
|
||||||
|
|
||||||
|
--warn-color: #e14154; // reddish for warnings/errors
|
||||||
|
|
||||||
//
|
//
|
||||||
// Vertical and horizontal padding for the status-article element (Status.html, Notification.html)
|
// Vertical and horizontal padding for the status-article element (Status.html, Notification.html)
|
||||||
//
|
//
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { submitMedia } from './submitMedia'
|
||||||
import { voteOnPoll } from '../src/routes/_api/polls'
|
import { voteOnPoll } from '../src/routes/_api/polls'
|
||||||
import { POLL_EXPIRY_DEFAULT } from '../src/routes/_static/polls'
|
import { POLL_EXPIRY_DEFAULT } from '../src/routes/_static/polls'
|
||||||
import { createList, getLists } from '../src/routes/_api/lists'
|
import { createList, getLists } from '../src/routes/_api/lists'
|
||||||
|
import { createFilter, deleteFilter, getFilters } from '../src/routes/_api/filters'
|
||||||
|
|
||||||
global.fetch = fetch
|
global.fetch = fetch
|
||||||
global.File = FileApi.File
|
global.File = FileApi.File
|
||||||
|
@ -102,3 +103,14 @@ export async function createListAs (username, title) {
|
||||||
export async function getListsAs (username) {
|
export async function getListsAs (username) {
|
||||||
return getLists(instanceName, users[username].accessToken)
|
return getLists(instanceName, users[username].accessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAllWordFiltersAs (username) {
|
||||||
|
const accessToken = users[username].accessToken
|
||||||
|
const filters = await getFilters(instanceName, accessToken)
|
||||||
|
await Promise.all(filters.map(({ id }) => deleteFilter(instanceName, accessToken, id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWordFilterAs (username, filter) {
|
||||||
|
const accessToken = users[username].accessToken
|
||||||
|
await createFilter(instanceName, accessToken, filter)
|
||||||
|
}
|
||||||
|
|
176
tests/spec/138-word-filters.js
Normal file
176
tests/spec/138-word-filters.js
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
import { Selector as $ } from 'testcafe'
|
||||||
|
import { loginAsFoobar } from '../roles'
|
||||||
|
import { createWordFilterAs, deleteAllWordFiltersAs, postAs, reblogStatusAs } from '../serverActions'
|
||||||
|
import { WORD_FILTER_CONTEXT_NOTIFICATIONS, WORD_FILTER_CONTEXTS } from '../../src/routes/_static/wordFilters'
|
||||||
|
import {
|
||||||
|
getNthStatusContent, getUrl,
|
||||||
|
homeNavButton,
|
||||||
|
modalDialog,
|
||||||
|
notificationsNavButton,
|
||||||
|
settingsNavButton,
|
||||||
|
sleep
|
||||||
|
} from '../utils'
|
||||||
|
|
||||||
|
fixture`138-word-filters.js`
|
||||||
|
.page`http://localhost:4002`
|
||||||
|
.afterEach(async () => {
|
||||||
|
await deleteAllWordFiltersAs('foobar')
|
||||||
|
})
|
||||||
|
|
||||||
|
const goToWordFilterSettings = async t => {
|
||||||
|
await t
|
||||||
|
.click(settingsNavButton)
|
||||||
|
.click($('a').withText('Instances'))
|
||||||
|
.click($('a').withText('localhost:3000'))
|
||||||
|
.hover($('h2').withText('Word filters'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFilter = async (t, phrase, tweak) => {
|
||||||
|
await t
|
||||||
|
.click($('button').withText('Add filter'))
|
||||||
|
.expect(modalDialog.hasAttribute('aria-hidden')).notOk()
|
||||||
|
.typeText($('input[type=text]#word-filter-word-or-phrase'), phrase)
|
||||||
|
if (tweak) {
|
||||||
|
await tweak(t)
|
||||||
|
}
|
||||||
|
await t
|
||||||
|
.click($('button').withText('Save'))
|
||||||
|
.expect(modalDialog.exists).notOk()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Can filter basic words', async t => {
|
||||||
|
await postAs('admin', 'do not filter me!')
|
||||||
|
await postAs('admin', 'filterMeOut okay!')
|
||||||
|
await postAs('admin', 'filterMeOutTooEvenThoughItIsOneBigWord!')
|
||||||
|
await sleep(500)
|
||||||
|
await loginAsFoobar(t)
|
||||||
|
await goToWordFilterSettings(t)
|
||||||
|
await addFilter(t, 'filterMeOut', async t => {
|
||||||
|
// uncheck "whole word"
|
||||||
|
await t
|
||||||
|
.click($('input#word-filter-whole'))
|
||||||
|
await sleep(500)
|
||||||
|
})
|
||||||
|
await t
|
||||||
|
.click(homeNavButton)
|
||||||
|
.expect(getUrl()).eql('http://localhost:4002/')
|
||||||
|
await t
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('do not filter me!')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can filter whole words', async t => {
|
||||||
|
await postAs('admin', 'do not filter me!')
|
||||||
|
await postAs('admin', 'anotherFilter okay!')
|
||||||
|
await postAs('admin', 'anotherFilterEvenThoughItIsOneBigWord!')
|
||||||
|
await sleep(500)
|
||||||
|
await loginAsFoobar(t)
|
||||||
|
await goToWordFilterSettings(t)
|
||||||
|
await addFilter(t, 'filterMeOut')
|
||||||
|
await t
|
||||||
|
.click(homeNavButton)
|
||||||
|
.expect(getUrl()).eql('http://localhost:4002/')
|
||||||
|
await t
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('anotherFilterEvenThoughItIsOneBigWord!')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can add filters on the fly', async t => {
|
||||||
|
await postAs('admin', 'hehehehehehe')
|
||||||
|
await postAs('admin', 'hohohohohoho')
|
||||||
|
await postAs('admin', 'hahahahahaha')
|
||||||
|
await sleep(500)
|
||||||
|
await loginAsFoobar(t)
|
||||||
|
await t
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('hahahahahaha')
|
||||||
|
await goToWordFilterSettings(t)
|
||||||
|
await t
|
||||||
|
.expect($('body').innerText).contains('You don\'t have any word filters.')
|
||||||
|
await addFilter(t, 'hahahahahaha')
|
||||||
|
await t
|
||||||
|
.click(homeNavButton)
|
||||||
|
.expect(getUrl()).eql('http://localhost:4002/')
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('hohohohohoho')
|
||||||
|
await goToWordFilterSettings(t)
|
||||||
|
await addFilter(t, 'hohohohohoho')
|
||||||
|
await t
|
||||||
|
.click(homeNavButton)
|
||||||
|
.expect(getUrl()).eql('http://localhost:4002/')
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('hehehehehehe')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can delete filters on the fly', async t => {
|
||||||
|
await postAs('admin', 'yalayala')
|
||||||
|
await postAs('admin', 'yoloyolo')
|
||||||
|
await sleep(500)
|
||||||
|
await loginAsFoobar(t)
|
||||||
|
await t
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('yoloyolo')
|
||||||
|
await goToWordFilterSettings(t)
|
||||||
|
await addFilter(t, 'yoloyolo')
|
||||||
|
await t
|
||||||
|
.click(homeNavButton)
|
||||||
|
.expect(getUrl()).eql('http://localhost:4002/')
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('yalayala')
|
||||||
|
await goToWordFilterSettings(t)
|
||||||
|
await t
|
||||||
|
.click($('button[aria-label="Delete"]'))
|
||||||
|
.expect($('body').innerText).contains('You don\'t have any word filters.')
|
||||||
|
.click(homeNavButton)
|
||||||
|
.expect(getUrl()).eql('http://localhost:4002/')
|
||||||
|
await t
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('yoloyolo')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can update filters when change comes from the server', async t => {
|
||||||
|
await postAs('admin', 'ohwowohwow')
|
||||||
|
await postAs('admin', 'ohboyohboy')
|
||||||
|
await sleep(500)
|
||||||
|
await loginAsFoobar(t)
|
||||||
|
await t
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('ohboyohboy')
|
||||||
|
await sleep(500)
|
||||||
|
await createWordFilterAs('foobar', {
|
||||||
|
phrase: 'ohboyohboy',
|
||||||
|
context: [...WORD_FILTER_CONTEXTS],
|
||||||
|
whole_word: false
|
||||||
|
})
|
||||||
|
await t
|
||||||
|
.expect(getNthStatusContent(1).innerText).eql('ohwowohwow')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can filter notifications', async t => {
|
||||||
|
await postAs('admin', 'hey @foobar do not filter this pretty please')
|
||||||
|
await postAs('admin', 'hey @foobar filterthisplease')
|
||||||
|
await sleep(500)
|
||||||
|
await loginAsFoobar(t)
|
||||||
|
|
||||||
|
await goToWordFilterSettings(t)
|
||||||
|
await addFilter(t, 'filterthisplease', async t => {
|
||||||
|
// uncheck all contexts except notifications
|
||||||
|
const contexts = WORD_FILTER_CONTEXTS.filter(_ => _ !== WORD_FILTER_CONTEXT_NOTIFICATIONS)
|
||||||
|
for (const context of contexts) {
|
||||||
|
await t.click(`input[id="where-to-filter-${context}"]`)
|
||||||
|
}
|
||||||
|
await sleep(200)
|
||||||
|
})
|
||||||
|
await t
|
||||||
|
.click(homeNavButton)
|
||||||
|
.expect(getUrl()).eql('http://localhost:4002/')
|
||||||
|
.expect(getNthStatusContent(1).innerText).contains('filterthisplease')
|
||||||
|
.click(notificationsNavButton)
|
||||||
|
.expect(getUrl()).contains('notifications')
|
||||||
|
.expect(getNthStatusContent(1).innerText).contains('do not filter this pretty please')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can filter reblogs', async t => {
|
||||||
|
await postAs('admin', 'you definitely want to see this')
|
||||||
|
const { id } = await postAs('baz', 'dontwanttoseethis')
|
||||||
|
await reblogStatusAs('admin', id)
|
||||||
|
await sleep(500)
|
||||||
|
await loginAsFoobar(t)
|
||||||
|
await goToWordFilterSettings(t)
|
||||||
|
await addFilter(t, 'dontwanttoseethis')
|
||||||
|
await t
|
||||||
|
.click(homeNavButton)
|
||||||
|
.expect(getUrl()).eql('http://localhost:4002/')
|
||||||
|
.expect(getNthStatusContent(1).innerText).contains('you definitely want to see this')
|
||||||
|
})
|
17
yarn.lock
17
yarn.lock
|
@ -978,6 +978,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.1"
|
tslib "^2.0.1"
|
||||||
|
|
||||||
|
"@formatjs/ecma402-abstract@1.6.2":
|
||||||
|
version "1.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.6.2.tgz#9d064a2cf790769aa6721e074fb5d5c357084bb9"
|
||||||
|
integrity sha512-aLBODrSRhHaL/0WdQ0T2UsGqRbdtRRHqqrs4zwNQoRsGBEtEAvlj/rgr6Uea4PSymVJrbZBoAyECM2Z3Pq4i0g==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
"@formatjs/intl-getcanonicallocales@1.5.3":
|
"@formatjs/intl-getcanonicallocales@1.5.3":
|
||||||
version "1.5.3"
|
version "1.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.5.3.tgz#b5978462340da1502502c3fde1c4abccff8f3b8e"
|
resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.5.3.tgz#b5978462340da1502502c3fde1c4abccff8f3b8e"
|
||||||
|
@ -986,6 +993,14 @@
|
||||||
cldr-core "38"
|
cldr-core "38"
|
||||||
tslib "^2.0.1"
|
tslib "^2.0.1"
|
||||||
|
|
||||||
|
"@formatjs/intl-listformat@^5.0.10":
|
||||||
|
version "5.0.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-5.0.10.tgz#9f8c4ad5e8a925240e151ba794c41fba01f742cc"
|
||||||
|
integrity sha512-FLtrtBPfBoeteRlYcHvThYbSW2YdJTllR0xEnk6cr/6FRArbfPRYMzDpFYlESzb5g8bpQMKZy+kFQ6V2Z+5KaA==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/ecma402-abstract" "1.6.2"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
"@formatjs/intl-locale@^2.4.14":
|
"@formatjs/intl-locale@^2.4.14":
|
||||||
version "2.4.14"
|
version "2.4.14"
|
||||||
resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-2.4.14.tgz#9852678ee1ba3214e75f2e21fd0010d06e998d93"
|
resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-2.4.14.tgz#9852678ee1ba3214e75f2e21fd0010d06e998d93"
|
||||||
|
@ -7546,7 +7561,7 @@ tslib@^1.9.0, tslib@^1.9.3:
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
|
||||||
integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
|
integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
|
||||||
|
|
||||||
tslib@^2.0.1:
|
tslib@^2.0.1, tslib@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
|
||||||
integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
|
integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
|
||||||
|
|
Loading…
Reference in a new issue