diff --git a/app/soapbox/normalizers/account.ts b/app/soapbox/normalizers/account.ts index e6b6608a1..8bdc11681 100644 --- a/app/soapbox/normalizers/account.ts +++ b/app/soapbox/normalizers/account.ts @@ -13,7 +13,6 @@ import { import emojify from 'soapbox/features/emoji/emoji'; import { normalizeEmoji } from 'soapbox/normalizers/emoji'; -import { acctFull } from 'soapbox/utils/accounts'; import { unescapeHTML } from 'soapbox/utils/html'; import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers'; @@ -197,8 +196,29 @@ const addInternalFields = (account: ImmutableMap) => { }); }; +const getDomainFromURL = (account: ImmutableMap): string => { + try { + const url = account.get('url'); + return new URL(url).host; + } catch { + return ''; + } +}; + +export const guessFqn = (account: ImmutableMap): string => { + const acct = account.get('acct', ''); + const [user, domain] = acct.split('@'); + + if (domain) { + return acct; + } else { + return [user, getDomainFromURL(account)].join('@'); + } +}; + const normalizeFqn = (account: ImmutableMap) => { - return account.set('fqn', acctFull(account)); + const fqn = account.get('fqn') || guessFqn(account); + return account.set('fqn', fqn); }; export const normalizeAccount = (account: Record) => { diff --git a/app/soapbox/normalizers/attachment.ts b/app/soapbox/normalizers/attachment.ts index de960d258..06fba73ad 100644 --- a/app/soapbox/normalizers/attachment.ts +++ b/app/soapbox/normalizers/attachment.ts @@ -26,8 +26,8 @@ export const AttachmentRecord = ImmutableRecord({ // Internal fields // TODO: Remove these? They're set in selectors/index.js - account: null, - status: null, + account: null as any, + status: null as any, }); // Ensure attachments have required fields diff --git a/app/soapbox/normalizers/notification.ts b/app/soapbox/normalizers/notification.ts index defd51215..ea2f922e9 100644 --- a/app/soapbox/normalizers/notification.ts +++ b/app/soapbox/normalizers/notification.ts @@ -9,16 +9,30 @@ import { fromJS, } from 'immutable'; +import type { Account, Status, EmbeddedEntity } from 'soapbox/types/entities'; + +type NotificationType = '' + | 'follow' + | 'follow_request' + | 'mention' + | 'reblog' + | 'favourite' + | 'poll' + | 'status' + | 'move' + | 'pleroma:chat_mention' + | 'pleroma:emoji_reaction'; + // https://docs.joinmastodon.org/entities/notification/ export const NotificationRecord = ImmutableRecord({ - account: null, - chat_message: null, // pleroma:chat_mention + account: null as EmbeddedEntity, + chat_message: null as ImmutableMap | string | null, // pleroma:chat_mention created_at: new Date(), - emoji: null, // pleroma:emoji_reaction + emoji: null as string | null, // pleroma:emoji_reaction id: '', - status: null, - target: null, // move - type: '', + status: null as EmbeddedEntity, + target: null as EmbeddedEntity, // move + type: '' as NotificationType, }); export const normalizeNotification = (notification: Record) => { diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 6496111fd..8f1c27c1e 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -16,13 +16,14 @@ import { normalizeEmoji } from 'soapbox/normalizers/emoji'; import { normalizeMention } from 'soapbox/normalizers/mention'; import { normalizePoll } from 'soapbox/normalizers/poll'; +import type { ReducerAccount } from 'soapbox/reducers/accounts'; import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities'; type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct'; // https://docs.joinmastodon.org/entities/status/ export const StatusRecord = ImmutableRecord({ - account: null as EmbeddedEntity, + account: null as EmbeddedEntity, application: null as ImmutableMap | null, bookmarked: false, card: null as Card | null, diff --git a/app/soapbox/reducers/accounts.ts b/app/soapbox/reducers/accounts.ts index 444d7ac97..781385858 100644 --- a/app/soapbox/reducers/accounts.ts +++ b/app/soapbox/reducers/accounts.ts @@ -45,14 +45,18 @@ type AccountMap = ImmutableMap; type APIEntity = Record; type APIEntities = Array; -type State = ImmutableMap; +export interface ReducerAccount extends AccountRecord { + moved: string | null, +} + +type State = ImmutableMap; const initialState: State = ImmutableMap(); -const minifyAccount = (account: AccountRecord): AccountRecord => { +const minifyAccount = (account: AccountRecord): ReducerAccount => { return account.mergeWith((o, n) => n || o, { moved: normalizeId(account.getIn(['moved', 'id'])), - }); + }) as ReducerAccount; }; const fixAccount = (state: State, account: APIEntity) => { @@ -194,9 +198,9 @@ const importAdminUser = (state: State, adminUser: ImmutableMap): St const account = state.get(id); if (!account) { - return state.set(id, buildAccount(adminUser)); + return state.set(id, minifyAccount(buildAccount(adminUser))); } else { - return state.set(id, mergeAdminUser(account, adminUser)); + return state.set(id, minifyAccount(mergeAdminUser(account, adminUser))); } }; @@ -223,7 +227,7 @@ export default function accounts(state: State = initialState, action: AnyAction) case ACCOUNTS_IMPORT: return normalizeAccounts(state, action.accounts); case ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP: - return state.set(-1, normalizeAccount({ username: action.username })); + return fixAccount(state, { id: -1, username: action.username }); case CHATS_FETCH_SUCCESS: case CHATS_EXPAND_SUCCESS: return importAccountsFromChats(state, action.chats); diff --git a/app/soapbox/reducers/filters.js b/app/soapbox/reducers/filters.js deleted file mode 100644 index 488706573..000000000 --- a/app/soapbox/reducers/filters.js +++ /dev/null @@ -1,12 +0,0 @@ -import { List as ImmutableList, fromJS } from 'immutable'; - -import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; - -export default function filters(state = ImmutableList(), action) { - switch(action.type) { - case FILTERS_FETCH_SUCCESS: - return fromJS(action.filters); - default: - return state; - } -} diff --git a/app/soapbox/reducers/filters.tsx b/app/soapbox/reducers/filters.tsx new file mode 100644 index 000000000..484b2b80a --- /dev/null +++ b/app/soapbox/reducers/filters.tsx @@ -0,0 +1,25 @@ +import { + Map as ImmutableMap, + List as ImmutableList, + fromJS, +} from 'immutable'; + +import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; + +import type { AnyAction } from 'redux'; + +type Filter = ImmutableMap; +type State = ImmutableList; + +const importFilters = (_state: State, filters: unknown): State => { + return ImmutableList(fromJS(filters)).map(filter => ImmutableMap(fromJS(filter))); +}; + +export default function filters(state: State = ImmutableList(), action: AnyAction): State { + switch(action.type) { + case FILTERS_FETCH_SUCCESS: + return importFilters(state, action.filters); + default: + return state; + } +} diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index 37d78e2c7..869b9de61 100644 --- a/app/soapbox/reducers/statuses.ts +++ b/app/soapbox/reducers/statuses.ts @@ -39,15 +39,22 @@ type StatusRecord = ReturnType; type APIEntity = Record; type APIEntities = Array; -type State = ImmutableMap; +type State = ImmutableMap; -const minifyStatus = (status: StatusRecord): StatusRecord => { +export interface ReducerStatus extends StatusRecord { + account: string | null, + reblog: string | null, + poll: string | null, + quote: string | null, +} + +const minifyStatus = (status: StatusRecord): ReducerStatus => { return status.mergeWith((o, n) => n || o, { account: normalizeId(status.getIn(['account', 'id'])), reblog: normalizeId(status.getIn(['reblog', 'id'])), poll: normalizeId(status.getIn(['poll', 'id'])), quote: normalizeId(status.getIn(['quote', 'id'])), - }); + }) as ReducerStatus; }; // Gets titles of poll options from status @@ -121,14 +128,14 @@ const fixQuote = (status: StatusRecord, oldStatus?: StatusRecord): StatusRecord } }; -const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): StatusRecord => { +const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): ReducerStatus => { const oldStatus = state.get(status.id); return normalizeStatus(status).withMutations(status => { fixQuote(status, oldStatus); calculateStatus(status, oldStatus, expandSpoilers); minifyStatus(status); - }); + }) as ReducerStatus; }; const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean): State => diff --git a/app/soapbox/selectors/index.js b/app/soapbox/selectors/index.js deleted file mode 100644 index 2db849b3e..000000000 --- a/app/soapbox/selectors/index.js +++ /dev/null @@ -1,332 +0,0 @@ -import { - Map as ImmutableMap, - List as ImmutableList, - OrderedSet as ImmutableOrderedSet, -} from 'immutable'; -import { createSelector } from 'reselect'; - -import { getSettings } from 'soapbox/actions/settings'; -import { getDomain } from 'soapbox/utils/accounts'; -import { validId } from 'soapbox/utils/auth'; -import ConfigDB from 'soapbox/utils/config_db'; -import { shouldFilter } from 'soapbox/utils/timelines'; - -const getAccountBase = (state, id) => state.getIn(['accounts', id], null); -const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); -const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null); -const getAccountMoved = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]); -const getAccountMeta = (state, id) => state.getIn(['accounts_meta', id], ImmutableMap()); -const getAccountAdminData = (state, id) => state.getIn(['admin', 'users', id]); -const getAccountPatron = (state, id) => { - const url = state.getIn(['accounts', id, 'url']); - return state.getIn(['patron', 'accounts', url]); -}; - -export const makeGetAccount = () => { - return createSelector([ - getAccountBase, - getAccountCounters, - getAccountRelationship, - getAccountMoved, - getAccountMeta, - getAccountAdminData, - getAccountPatron, - ], (base, counters, relationship, moved, meta, admin, patron) => { - if (base === null) { - return null; - } - - return base.withMutations(map => { - map.merge(counters); - map.merge(meta); - map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma - map.set('relationship', relationship); - map.set('moved', moved); - map.set('patron', patron); - map.setIn(['pleroma', 'admin'], admin); - }); - }); -}; - -const findAccountsByUsername = (state, username) => { - const accounts = state.get('accounts'); - - return accounts.filter(account => { - return username.toLowerCase() === account.getIn(['acct'], '').toLowerCase(); - }); -}; - -export const findAccountByUsername = (state, username) => { - const accounts = findAccountsByUsername(state, username); - - if (accounts.size > 1) { - const me = state.get('me'); - const meURL = state.getIn(['accounts', me, 'url']); - - return accounts.find(account => { - try { - // If more than one account has the same username, try matching its host - const { host } = new URL(account.get('url')); - const { host: meHost } = new URL(meURL); - return host === meHost; - } catch { - return false; - } - }); - } else { - return accounts.first(); - } -}; - -const toServerSideType = columnType => { - switch (columnType) { - case 'home': - case 'notifications': - case 'public': - case 'thread': - return columnType; - default: - if (columnType.indexOf('list:') > -1) { - return 'home'; - } else { - return 'public'; // community, account, hashtag - } - } -}; - -export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); - -const escapeRegExp = string => - string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string - -export const regexFromFilters = filters => { - if (filters.size === 0) { - return null; - } - - return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.get('phrase')); - - if (filter.get('whole_word')) { - if (/^[\w]/.test(expr)) { - expr = `\\b${expr}`; - } - - if (/[\w]$/.test(expr)) { - expr = `${expr}\\b`; - } - } - - return expr; - }).join('|'), 'i'); -}; - -export const makeGetStatus = () => { - return createSelector( - [ - (state, { id }) => state.getIn(['statuses', id]), - (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), - (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), - (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), - (state, { username }) => username, - getFilters, - (state) => state.get('me'), - ], - - (statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => { - if (!statusBase) { - return null; - } - - const accountUsername = accountBase.get('acct'); - //Must be owner of status if username exists - if (accountUsername !== username && username !== undefined) { - return null; - } - - if (statusReblog) { - statusReblog = statusReblog.set('account', accountReblog); - } else { - statusReblog = null; - } - - const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters); - const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index')); - - return statusBase.withMutations(map => { - map.set('reblog', statusReblog); - map.set('account', accountBase); - map.set('filtered', filtered); - }); - }, - ); -}; - -const getAlertsBase = state => state.get('alerts'); - -export const getAlerts = createSelector([getAlertsBase], (base) => { - const arr = []; - - base.forEach(item => { - arr.push({ - message: item.get('message'), - title: item.get('title'), - actionLabel: item.get('actionLabel'), - actionLink: item.get('actionLink'), - key: item.get('key'), - className: `notification-bar-${item.get('severity', 'info')}`, - activeClassName: 'snackbar--active', - dismissAfter: 6000, - style: false, - }); - }); - - return arr; -}); - -export const makeGetNotification = () => { - return createSelector([ - (state, notification) => notification, - (state, notification) => state.getIn(['accounts', notification.get('account')]), - (state, notification) => state.getIn(['accounts', notification.get('target')]), - (state, notification) => state.getIn(['statuses', notification.get('status')]), - ], (notification, account, target, status) => { - return notification.merge({ account, target, status }); - }); -}; - -export const getAccountGallery = createSelector([ - (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), - state => state.get('statuses'), - state => state.get('accounts'), -], (statusIds, statuses, accounts) => { - - return statusIds.reduce((medias, statusId) => { - const status = statuses.get(statusId); - const account = accounts.get(status.get('account')); - if (status.get('reblog')) return medias; - return medias.concat(status.get('media_attachments') - .map(media => media.merge({ status, account }))); - }, ImmutableList()); -}); - -export const makeGetChat = () => { - return createSelector( - [ - (state, { id }) => state.getIn(['chats', 'items', id]), - (state, { id }) => state.getIn(['accounts', state.getIn(['chats', 'items', id, 'account'])]), - (state, { last_message }) => state.getIn(['chat_messages', last_message]), - ], - - (chat, account, lastMessage) => { - if (!chat) return null; - - return chat.withMutations(map => { - map.set('account', account); - map.set('last_message', lastMessage); - }); - }, - ); -}; - -export const makeGetReport = () => { - const getStatus = makeGetStatus(); - - return createSelector( - [ - (state, id) => state.getIn(['admin', 'reports', id]), - (state, id) => state.getIn(['admin', 'reports', id, 'statuses']).map( - statusId => state.getIn(['statuses', statusId])) - .filter(s => s) - .map(s => getStatus(state, s.toJS())), - ], - - (report, statuses) => { - if (!report) return null; - return report.set('statuses', statuses); - }, - ); -}; - -const getAuthUserIds = createSelector([ - state => state.getIn(['auth', 'users'], ImmutableMap()), -], authUsers => { - return authUsers.reduce((ids, authUser) => { - try { - const id = authUser.get('id'); - return validId(id) ? ids.add(id) : ids; - } catch { - return ids; - } - }, ImmutableOrderedSet()); -}); - -export const makeGetOtherAccounts = () => { - return createSelector([ - state => state.get('accounts'), - getAuthUserIds, - state => state.get('me'), - ], - (accounts, authUserIds, me) => { - return authUserIds - .reduce((list, id) => { - if (id === me) return list; - const account = accounts.get(id); - return account ? list.push(account) : list; - }, ImmutableList()); - }); -}; - -const getSimplePolicy = createSelector([ - state => state.getIn(['admin', 'configs'], ImmutableMap()), - state => state.getIn(['instance', 'pleroma', 'metadata', 'federation', 'mrf_simple'], ImmutableMap()), -], (configs, instancePolicy) => { - return instancePolicy.merge(ConfigDB.toSimplePolicy(configs)); -}); - -const getRemoteInstanceFavicon = (state, host) => ( - state.get('accounts') - .find(account => getDomain(account) === host, null, ImmutableMap()) - .getIn(['pleroma', 'favicon']) -); - -const getRemoteInstanceFederation = (state, host) => ( - getSimplePolicy(state) - .map(hosts => hosts.includes(host)) -); - -export const makeGetHosts = () => { - return createSelector([getSimplePolicy], (simplePolicy) => { - return simplePolicy - .deleteAll(['accept', 'reject_deletes', 'report_removal']) - .reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet()) - .sort(); - }); -}; - -export const makeGetRemoteInstance = () => { - return createSelector([ - (state, host) => host, - getRemoteInstanceFavicon, - getRemoteInstanceFederation, - ], (host, favicon, federation) => { - return ImmutableMap({ - host, - favicon, - federation, - }); - }); -}; - -export const makeGetStatusIds = () => createSelector([ - (state, { type, prefix }) => getSettings(state).get(prefix || type, ImmutableMap()), - (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableOrderedSet()), - (state) => state.get('statuses'), - (state) => state.get('me'), -], (columnSettings, statusIds, statuses, me) => { - return statusIds.filter(id => { - const status = statuses.get(id); - if (!status) return true; - return !shouldFilter(status, columnSettings); - }); -}); diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts new file mode 100644 index 000000000..2eb21744e --- /dev/null +++ b/app/soapbox/selectors/index.ts @@ -0,0 +1,359 @@ +import { + Map as ImmutableMap, + List as ImmutableList, + OrderedSet as ImmutableOrderedSet, +} from 'immutable'; +import { createSelector } from 'reselect'; + +import { getSettings } from 'soapbox/actions/settings'; +import { getDomain } from 'soapbox/utils/accounts'; +import { validId } from 'soapbox/utils/auth'; +import ConfigDB from 'soapbox/utils/config_db'; +import { shouldFilter } from 'soapbox/utils/timelines'; + +import type { RootState } from 'soapbox/store'; +import type { Notification } from 'soapbox/types/entities'; + +const normalizeId = (id: any): string => typeof id === 'string' ? id : ''; + +const getAccountBase = (state: RootState, id: string) => state.accounts.get(id); +const getAccountCounters = (state: RootState, id: string) => state.accounts_counters.get(id); +const getAccountRelationship = (state: RootState, id: string) => state.relationships.get(id); +const getAccountMoved = (state: RootState, id: string) => state.accounts.get(state.accounts.get(id)?.moved || ''); +const getAccountMeta = (state: RootState, id: string) => state.accounts_meta.get(id, ImmutableMap()); +const getAccountAdminData = (state: RootState, id: string) => state.admin.users.get(id); +const getAccountPatron = (state: RootState, id: string) => { + const url = state.accounts.get(id)?.url; + return state.patron.getIn(['accounts', url]); +}; + +export const makeGetAccount = () => { + return createSelector([ + getAccountBase, + getAccountCounters, + getAccountRelationship, + getAccountMoved, + getAccountMeta, + getAccountAdminData, + getAccountPatron, + ], (base, counters, relationship, moved, meta, admin, patron) => { + if (!base) return null; + + return base.withMutations(map => { + map.merge(counters); + map.merge(meta); + map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma + map.set('relationship', relationship); + map.set('moved', moved || null); + map.set('patron', patron); + map.setIn(['pleroma', 'admin'], admin); + }); + }); +}; + +const findAccountsByUsername = (state: RootState, username: string) => { + const accounts = state.accounts; + + return accounts.filter(account => { + return username.toLowerCase() === account.acct.toLowerCase(); + }); +}; + +export const findAccountByUsername = (state: RootState, username: string) => { + const accounts = findAccountsByUsername(state, username); + + if (accounts.size > 1) { + const me = state.me; + const meURL = state.accounts.get(me)?.url || ''; + + return accounts.find(account => { + try { + // If more than one account has the same username, try matching its host + const { host } = new URL(account.url); + const { host: meHost } = new URL(meURL); + return host === meHost; + } catch { + return false; + } + }); + } else { + return accounts.first(); + } +}; + +const toServerSideType = (columnType: string): string => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + return columnType; + default: + if (columnType.indexOf('list:') > -1) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; + +type FilterContext = { contextType: string }; + +export const getFilters = (state: RootState, { contextType }: FilterContext) => { + return state.filters.filter((filter): boolean => { + return contextType + && filter.get('context').includes(toServerSideType(contextType)) + && (filter.get('expires_at') === null + || Date.parse(filter.get('expires_at')) > new Date().getTime()); + }); +}; + +const escapeRegExp = (string: string) => + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + +export const regexFromFilters = (filters: ImmutableList>) => { + if (filters.size === 0) return null; + + return new RegExp(filters.map(filter => { + let expr = escapeRegExp(filter.get('phrase')); + + if (filter.get('whole_word')) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } + } + + return expr; + }).join('|'), 'i'); +}; + +type APIStatus = { id: string, username: string }; + +export const makeGetStatus = () => { + return createSelector( + [ + (state: RootState, { id }: APIStatus) => state.statuses.get(id), + (state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''), + (state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''), + (state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''), + (_state: RootState, { username }: APIStatus) => username, + getFilters, + (state: RootState) => state.me, + ], + + (statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => { + if (!statusBase || !accountBase) return null; + + const accountUsername = accountBase.acct; + //Must be owner of status if username exists + if (accountUsername !== username && username !== undefined) { + return null; + } + + if (statusReblog && accountReblog) { + // @ts-ignore AAHHHHH + statusReblog = statusReblog.set('account', accountReblog); + } else { + statusReblog = undefined; + } + + const regex = (accountReblog || accountBase).id !== me && regexFromFilters(filters); + const filtered = regex && regex.test(statusReblog?.search_index || statusBase.search_index); + + return statusBase.withMutations(map => { + map.set('reblog', statusReblog || null); + // @ts-ignore :( + map.set('account', accountBase || null); + map.set('filtered', Boolean(filtered)); + }); + }, + ); +}; + +const getAlertsBase = (state: RootState) => state.alerts; + +const buildAlert = (item: any) => { + return { + message: item.message, + title: item.title, + actionLabel: item.actionLabel, + actionLink: item.actionLink, + key: item.key, + className: `notification-bar-${item.severity}`, + activeClassName: 'snackbar--active', + dismissAfter: 6000, + style: false, + }; +}; + +type Alert = ReturnType; + +export const getAlerts = createSelector([getAlertsBase], (base): Alert[] => { + const arr: Alert[] = []; + base.forEach((item: any) => arr.push(buildAlert(item))); + return arr; +}); + +export const makeGetNotification = () => { + return createSelector([ + (_state: RootState, notification: Notification) => notification, + (state: RootState, notification: Notification) => state.accounts.get(normalizeId(notification.account)), + (state: RootState, notification: Notification) => state.accounts.get(normalizeId(notification.target)), + (state: RootState, notification: Notification) => state.statuses.get(normalizeId(notification.status)), + ], (notification, account, target, status) => { + return notification.merge({ + // @ts-ignore + account: account || null, + // @ts-ignore + target: target || null, + // @ts-ignore + status: status || null, + }); + }); +}; + +export const getAccountGallery = createSelector([ + (state: RootState, id: string) => state.timelines.getIn([`account:${id}:media`, 'items'], ImmutableList()), + (state: RootState) => state.statuses, + (state: RootState) => state.accounts, +], (statusIds, statuses, accounts) => { + + return statusIds.reduce((medias: ImmutableList, statusId: string) => { + const status = statuses.get(statusId); + if (!status) return medias; + if (status.reblog) return medias; + if (typeof status.account !== 'string') return medias; + + const account = accounts.get(status.account); + + return medias.concat( + status.media_attachments.map(media => media.merge({ status, account }))); + }, ImmutableList()); +}); + +type APIChat = { id: string, last_message: string }; + +export const makeGetChat = () => { + return createSelector( + [ + (state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]), + (state: RootState, { id }: APIChat) => state.accounts.get(state.chats.getIn(['items', id, 'account'])), + (state: RootState, { last_message }: APIChat) => state.chat_messages.get(last_message), + ], + + (chat, account, lastMessage: string) => { + if (!chat) return null; + + return chat.withMutations((map: ImmutableMap) => { + map.set('account', account); + map.set('last_message', lastMessage); + }); + }, + ); +}; + +export const makeGetReport = () => { + const getStatus = makeGetStatus(); + + return createSelector( + [ + (state: RootState, id: string) => state.admin.getIn(['reports', id]), + (state: RootState, id: string) => state.admin.getIn(['reports', id, 'statuses']).map( + (statusId: string) => state.statuses.get(statusId)) + .filter((s: any) => s) + .map((s: any) => getStatus(state, s.toJS())), + ], + + (report, statuses) => { + if (!report) return null; + return report.set('statuses', statuses); + }, + ); +}; + +const getAuthUserIds = createSelector([ + (state: RootState) => state.auth.get('users', ImmutableMap()), +], authUsers => { + return authUsers.reduce((ids: ImmutableOrderedSet, authUser: ImmutableMap) => { + try { + const id = authUser.get('id'); + return validId(id) ? ids.add(id) : ids; + } catch { + return ids; + } + }, ImmutableOrderedSet()); +}); + +export const makeGetOtherAccounts = () => { + return createSelector([ + (state: RootState) => state.accounts, + getAuthUserIds, + (state: RootState) => state.me, + ], + (accounts, authUserIds, me) => { + return authUserIds + .reduce((list: ImmutableList, id: string) => { + if (id === me) return list; + const account = accounts.get(id); + return account ? list.push(account) : list; + }, ImmutableList()); + }); +}; + +const getSimplePolicy = createSelector([ + (state: RootState) => state.admin.configs, + (state: RootState) => state.instance.pleroma.getIn(['metadata', 'federation', 'mrf_simple'], ImmutableMap()), +], (configs, instancePolicy: ImmutableMap) => { + return instancePolicy.merge(ConfigDB.toSimplePolicy(configs)); +}); + +const getRemoteInstanceFavicon = (state: RootState, host: string) => ( + (state.accounts.find(account => getDomain(account) === host, null) || ImmutableMap()) + .getIn(['pleroma', 'favicon']) +); + +const getRemoteInstanceFederation = (state: RootState, host: string) => ( + getSimplePolicy(state) + .map(hosts => hosts.includes(host)) +); + +export const makeGetHosts = () => { + return createSelector([getSimplePolicy], (simplePolicy) => { + return simplePolicy + .deleteAll(['accept', 'reject_deletes', 'report_removal']) + .reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet()) + .sort(); + }); +}; + +export const makeGetRemoteInstance = () => { + return createSelector([ + (_state: RootState, host: string) => host, + getRemoteInstanceFavicon, + getRemoteInstanceFederation, + ], (host, favicon, federation) => { + return ImmutableMap({ + host, + favicon, + federation, + }); + }); +}; + +type ColumnQuery = { type: string, prefix?: string }; + +export const makeGetStatusIds = () => createSelector([ + (state: RootState, { type, prefix }: ColumnQuery) => getSettings(state).get(prefix || type, ImmutableMap()), + (state: RootState, { type }: ColumnQuery) => state.timelines.getIn([type, 'items'], ImmutableOrderedSet()), + (state: RootState) => state.statuses, +], (columnSettings, statusIds: string[], statuses) => { + return statusIds.filter((id: string) => { + const status = statuses.get(id); + if (!status) return true; + return !shouldFilter(status, columnSettings); + }); +}); diff --git a/app/soapbox/utils/__tests__/accounts-test.js b/app/soapbox/utils/__tests__/accounts-test.js index 15a42ec57..ddf38adde 100644 --- a/app/soapbox/utils/__tests__/accounts-test.js +++ b/app/soapbox/utils/__tests__/accounts-test.js @@ -2,7 +2,6 @@ import { fromJS } from 'immutable'; import { getDomain, - acctFull, isStaff, isAdmin, isModerator, @@ -18,28 +17,6 @@ describe('getDomain', () => { }); }); -describe('acctFull', () => { - describe('with a local user', () => { - const account = fromJS({ - acct: 'alice', - url: 'https://party.com/users/alice', - }); - it('returns the full acct', () => { - expect(acctFull(account)).toEqual('alice@party.com'); - }); - }); - - describe('with a remote user', () => { - const account = fromJS({ - acct: 'bob@pool.com', - url: 'https://pool.com/users/bob', - }); - it('returns the full acct', () => { - expect(acctFull(account)).toEqual('bob@pool.com'); - }); - }); -}); - describe('isStaff', () => { describe('with empty user', () => { const account = fromJS({}); diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index 34e4429f9..3536cc966 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -2,26 +2,20 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl import { Account } from 'soapbox/types/entities'; -const getDomainFromURL = (account: ImmutableMap): string => { +const getDomainFromURL = (account: Account): string => { try { - const url = account.get('url'); + const url = account.url; return new URL(url).host; } catch { return ''; } }; -export const getDomain = (account: ImmutableMap): string => { - const domain = account.get('acct', '').split('@')[1]; +export const getDomain = (account: Account): string => { + const domain = account.acct.split('@')[1]; return domain ? domain : getDomainFromURL(account); }; -export const guessFqn = (account: ImmutableMap): string => { - const [user, domain] = account.get('acct', '').split('@'); - if (!domain) return [user, getDomainFromURL(account)].join('@'); - return account.get('acct', ''); -}; - export const getBaseURL = (account: ImmutableMap): string => { try { const url = account.get('url'); @@ -31,11 +25,6 @@ export const getBaseURL = (account: ImmutableMap): string => { } }; -// user@domain even for local users -export const acctFull = (account: ImmutableMap): string => ( - account.get('fqn') || guessFqn(account) || '' -); - export const getAcct = (account: Account, displayFqn: boolean): string => ( displayFqn === true ? account.fqn : account.acct );