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:
Nolan Lawson 2021-03-13 17:31:17 -08:00 committed by GitHub
parent 3271344c76
commit 4adc8ff748
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1202 additions and 199 deletions

View file

@ -39,6 +39,7 @@
"build-vercel-json": "node -r esm bin/build-vercel-json.js"
},
"dependencies": {
"@formatjs/intl-listformat": "^5.0.10",
"@formatjs/intl-locale": "^2.4.14",
"@formatjs/intl-pluralrules": "^4.0.6",
"@formatjs/intl-relativetimeformat": "^8.0.4",

View file

@ -4,8 +4,8 @@ import './routes/_utils/historyEvents'
import './routes/_utils/loadingMask'
import './routes/_utils/forceOnline'
import { mark, stop } from './routes/_utils/marks'
import { loadPolyfills } from './routes/_utils/loadPolyfills'
import { loadNonCriticalPolyfills } from './routes/_utils/loadNonCriticalPolyfills'
import { loadPolyfills } from './routes/_utils/polyfills/loadPolyfills'
import { loadNonCriticalPolyfills } from './routes/_utils/polyfills/loadNonCriticalPolyfills'
mark('loadPolyfills')
loadPolyfills().then(() => {

View file

@ -214,9 +214,11 @@ export default {
thirtyMinutes: '30 minutes',
oneHour: '1 hour',
sixHours: '6 hours',
twelveHours: '12 hours',
oneDay: '1 day',
threeDays: '3 days',
sevenDays: '7 days',
never: 'Never',
addEmoji: 'Insert emoji',
addMedia: 'Add media (images, video, audio)',
addPoll: 'Add poll',
@ -625,5 +627,28 @@ export default {
showingOfflineContent: 'Internet request failed. Showing offline content.',
youAreOffline: 'You seem to be offline. You can still read toots while offline.',
// 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'
}

View file

@ -31,9 +31,9 @@ async function insertUpdatesIntoTimeline (instanceName, timelineName, updates) {
console.log('itemSummariesToAdd', JSON.parse(JSON.stringify(itemSummariesToAdd)))
console.log('updates.map(timelineItemToSummary)', JSON.parse(JSON.stringify(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(
concat(itemSummariesToAdd, updates.map(timelineItemToSummary)),
concat(itemSummariesToAdd, updates.map(item => timelineItemToSummary(item, instanceName))),
_ => _.id
)
if (!isEqual(itemSummariesToAdd, newItemSummariesToAdd)) {
@ -78,7 +78,7 @@ async function insertUpdatesIntoThreads (instanceName, updates) {
continue
}
const newItemSummariesToAdd = uniqBy(
concat(itemSummariesToAdd, validUpdates.map(timelineItemToSummary)),
concat(itemSummariesToAdd, validUpdates.map(item => timelineItemToSummary(item, instanceName))),
_ => _.id
)
if (!isEqual(itemSummariesToAdd, newItemSummariesToAdd)) {

View 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 || ''))
}
}

View file

@ -1,8 +1,9 @@
import { mark, stop } from '../../_utils/marks'
import { deleteStatus } from '../deleteStatuses'
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) {
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
addStatusOrNotification(instanceName, timelineName, payload.last_status)
break
case 'filters_changed':
emit('wordFiltersChanged', instanceName)
break
}
stop('processMessage')
}

View file

@ -98,7 +98,7 @@ async function fetchTimelineItemsFromNetwork (instanceName, accessToken, timelin
async function addPagedTimelineItems (instanceName, timelineName, items) {
console.log('addPagedTimelineItems, length:', items.length)
mark('addPagedTimelineItemSummaries')
const newSummaries = items.map(timelineItemToSummary)
const newSummaries = items.map(item => timelineItemToSummary(item, instanceName))
await addPagedTimelineItemSummaries(instanceName, timelineName, newSummaries)
stop('addPagedTimelineItemSummaries')
}
@ -154,7 +154,7 @@ async function fetchTimelineItems (instanceName, accessToken, timelineName, onli
async function addTimelineItems (instanceName, timelineName, items, stale) {
console.log('addTimelineItems, length:', items.length)
mark('addTimelineItemSummaries')
const newSummaries = items.map(timelineItemToSummary)
const newSummaries = items.map(item => timelineItemToSummary(item, instanceName))
addTimelineItemSummaries(instanceName, timelineName, newSummaries, stale)
stop('addTimelineItemSummaries')
}

View 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 })
}

View file

@ -0,0 +1,3 @@
export const importShowWordFilterDialog = () => import(
/* webpackChunkName: 'showWordFilterDialog' */ '../creators/showWordFilterDialog'
).then(mod => mod.default)

View file

@ -66,7 +66,7 @@
import { doubleRAF } from '../../../_utils/doubleRAF'
import { convertCustomEmojiToEmojiPickerFormat } from '../../../_utils/convertCustomEmojiToEmojiPickerFormat'
import { supportsFocusVisible } from '../../../_utils/supportsFocusVisible'
import { importFocusVisible } from '../../../_utils/asyncPolyfills'
import { importFocusVisible } from '../../../_utils/polyfills/asyncPolyfills'
import { emojiPickerI18n, emojiPickerDataSource, emojiPickerLocale } from '../../../_static/emojiPickerIntl'
export default {

View file

@ -9,7 +9,7 @@
<form class="confirmation-dialog-form">
<slot></slot>
<div class="confirmation-dialog-form-flex">
<button type="button" on:click="onPositive()">
<button type="button" disabled={confirmationButtonDisabled} on:click="onPositive()">
{positiveText || 'OK'}
</button>
<button type="button" on:click="onNegative()">

View 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>

View file

@ -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
})
}

View file

@ -15,17 +15,9 @@
{/each}
</form>
</div>
<style>
.generic-instance-settings {
background: var(--form-bg);
border: 1px solid var(--main-border);
border-radius: 4px;
display: block;
padding: 20px;
line-height: 2em;
}
</style>
<GenericInstanceSettingsStyle/>
<script>
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
import { store } from '../../../_store/store'
export default {
@ -43,6 +35,9 @@
this.store.setInstanceSetting(instanceName, target.name, target.checked)
}
},
store: () => store
store: () => store,
components: {
GenericInstanceSettingsStyle
}
}
</script>

View file

@ -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>

View file

@ -1,4 +1,4 @@
<div class="acct-current-user">
<div class="generic-instance-settings acct-current-user">
<div class="acct-avatar">
<Avatar account={verifyCredentials} size="big" />
</div>
@ -10,12 +10,10 @@
<AccountDisplayName account={verifyCredentials} />
</span>
</div>
<GenericInstanceSettingsStyle />
<style>
.acct-current-user {
background: var(--form-bg);
border: 1px solid var(--main-border);
border-radius: 4px;
padding: 20px;
line-height: 1.4;
display: grid;
align-items: center;
font-size: 1.3em;
@ -40,12 +38,14 @@
import ExternalLink from '../../ExternalLink.html'
import Avatar from '../../Avatar.html'
import AccountDisplayName from '../../profile/AccountDisplayName.html'
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
export default {
components: {
Avatar,
ExternalLink,
AccountDisplayName
AccountDisplayName,
GenericInstanceSettingsStyle
}
}
</script>

View file

@ -1,4 +1,4 @@
<div class="push-notifications">
<div class="generic-instance-settings">
{#if pushNotificationsSupport === false}
<p>{intl.browserDoesNotSupportPush}</p>
{:elseif $notificationPermission === "denied"}
@ -23,15 +23,8 @@
{/each}
</form>
</div>
<GenericInstanceSettingsStyle/>
<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"] {
opacity: 0.5;
}
@ -40,6 +33,7 @@
}
</style>
<script>
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
import { store } from '../../../_store/store'
import { importShowTextConfirmationDialog } from '../../dialog/asyncDialogs/importShowTextConfirmationDialog.js'
import { logOutOfInstance } from '../../../_actions/instances'
@ -118,6 +112,9 @@
}
}
}
},
components: {
GenericInstanceSettingsStyle
}
}
</script>

View file

@ -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">
{#each themeGroups as themeGroup}
<div class="theme-group">
@ -24,15 +24,8 @@
{/each}
</div>
</form>
<GenericInstanceSettingsStyle/>
<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 {
display: grid;
grid-template-columns: 1fr 1fr;
@ -89,6 +82,7 @@
}
</style>
<script>
import GenericInstanceSettingsStyle from './GenericInstanceSettingsStyle.html'
import { changeTheme } from '../../../_actions/instances'
import { store } from '../../../_store/store'
import { themes } from '../../../_static/themes'
@ -134,6 +128,9 @@
const { selectedTheme, instanceName } = this.get()
changeTheme(instanceName, selectedTheme)
}
},
components: {
GenericInstanceSettingsStyle
}
}
</script>

View file

@ -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>

View file

@ -63,3 +63,11 @@ export async function getFollowRequestCount (instanceName) {
export async function setFollowRequestCount (instanceName, 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)
}

View file

@ -4,15 +4,16 @@
{#if verifyCredentials}
<h2>{intl.loggedInAs}</h2>
<InstanceUserProfile {verifyCredentials} />
<h2>{intl.theme}</h2>
<ThemeSettings {instanceName} />
<h2>{intl.homeTimelineFilters}</h2>
<HomeTimelineFilterSettings {instanceName} />
<h2>{intl.notificationFilters}</h2>
<NotificationFilterSettings {instanceName} />
<h2>{intl.wordFilters}</h2>
<WordFilterSettings {instanceName} />
<h2>{intl.pushNotifications}</h2>
<PushNotificationSettings {instanceName} />
<h2>{intl.theme}</h2>
<ThemeSettings {instanceName} />
<InstanceActions {instanceName} />
{/if}
</SettingsLayout>
@ -32,12 +33,17 @@
import PushNotificationSettings from '../../../_components/settings/instance/PushNotificationSettings.html'
import ThemeSettings from '../../../_components/settings/instance/ThemeSettings.html'
import InstanceActions from '../../../_components/settings/instance/InstanceActions.html'
import WordFilterSettings from '../../../_components/settings/instance/WordFilterSettings.html'
import { updateVerifyCredentialsForInstance } from '../../../_actions/instances'
import { updateFiltersForInstance } from '../../../_actions/filters'
export default {
async oncreate () {
const { instanceName } = this.get()
await updateVerifyCredentialsForInstance(instanceName)
await Promise.all([
updateVerifyCredentialsForInstance(instanceName),
updateFiltersForInstance(instanceName)
])
},
store: () => store,
computed: {
@ -51,7 +57,8 @@
ThemeSettings,
InstanceActions,
HomeTimelineFilterSettings,
NotificationFilterSettings
NotificationFilterSettings,
WordFilterSettings
}
}
</script>

View file

@ -55,7 +55,7 @@
}
.form-error {
border: 2px solid red;
border: 2px solid var(--warn-color);
border-radius: 2px;
padding: 10px;
font-size: 1.3em;

View 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

View 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
)
}

View file

@ -14,6 +14,7 @@ export function instanceComputations (store) {
computeForInstance(store, 'currentInstanceInfo', 'instanceInfos', null)
computeForInstance(store, 'pinnedPage', 'pinnedPages', '/local')
computeForInstance(store, 'lists', 'instanceLists', [])
computeForInstance(store, 'filters', 'instanceFilters', [])
computeForInstance(store, 'currentStatusModifications', 'statusModifications', null)
computeForInstance(store, 'currentCustomEmoji', 'customEmoji', [])
computeForInstance(store, 'currentComposeData', 'composeData', {})

View file

@ -1,10 +1,18 @@
// like loggedInObservers.js, these can be lazy-loaded once the user is actually logged in
import { timelineComputations } from './timelineComputations'
import { autosuggestComputations } from './autosuggestComputations'
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 () {
mark('loggedInComputations')
wordFilterComputations(store)
timelineComputations(store)
timelineFilterComputations(store)
badgeComputations(store)
autosuggestComputations(store)
stop('loggedInComputations')
}

View file

@ -1,15 +1,5 @@
import { get } from '../../_utils/lodash-lite'
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'
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) {
mark('timelineComputations')
computeForTimeline(store, 'timelineItemSummaries', null)
@ -78,98 +43,5 @@ export function timelineComputations (store) {
store.compute('lastTimelineItemId', ['timelineItemSummaries'], (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')
}

View 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)
)
)
}

View 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]
}))
})
}

View file

@ -7,6 +7,7 @@ import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { mark, stop } from '../../_utils/marks'
import { store } from '../store'
import { updateFollowRequestCountIfLockedAccount } from '../../_actions/followRequests'
import { setupFiltersForInstance } from '../../_actions/filters'
// stream to watch for home timeline updates and notifications
let currentInstanceStream
@ -44,6 +45,7 @@ async function refreshInstanceData (instanceName) {
// these are all low-priority
scheduleIdleTask(() => setupCustomEmojiForInstance(instanceName))
scheduleIdleTask(() => setupListsForInstance(instanceName))
scheduleIdleTask(() => setupFiltersForInstance(instanceName))
scheduleIdleTask(() => updatePushSubscriptionForInstance(instanceName))
// these are the only critical ones

View file

@ -6,12 +6,14 @@ import { notificationPermissionObservers } from './notificationPermissionObserve
import { customScrollbarObservers } from './customScrollbarObservers'
import { customEmojiObservers } from './customEmojiObservers'
import { cleanup } from './cleanup'
import { wordFilterObservers } from './wordFilterObservers'
// These observers can be lazy-loaded when the user is actually logged in.
// Prevents circular dependencies and reduces the size of main.js
export function loggedInObservers () {
instanceObservers()
timelineObservers()
wordFilterObservers()
notificationObservers()
autosuggestObservers()
notificationPermissionObservers()

View 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 })
}

View file

@ -45,9 +45,11 @@ const persistedState = {
const nonPersistedState = {
customEmoji: {},
unexpiredInstanceFilters: {},
followRequestCounts: {},
instanceInfos: {},
instanceLists: {},
instanceFilters: {},
online: !process.browser || navigator.onLine,
pinnedStatuses: {},
polls: {},

View file

@ -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
}

View file

@ -1,14 +1,13 @@
// create a function for filtering timeline item summaries
function noFilter () {
return true
export const createFilterFunction = (
showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls, wordFilterContext
) => {
return item => {
if (item.filterContexts && item.filterContexts.includes(wordFilterContext)) {
return false
}
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 => {
switch (item.type) {
case 'poll':
return showPolls

View 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')
}

View file

@ -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
}

View file

@ -7,11 +7,17 @@ if (process.browser) {
}
export function on (eventName, component, method) {
if (typeof method === 'undefined') {
method = component
component = undefined
}
const callback = method.bind(component)
eventBus.on(eventName, callback)
if (component) {
component.on('destroy', () => {
eventBus.removeListener(eventName, callback)
})
}
}
export const emit = eventBus.emit.bind(eventBus)

View file

@ -10,3 +10,7 @@ export const importFocusVisible = () => import(
export const importRelativeTimeFormat = () => import(
/* webpackChunkName: '$polyfill$-relative-time-format' */ './relativeTimeFormatPolyfill'
)
export const importListFormat = () => import(
/* webpackChunkName: '$polyfill$-list-format' */ './listFormatPolyfill'
)

View 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'

View file

@ -1,5 +1,5 @@
import { importFocusVisible } from './asyncPolyfills'
import { supportsFocusVisible } from './supportsFocusVisible'
import { supportsFocusVisible } from '../supportsFocusVisible'
export function loadNonCriticalPolyfills () {
return Promise.all([

View file

@ -1,6 +1,7 @@
import {
importRequestIdleCallback,
importRelativeTimeFormat
importRelativeTimeFormat,
importListFormat
} from './asyncPolyfills'
export function loadPolyfills () {
@ -10,6 +11,7 @@ export function loadPolyfills () {
typeof Intl.RelativeTimeFormat !== 'function' ||
typeof Intl.Locale !== 'function' ||
typeof Intl.PluralRules !== 'function'
) && importRelativeTimeFormat()
) && importRelativeTimeFormat(),
typeof Intl.ListFormat !== 'function' && importListFormat()
])
}

View file

@ -1,13 +1,24 @@
import { computeFilterContextsForStatusOrNotification } from './computeFilterContextsForStatusOrNotification'
import { store } from '../_store/store'
class TimelineSummary {
constructor (item) {
constructor (item, instanceName) {
this.id = item.id
this.accountId = item.account.id
this.replyId = (item.in_reply_to_id) || undefined
this.reblogId = (item.reblog && item.reblog.id) || 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) {
return new TimelineSummary(item)
export function timelineItemToSummary (item, instanceName) {
return new TimelineSummary(item, instanceName)
}

View file

@ -1,5 +1,7 @@
:root {
--warn-color: #e14154; // reddish for warnings/errors
//
// Vertical and horizontal padding for the status-article element (Status.html, Notification.html)
//

View file

@ -12,6 +12,7 @@ import { submitMedia } from './submitMedia'
import { voteOnPoll } from '../src/routes/_api/polls'
import { POLL_EXPIRY_DEFAULT } from '../src/routes/_static/polls'
import { createList, getLists } from '../src/routes/_api/lists'
import { createFilter, deleteFilter, getFilters } from '../src/routes/_api/filters'
global.fetch = fetch
global.File = FileApi.File
@ -102,3 +103,14 @@ export async function createListAs (username, title) {
export async function getListsAs (username) {
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)
}

View 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')
})

View file

@ -978,6 +978,13 @@
dependencies:
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":
version "1.5.3"
resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.5.3.tgz#b5978462340da1502502c3fde1c4abccff8f3b8e"
@ -986,6 +993,14 @@
cldr-core "38"
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":
version "2.4.14"
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"
integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
tslib@^2.0.1:
tslib@^2.0.1, tslib@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==