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"
|
||||
},
|
||||
"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",
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
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 { 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')
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
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 { 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 {
|
||||
|
|
|
@ -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()">
|
||||
|
|
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}
|
||||
</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>
|
||||
|
|
|
@ -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">
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
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}
|
||||
<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>
|
||||
|
|
|
@ -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;
|
||||
|
|
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, 'pinnedPage', 'pinnedPages', '/local')
|
||||
computeForInstance(store, 'lists', 'instanceLists', [])
|
||||
computeForInstance(store, 'filters', 'instanceFilters', [])
|
||||
computeForInstance(store, 'currentStatusModifications', 'statusModifications', null)
|
||||
computeForInstance(store, 'currentCustomEmoji', 'customEmoji', [])
|
||||
computeForInstance(store, 'currentComposeData', 'composeData', {})
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
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 { 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
|
||||
|
|
|
@ -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()
|
||||
|
|
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 = {
|
||||
customEmoji: {},
|
||||
unexpiredInstanceFilters: {},
|
||||
followRequestCounts: {},
|
||||
instanceInfos: {},
|
||||
instanceLists: {},
|
||||
instanceFilters: {},
|
||||
online: !process.browser || navigator.onLine,
|
||||
pinnedStatuses: {},
|
||||
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
|
||||
|
||||
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
|
||||
|
|
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) {
|
||||
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)
|
||||
|
|
|
@ -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'
|
||||
)
|
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 { supportsFocusVisible } from './supportsFocusVisible'
|
||||
import { supportsFocusVisible } from '../supportsFocusVisible'
|
||||
|
||||
export function loadNonCriticalPolyfills () {
|
||||
return Promise.all([
|
|
@ -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()
|
||||
])
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
//
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
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:
|
||||
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==
|
||||
|
|
Loading…
Reference in a new issue