diff --git a/package.json b/package.json index 654a712d..b8c27ff5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client.js b/src/client.js index 7bb5a668..2026e1fb 100644 --- a/src/client.js +++ b/src/client.js @@ -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(() => { diff --git a/src/intl/en-US.js b/src/intl/en-US.js index 43697d8c..d6546885 100644 --- a/src/intl/en-US.js +++ b/src/intl/en-US.js @@ -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' } diff --git a/src/routes/_actions/addStatusOrNotification.js b/src/routes/_actions/addStatusOrNotification.js index 731cf116..76eb558c 100644 --- a/src/routes/_actions/addStatusOrNotification.js +++ b/src/routes/_actions/addStatusOrNotification.js @@ -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)) { diff --git a/src/routes/_actions/filters.js b/src/routes/_actions/filters.js new file mode 100644 index 00000000..d59ca455 --- /dev/null +++ b/src/routes/_actions/filters.js @@ -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 || '')) + } +} diff --git a/src/routes/_actions/stream/processMessage.js b/src/routes/_actions/stream/processMessage.js index 0368242b..4e66a052 100644 --- a/src/routes/_actions/stream/processMessage.js +++ b/src/routes/_actions/stream/processMessage.js @@ -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') } diff --git a/src/routes/_actions/timeline.js b/src/routes/_actions/timeline.js index 4efb7b1a..900fabfc 100644 --- a/src/routes/_actions/timeline.js +++ b/src/routes/_actions/timeline.js @@ -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') } diff --git a/src/routes/_api/filters.js b/src/routes/_api/filters.js new file mode 100644 index 00000000..98ec6050 --- /dev/null +++ b/src/routes/_api/filters.js @@ -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 }) +} diff --git a/src/routes/_components/dialog/asyncDialogs/importShowWordFilterDialog.js b/src/routes/_components/dialog/asyncDialogs/importShowWordFilterDialog.js new file mode 100644 index 00000000..fd779623 --- /dev/null +++ b/src/routes/_components/dialog/asyncDialogs/importShowWordFilterDialog.js @@ -0,0 +1,3 @@ +export const importShowWordFilterDialog = () => import( + /* webpackChunkName: 'showWordFilterDialog' */ '../creators/showWordFilterDialog' +).then(mod => mod.default) diff --git a/src/routes/_components/dialog/components/EmojiDialog.html b/src/routes/_components/dialog/components/EmojiDialog.html index 43253498..4a988916 100644 --- a/src/routes/_components/dialog/components/EmojiDialog.html +++ b/src/routes/_components/dialog/components/EmojiDialog.html @@ -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 { diff --git a/src/routes/_components/dialog/components/GenericConfirmationDialog.html b/src/routes/_components/dialog/components/GenericConfirmationDialog.html index 10fad524..b7e5e980 100644 --- a/src/routes/_components/dialog/components/GenericConfirmationDialog.html +++ b/src/routes/_components/dialog/components/GenericConfirmationDialog.html @@ -9,7 +9,7 @@
-
- + diff --git a/src/routes/_components/settings/instance/GenericInstanceSettingsStyle.html b/src/routes/_components/settings/instance/GenericInstanceSettingsStyle.html new file mode 100644 index 00000000..739c7ebc --- /dev/null +++ b/src/routes/_components/settings/instance/GenericInstanceSettingsStyle.html @@ -0,0 +1,10 @@ + diff --git a/src/routes/_components/settings/instance/InstanceUserProfile.html b/src/routes/_components/settings/instance/InstanceUserProfile.html index 2b963325..c268bfa2 100644 --- a/src/routes/_components/settings/instance/InstanceUserProfile.html +++ b/src/routes/_components/settings/instance/InstanceUserProfile.html @@ -1,4 +1,4 @@ -
+
@@ -10,12 +10,10 @@
+ diff --git a/src/routes/_components/settings/instance/ThemeSettings.html b/src/routes/_components/settings/instance/ThemeSettings.html index f08632bc..9558d7be 100644 --- a/src/routes/_components/settings/instance/ThemeSettings.html +++ b/src/routes/_components/settings/instance/ThemeSettings.html @@ -1,4 +1,4 @@ -
+
{#each themeGroups as themeGroup}
@@ -24,15 +24,8 @@ {/each}
+ diff --git a/src/routes/_components/settings/instance/WordFilterSettings.html b/src/routes/_components/settings/instance/WordFilterSettings.html new file mode 100644 index 00000000..5b13853a --- /dev/null +++ b/src/routes/_components/settings/instance/WordFilterSettings.html @@ -0,0 +1,96 @@ +
+ {#if filters.length} + + + + + + + + + + + {#each formattedFilters as filter (filter.id)} + + + + + + + {/each} + +
{intl.wordOrPhrase}{intl.contexts}
{filter.phrase}{filter.formattedContexts} + + + +
+ {:else} +

{intl.noFilters}

+ {/if} + +
+ + + diff --git a/src/routes/_database/meta.js b/src/routes/_database/meta.js index 526ea3ef..35f7e500 100644 --- a/src/routes/_database/meta.js +++ b/src/routes/_database/meta.js @@ -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) +} diff --git a/src/routes/_pages/settings/instances/[instanceName].html b/src/routes/_pages/settings/instances/[instanceName].html index c97fad5f..05a776a8 100644 --- a/src/routes/_pages/settings/instances/[instanceName].html +++ b/src/routes/_pages/settings/instances/[instanceName].html @@ -4,15 +4,16 @@ {#if verifyCredentials}

{intl.loggedInAs}

+

{intl.theme}

+

{intl.homeTimelineFilters}

{intl.notificationFilters}

+

{intl.wordFilters}

+

{intl.pushNotifications}

-

{intl.theme}

- - {/if} @@ -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 } } diff --git a/src/routes/_pages/settings/instances/add.html b/src/routes/_pages/settings/instances/add.html index aa823943..479e2227 100644 --- a/src/routes/_pages/settings/instances/add.html +++ b/src/routes/_pages/settings/instances/add.html @@ -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; diff --git a/src/routes/_static/wordFilters.js b/src/routes/_static/wordFilters.js new file mode 100644 index 00000000..b02c508e --- /dev/null +++ b/src/routes/_static/wordFilters.js @@ -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 diff --git a/src/routes/_store/computations/badgeComputations.js b/src/routes/_store/computations/badgeComputations.js new file mode 100644 index 00000000..8203c455 --- /dev/null +++ b/src/routes/_store/computations/badgeComputations.js @@ -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 + ) +} diff --git a/src/routes/_store/computations/instanceComputations.js b/src/routes/_store/computations/instanceComputations.js index 43a9069d..14e70855 100644 --- a/src/routes/_store/computations/instanceComputations.js +++ b/src/routes/_store/computations/instanceComputations.js @@ -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', {}) diff --git a/src/routes/_store/computations/loggedInComputations.js b/src/routes/_store/computations/loggedInComputations.js index 39072659..03a1f12b 100644 --- a/src/routes/_store/computations/loggedInComputations.js +++ b/src/routes/_store/computations/loggedInComputations.js @@ -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') } diff --git a/src/routes/_store/computations/timelineComputations.js b/src/routes/_store/computations/timelineComputations.js index 9c74078d..2330854a 100644 --- a/src/routes/_store/computations/timelineComputations.js +++ b/src/routes/_store/computations/timelineComputations.js @@ -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') } diff --git a/src/routes/_store/computations/timelineFilterComputations.js b/src/routes/_store/computations/timelineFilterComputations.js new file mode 100644 index 00000000..07ba79da --- /dev/null +++ b/src/routes/_store/computations/timelineFilterComputations.js @@ -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) + ) + ) +} diff --git a/src/routes/_store/computations/wordFilterComputations.js b/src/routes/_store/computations/wordFilterComputations.js new file mode 100644 index 00000000..46e46b95 --- /dev/null +++ b/src/routes/_store/computations/wordFilterComputations.js @@ -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] + })) + }) +} diff --git a/src/routes/_store/observers/instanceObservers.js b/src/routes/_store/observers/instanceObservers.js index 9af4104f..8e0eddc4 100644 --- a/src/routes/_store/observers/instanceObservers.js +++ b/src/routes/_store/observers/instanceObservers.js @@ -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 diff --git a/src/routes/_store/observers/loggedInObservers.js b/src/routes/_store/observers/loggedInObservers.js index 83c652c0..5f23f080 100644 --- a/src/routes/_store/observers/loggedInObservers.js +++ b/src/routes/_store/observers/loggedInObservers.js @@ -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() diff --git a/src/routes/_store/observers/wordFilterObservers.js b/src/routes/_store/observers/wordFilterObservers.js new file mode 100644 index 00000000..44849da6 --- /dev/null +++ b/src/routes/_store/observers/wordFilterObservers.js @@ -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 }) +} diff --git a/src/routes/_store/store.js b/src/routes/_store/store.js index e79803cb..6b799638 100644 --- a/src/routes/_store/store.js +++ b/src/routes/_store/store.js @@ -45,9 +45,11 @@ const persistedState = { const nonPersistedState = { customEmoji: {}, + unexpiredInstanceFilters: {}, followRequestCounts: {}, instanceInfos: {}, instanceLists: {}, + instanceFilters: {}, online: !process.browser || navigator.onLine, pinnedStatuses: {}, polls: {}, diff --git a/src/routes/_utils/computeFilterContextsForStatusOrNotification.js b/src/routes/_utils/computeFilterContextsForStatusOrNotification.js new file mode 100644 index 00000000..2ba8641f --- /dev/null +++ b/src/routes/_utils/computeFilterContextsForStatusOrNotification.js @@ -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 +} diff --git a/src/routes/_utils/createFilterFunction.js b/src/routes/_utils/createFilterFunction.js index 96b0dc21..6846a6f7 100644 --- a/src/routes/_utils/createFilterFunction.js +++ b/src/routes/_utils/createFilterFunction.js @@ -1,14 +1,13 @@ // create a function for filtering timeline item summaries -function noFilter () { - return true -} - -export function createFilterFunction (showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls) { - if (showReblogs && showReplies && showFollows && showFavs && showMentions && showPolls) { - return noFilter // fast path for the default setting - } +export const createFilterFunction = ( + showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls, wordFilterContext +) => { return item => { + if (item.filterContexts && item.filterContexts.includes(wordFilterContext)) { + return false + } + switch (item.type) { case 'poll': return showPolls diff --git a/src/routes/_utils/createRegexFromFilter.js b/src/routes/_utils/createRegexFromFilter.js new file mode 100644 index 00000000..adaa3793 --- /dev/null +++ b/src/routes/_utils/createRegexFromFilter.js @@ -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') +} diff --git a/src/routes/_utils/createSearchIndexFromStatusOrNotification.js b/src/routes/_utils/createSearchIndexFromStatusOrNotification.js new file mode 100644 index 00000000..b50b6929 --- /dev/null +++ b/src/routes/_utils/createSearchIndexFromStatusOrNotification.js @@ -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(//g, '\n').replace(/<\/p>

/g, '\n\n') + return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent +} diff --git a/src/routes/_utils/eventBus.js b/src/routes/_utils/eventBus.js index 1eaaeea6..ad5e345b 100644 --- a/src/routes/_utils/eventBus.js +++ b/src/routes/_utils/eventBus.js @@ -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) - component.on('destroy', () => { - eventBus.removeListener(eventName, callback) - }) + if (component) { + component.on('destroy', () => { + eventBus.removeListener(eventName, callback) + }) + } } export const emit = eventBus.emit.bind(eventBus) diff --git a/src/routes/_utils/asyncPolyfills.js b/src/routes/_utils/polyfills/asyncPolyfills.js similarity index 76% rename from src/routes/_utils/asyncPolyfills.js rename to src/routes/_utils/polyfills/asyncPolyfills.js index 8d4e41a7..22ed3fca 100644 --- a/src/routes/_utils/asyncPolyfills.js +++ b/src/routes/_utils/polyfills/asyncPolyfills.js @@ -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' +) diff --git a/src/routes/_utils/polyfills/listFormatPolyfill.js b/src/routes/_utils/polyfills/listFormatPolyfill.js new file mode 100644 index 00000000..aa69739e --- /dev/null +++ b/src/routes/_utils/polyfills/listFormatPolyfill.js @@ -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' diff --git a/src/routes/_utils/loadNonCriticalPolyfills.js b/src/routes/_utils/polyfills/loadNonCriticalPolyfills.js similarity index 74% rename from src/routes/_utils/loadNonCriticalPolyfills.js rename to src/routes/_utils/polyfills/loadNonCriticalPolyfills.js index 3b605bd4..354bb2e9 100644 --- a/src/routes/_utils/loadNonCriticalPolyfills.js +++ b/src/routes/_utils/polyfills/loadNonCriticalPolyfills.js @@ -1,5 +1,5 @@ import { importFocusVisible } from './asyncPolyfills' -import { supportsFocusVisible } from './supportsFocusVisible' +import { supportsFocusVisible } from '../supportsFocusVisible' export function loadNonCriticalPolyfills () { return Promise.all([ diff --git a/src/routes/_utils/loadPolyfills.js b/src/routes/_utils/polyfills/loadPolyfills.js similarity index 70% rename from src/routes/_utils/loadPolyfills.js rename to src/routes/_utils/polyfills/loadPolyfills.js index 08d54d3d..981454ba 100644 --- a/src/routes/_utils/loadPolyfills.js +++ b/src/routes/_utils/polyfills/loadPolyfills.js @@ -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() ]) } diff --git a/src/routes/_utils/relativeTimeFormatPolyfill.js b/src/routes/_utils/polyfills/relativeTimeFormatPolyfill.js similarity index 100% rename from src/routes/_utils/relativeTimeFormatPolyfill.js rename to src/routes/_utils/polyfills/relativeTimeFormatPolyfill.js diff --git a/src/routes/_utils/timelineItemToSummary.js b/src/routes/_utils/timelineItemToSummary.js index dc7d23cb..c4b6d0a5 100644 --- a/src/routes/_utils/timelineItemToSummary.js +++ b/src/routes/_utils/timelineItemToSummary.js @@ -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) } diff --git a/src/scss/variables.scss b/src/scss/variables.scss index d3a44686..25458163 100644 --- a/src/scss/variables.scss +++ b/src/scss/variables.scss @@ -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) // diff --git a/tests/serverActions.js b/tests/serverActions.js index aabbb6c7..f175fa6d 100644 --- a/tests/serverActions.js +++ b/tests/serverActions.js @@ -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) +} diff --git a/tests/spec/138-word-filters.js b/tests/spec/138-word-filters.js new file mode 100644 index 00000000..4dec6ccd --- /dev/null +++ b/tests/spec/138-word-filters.js @@ -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') +}) diff --git a/yarn.lock b/yarn.lock index 2d1c7373..e131b994 100644 --- a/yarn.lock +++ b/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==