From 141739966316ecef2657e651377b9b977590a9d5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 23 Oct 2024 10:23:48 -0500 Subject: [PATCH 1/7] Add applicationSchema --- src/schemas/application.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/schemas/application.ts diff --git a/src/schemas/application.ts b/src/schemas/application.ts new file mode 100644 index 000000000..a94ee1066 --- /dev/null +++ b/src/schemas/application.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +const applicationSchema = z.object({ + name: z.string(), + website: z.string().url().nullable().catch(null), + scopes: z.string().array().catch([]), + redirect_uris: z.string().url().array().optional().catch(undefined), + redirect_uri: z.string().url().optional().catch(undefined), +}).transform((app) => { + const { name, website, scopes, redirect_uris, redirect_uri } = app; + + return { + name, + website, + scopes, + redirect_uris: redirect_uris || (redirect_uri ? [redirect_uri] : []), + }; +}); + +type Application = z.infer; + +export { applicationSchema, Application }; \ No newline at end of file From ae546db8f048a491210cb8419783358357d7e591 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 23 Oct 2024 11:50:09 -0500 Subject: [PATCH 2/7] Remove immutable from the auth reducer (woah, wow) --- src/reducers/auth.ts | 494 ++++++++-------------------- src/schemas/soapbox/soapbox-auth.ts | 4 +- 2 files changed, 146 insertions(+), 352 deletions(-) diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index f066f3dbb..d5bc456a1 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -1,15 +1,16 @@ -import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; -import trim from 'lodash/trim'; +import { AxiosError } from 'axios'; +import { produce } from 'immer'; +import { z } from 'zod'; -import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload'; -import * as BuildConfig from 'soapbox/build-config'; -import KVStore from 'soapbox/storage/kv-store'; -import { validId, isURL } from 'soapbox/utils/auth'; +import { Account, accountSchema } from 'soapbox/schemas'; +import { Application, applicationSchema } from 'soapbox/schemas/application'; +import { AuthUser, SoapboxAuth, soapboxAuthSchema } from 'soapbox/schemas/soapbox/soapbox-auth'; +import { Token, tokenSchema } from 'soapbox/schemas/token'; +import { jsonSchema } from 'soapbox/schemas/utils'; import { AUTH_APP_CREATED, AUTH_LOGGED_IN, - AUTH_APP_AUTHORIZED, AUTH_LOGGED_OUT, SWITCH_ACCOUNT, VERIFY_CREDENTIALS_SUCCESS, @@ -17,393 +18,184 @@ import { } from '../actions/auth'; import { ME_FETCH_SKIP } from '../actions/me'; -import type { AxiosError } from 'axios'; -import type { AnyAction } from 'redux'; -import type { APIEntity, Account as AccountEntity } from 'soapbox/types/entities'; +import type { UnknownAction } from 'redux'; -export const AuthAppRecord = ImmutableRecord({ - access_token: null as string | null, - client_id: null as string | null, - client_secret: null as string | null, - id: null as string | null, - name: null as string | null, - redirect_uri: null as string | null, - token_type: null as string | null, - vapid_key: null as string | null, - website: null as string | null, -}); +const STORAGE_KEY = 'soapbox:auth'; +const SESSION_KEY = 'soapbox:auth:me'; -export const AuthTokenRecord = ImmutableRecord({ - access_token: '', - account: null as string | null, - created_at: 0, - expires_in: null as number | null, - id: null as number | null, - me: null as string | null, - refresh_token: null as string | null, - scope: '', - token_type: '', -}); - -export const AuthUserRecord = ImmutableRecord({ - access_token: '', - id: '', - url: '', -}); - -export const ReducerRecord = ImmutableRecord({ - app: AuthAppRecord(), - tokens: ImmutableMap(), - users: ImmutableMap(), - me: null as string | null, -}); - -type AuthToken = ReturnType; -type AuthUser = ReturnType; -type State = ReturnType; - -const buildKey = (parts: string[]) => parts.join(':'); - -// For subdirectory support -const NAMESPACE = trim(BuildConfig.FE_SUBDIRECTORY, '/') ? `soapbox@${BuildConfig.FE_SUBDIRECTORY}` : 'soapbox'; - -const STORAGE_KEY = buildKey([NAMESPACE, 'auth']); -const SESSION_KEY = buildKey([NAMESPACE, 'auth', 'me']); - -const getSessionUser = () => { - const id = sessionStorage.getItem(SESSION_KEY); - return validId(id) ? id : undefined; -}; - -const getLocalState = () => { - const state = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - - if (!state) return undefined; - - return ReducerRecord({ - app: AuthAppRecord(state.app), - tokens: ImmutableMap(Object.entries(state.tokens).map(([key, value]) => [key, AuthTokenRecord(value as any)])), - users: ImmutableMap(Object.entries(state.users).map(([key, value]) => [key, AuthUserRecord(value as any)])), - me: state.me, - }); -}; - -const sessionUser = getSessionUser(); -export const localState = getLocalState(); fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY)!)); - -// Checks if the user has an ID and access token -const validUser = (user?: AuthUser) => { +/** Get current user's URL from session storage. */ +function getSessionUser(): string | undefined { + const value = sessionStorage.getItem(SESSION_KEY); try { - return !!(user && validId(user.id) && validId(user.access_token)); - } catch (e) { - return false; - } -}; - -// Finds the first valid user in the state -const firstValidUser = (state: State) => state.users.find(validUser); - -// For legacy purposes. IDs get upgraded to URLs further down. -const getUrlOrId = (user?: AuthUser): string | null => { - try { - const { id, url } = user!.toJS(); - return (url || id) as string; + return z.string().url().parse(value); } catch { - return null; + return undefined; } -}; +} -// If `me` doesn't match an existing user, attempt to shift it. -const maybeShiftMe = (state: State) => { - const me = state.me!; - const user = state.users.get(me); +/** Retrieve state from browser storage. */ +function getLocalState(): SoapboxAuth | undefined { + const data = localStorage.getItem(STORAGE_KEY); + const result = jsonSchema.pipe(soapboxAuthSchema).safeParse(data); - if (!validUser(user)) { - const nextUser = firstValidUser(state); - return state.set('me', getUrlOrId(nextUser)); - } else { - return state; + if (!result.success) { + return undefined; } -}; -// Set the user from the session or localStorage, whichever is valid first -const setSessionUser = (state: State) => state.update('me', me => { - const user = ImmutableList([ - state.users.get(sessionUser!)!, - state.users.get(me!)!, - ]).find(validUser); + return result.data; +} - return getUrlOrId(user); -}); +/** Serialize and save the auth into localStorage. */ +function persistAuth(auth: SoapboxAuth): void { + const value = JSON.stringify(auth); + localStorage.setItem(STORAGE_KEY, value); -// Upgrade the initial state -const migrateLegacy = (state: State) => { - if (localState) return state; - return state.withMutations(state => { - const app = AuthAppRecord(JSON.parse(localStorage.getItem('soapbox:auth:app')!)); - const user = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:user')!)) as ImmutableMap; - if (!user) return; - state.set('me', '_legacy'); // Placeholder account ID - state.set('app', app); - state.set('tokens', ImmutableMap({ - [user.get('access_token')]: AuthTokenRecord(user.set('account', '_legacy')), - })); - state.set('users', ImmutableMap({ - '_legacy': AuthUserRecord({ - id: '_legacy', - access_token: user.get('access_token'), - }), - })); - }); -}; - -const isUpgradingUrlId = (state: State) => { - const me = state.me; - const user = state.users.get(me!); - return validId(me) && user && !isURL(me); -}; - -// Checks the state and makes it valid -const sanitizeState = (state: State) => { - // Skip sanitation during ID to URL upgrade - if (isUpgradingUrlId(state)) return state; - - return state.withMutations(state => { - // Remove invalid users, ensure ID match - state.update('users', users => ( - users.filter((user, url) => ( - validUser(user) && user.get('url') === url - )) - )); - // Remove mismatched tokens - state.update('tokens', tokens => ( - tokens.filter((token, id) => ( - validId(id) && token.get('access_token') === id - )) - )); - }); -}; - -const persistAuth = (state: State) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS())); - -const persistSession = (state: State) => { - const me = state.me; - if (me && typeof me === 'string') { - sessionStorage.setItem(SESSION_KEY, me); + if (auth.me) { + sessionStorage.setItem(SESSION_KEY, auth.me); } -}; +} -const persistState = (state: State) => { - persistAuth(state); - persistSession(state); -}; +/** Hydrate the initial state, or create a new state. */ +function initialize(): SoapboxAuth { + const auth = getLocalState() || { tokens: {}, users: {} }; + auth.me = getSessionUser() || auth.me; -const initialize = (state: State) => { - return state.withMutations(state => { - maybeShiftMe(state); - setSessionUser(state); - migrateLegacy(state); - sanitizeState(state); - persistState(state); + maybeShiftMe(auth); + persistAuth(auth); + + return auth; +} + +/** Initial state of the reducer. */ +const initialState = initialize(); + +/** Import a Token into the state. */ +function importToken(auth: SoapboxAuth, token: Token): SoapboxAuth { + return produce(auth, draft => { + draft.tokens[token.access_token] = token; }); -}; +} -const initialState = initialize(ReducerRecord().merge(localState as any)); - -const importToken = (state: State, token: APIEntity) => { - return state.setIn(['tokens', token.access_token], AuthTokenRecord(token)); -}; - -// Upgrade the `_legacy` placeholder ID with a real account -const upgradeLegacyId = (state: State, account: APIEntity) => { - if (localState) return state; - return state.withMutations(state => { - state.update('me', me => me === '_legacy' ? account.url : me); - state.deleteIn(['users', '_legacy']); +/** Import Application into the state. */ +function importApplication(auth: SoapboxAuth, app: Application): SoapboxAuth { + return produce(auth, draft => { + draft.app = app; }); - // TODO: Delete `soapbox:auth:app` and `soapbox:auth:user` localStorage? - // By this point it's probably safe, but we'll leave it just in case. -}; +} -// Users are now stored by their ActivityPub ID instead of their -// primary key to support auth against multiple hosts. -const upgradeNonUrlId = (state: State, account: APIEntity) => { - const me = state.me; - if (isURL(me)) return state; +/** If the user is not set, set it to the first available user. This mutates the object. */ +function maybeShiftMe(auth: SoapboxAuth): void { + if (!auth.me || !auth.users[auth.me]) { + auth.me = Object.keys(auth.users)[0]; + } +} - return state.withMutations(state => { - state.update('me', me => me === account.id ? account.url : me); - state.deleteIn(['users', account.id]); - }); -}; - -// Returns a predicate function for filtering a mismatched user/token -const userMismatch = (token: string, account: APIEntity) => { - return (user: AuthUser, url: string) => { - const sameToken = user.get('access_token') === token; - const differentUrl = url !== account.url || user.get('url') !== account.url; - const differentId = user.get('id') !== account.id; - - return sameToken && (differentUrl || differentId); +/** Import an Account into the state as an auth user. */ +function importCredentials(auth: SoapboxAuth, accessToken: string, account: Account): SoapboxAuth { + const authUser: AuthUser = { + id: account.id, + access_token: accessToken, + url: account.url, }; -}; -const importCredentials = (state: State, token: string, account: APIEntity) => { - return state.withMutations(state => { - state.setIn(['users', account.url], AuthUserRecord({ - id: account.id, - access_token: token, - url: account.url, - })); - state.setIn(['tokens', token, 'account'], account.id); - state.setIn(['tokens', token, 'me'], account.url); - state.update('users', users => users.filterNot(userMismatch(token, account))); - state.update('me', me => me || account.url); - upgradeLegacyId(state, account); - upgradeNonUrlId(state, account); + return produce(auth, draft => { + draft.users[account.url] = authUser; + maybeShiftMe(draft); }); -}; +} -const deleteToken = (state: State, token: string) => { - return state.withMutations(state => { - state.update('tokens', tokens => tokens.delete(token)); - state.update('users', users => users.filterNot(user => user.get('access_token') === token)); - maybeShiftMe(state); - }); -}; +function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth { + return produce(auth, draft => { + delete draft.tokens[accessToken]; -const deleteUser = (state: State, account: Pick) => { - const accountUrl = account.url; - - return state.withMutations(state => { - state.update('users', users => users.delete(accountUrl)); - state.update('tokens', tokens => tokens.filterNot(token => token.get('me') === accountUrl)); - maybeShiftMe(state); - }); -}; - -const importMastodonPreload = (state: State, data: ImmutableMap) => { - return state.withMutations(state => { - const accountId = data.getIn(['meta', 'me']) as string; - const accountUrl = data.getIn(['accounts', accountId, 'url']) as string; - const accessToken = data.getIn(['meta', 'access_token']) as string; - - if (validId(accessToken) && validId(accountId) && isURL(accountUrl)) { - state.setIn(['tokens', accessToken], AuthTokenRecord({ - access_token: accessToken, - account: accountId, - me: accountUrl, - scope: 'read write follow push', - token_type: 'Bearer', - })); - - state.setIn(['users', accountUrl], AuthUserRecord({ - id: accountId, - access_token: accessToken, - url: accountUrl, - })); + for (const url in draft.users) { + if (draft.users[url].access_token === accessToken) { + delete draft.users[url]; + } } - maybeShiftMe(state); + maybeShiftMe(draft); }); -}; +} -const persistAuthAccount = (account: APIEntity) => { - if (account && account.url) { - const key = `authAccount:${account.url}`; - if (!account.pleroma) account.pleroma = {}; - KVStore.getItem(key).then((oldAccount: any) => { - const settings = oldAccount?.pleroma?.settings_store || {}; - if (!account.pleroma.settings_store) { - account.pleroma.settings_store = settings; - } - KVStore.setItem(key, account); - }) - .catch(console.error); - } -}; +function deleteUser(auth: SoapboxAuth, accountUrl: string): SoapboxAuth { + return produce(auth, draft => { + const accessToken = draft.users[accountUrl]?.access_token; -const deleteForbiddenToken = (state: State, error: AxiosError, token: string) => { + delete draft.tokens[accessToken]; + delete draft.users[accountUrl]; + + maybeShiftMe(draft); + }); +} + +function deleteForbiddenToken(auth: SoapboxAuth, error: AxiosError, token: string): SoapboxAuth { if ([401, 403].includes(error.response?.status!)) { - return deleteToken(state, token); + return deleteToken(auth, token); } else { - return state; + return auth; } -}; +} -const reducer = (state: State, action: AnyAction) => { +function reducer(state: SoapboxAuth, action: UnknownAction): SoapboxAuth { switch (action.type) { - case AUTH_APP_CREATED: - return state.set('app', AuthAppRecord(action.app)); - case AUTH_APP_AUTHORIZED: - return state.update('app', app => app.merge(action.token)); - case AUTH_LOGGED_IN: - return importToken(state, action.token); - case AUTH_LOGGED_OUT: - return deleteUser(state, action.account); - case VERIFY_CREDENTIALS_SUCCESS: - persistAuthAccount(action.account); - return importCredentials(state, action.token, action.account); - case VERIFY_CREDENTIALS_FAIL: - return deleteForbiddenToken(state, action.error, action.token); - case SWITCH_ACCOUNT: - return state.set('me', action.account.url); + case AUTH_APP_CREATED: { + const result = applicationSchema.safeParse(action.app); + return result.success ? importApplication(state, result.data) : state; + } + case AUTH_LOGGED_IN: { + const result = tokenSchema.safeParse(action.token); + return result.success ? importToken(state, result.data) : state; + } + case AUTH_LOGGED_OUT: { + const result = accountSchema.safeParse(action.account); + return result.success ? deleteUser(state, result.data.url) : state; + } + case VERIFY_CREDENTIALS_SUCCESS: { + const result = accountSchema.safeParse(action.account); + if (result.success && typeof action.token === 'string') { + return importCredentials(state, action.token, result.data); + } else { + return state; + } + } + case VERIFY_CREDENTIALS_FAIL: { + if (action.error instanceof AxiosError && typeof action.token === 'string') { + return deleteForbiddenToken(state, action.error, action.token); + } else { + return state; + } + } + case SWITCH_ACCOUNT: { + const result = accountSchema.safeParse(action.account); + if (!result.success) { + return state; + } + // Middle-click to switch profiles updates the user in the new tab but leaves the current tab alone. + if (action.background === true) { + sessionStorage.setItem(SESSION_KEY, result.data.url); + return state; + } + return { ...state, me: result.data.url }; + } case ME_FETCH_SKIP: - return state.set('me', null); - case MASTODON_PRELOAD_IMPORT: - return importMastodonPreload(state, fromJS(action.data) as ImmutableMap); + return { ...state, me: undefined }; default: return state; } -}; +} -const reload = () => location.replace('/'); - -// `me` is a user ID string -const validMe = (state: State) => { - const me = state.me; - return typeof me === 'string' && me !== '_legacy'; -}; - -// `me` has changed from one valid ID to another -const userSwitched = (oldState: State, state: State) => { - const me = state.me; - const oldMe = oldState.me; - - const stillValid = validMe(oldState) && validMe(state); - const didChange = oldMe !== me; - const userUpgradedUrl = state.users.get(me!)?.id === oldMe; - - return stillValid && didChange && !userUpgradedUrl; -}; - -const maybeReload = (oldState: State, state: State, action: AnyAction) => { - const shouldRefresh = action.type === AUTH_LOGGED_OUT && action.refresh; - const switched = userSwitched(oldState, state); - - if (switched || shouldRefresh) { - reload(); - } -}; - -export default function auth(oldState: State = initialState, action: AnyAction) { +export default function auth(oldState: SoapboxAuth = initialState, action: UnknownAction): SoapboxAuth { const state = reducer(oldState, action); - if (!state.equals(oldState)) { - // Persist the state in localStorage + // Persist the state in localStorage when it changes. + if (state !== oldState) { persistAuth(state); + } - // When middle-clicking a profile, we want to save the - // user in localStorage, but not update the reducer - if (action.background === true) { - return oldState; - } - - // Persist the session - persistSession(state); - - // Reload the page under some conditions - maybeReload(oldState, state, action); + // Reload the page when the user logs out or switches accounts. + if (action.type === AUTH_LOGGED_OUT || oldState.me !== state.me) { + location.replace('/'); } return state; diff --git a/src/schemas/soapbox/soapbox-auth.ts b/src/schemas/soapbox/soapbox-auth.ts index b80d71bba..9fa3ef798 100644 --- a/src/schemas/soapbox/soapbox-auth.ts +++ b/src/schemas/soapbox/soapbox-auth.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { applicationSchema } from 'soapbox/schemas/application'; import { tokenSchema } from 'soapbox/schemas/token'; const authUserSchema = z.object({ @@ -9,9 +10,10 @@ const authUserSchema = z.object({ }); const soapboxAuthSchema = z.object({ + app: applicationSchema.optional(), tokens: z.record(z.string(), tokenSchema), users: z.record(z.string(), authUserSchema), - me: z.string().url().optional().catch(undefined), + me: z.string().url().optional(), }); type AuthUser = z.infer; From 09f813403fde0b0d9cb67e40d1eeac7de52dbf02 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 23 Oct 2024 12:58:27 -0500 Subject: [PATCH 3/7] Actually do import the token into the auth app, fix applicationSchema and authAppSchema --- src/reducers/auth.ts | 13 +++++++++++++ src/schemas/application.ts | 11 ++++++----- src/schemas/soapbox/soapbox-auth.ts | 8 +++++++- src/schemas/token.ts | 2 ++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index d5bc456a1..b5145a340 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -15,6 +15,7 @@ import { SWITCH_ACCOUNT, VERIFY_CREDENTIALS_SUCCESS, VERIFY_CREDENTIALS_FAIL, + AUTH_APP_AUTHORIZED, } from '../actions/auth'; import { ME_FETCH_SKIP } from '../actions/me'; @@ -143,6 +144,18 @@ function reducer(state: SoapboxAuth, action: UnknownAction): SoapboxAuth { const result = applicationSchema.safeParse(action.app); return result.success ? importApplication(state, result.data) : state; } + case AUTH_APP_AUTHORIZED: { + const result = tokenSchema.safeParse(action.token); + if (result.success) { + return produce(state, draft => { + if (draft.app) { + draft.app.access_token = result.data.access_token; + } + }); + } else { + return state; + } + } case AUTH_LOGGED_IN: { const result = tokenSchema.safeParse(action.token); return result.success ? importToken(state, result.data) : state; diff --git a/src/schemas/application.ts b/src/schemas/application.ts index a94ee1066..7366789bf 100644 --- a/src/schemas/application.ts +++ b/src/schemas/application.ts @@ -1,18 +1,19 @@ import { z } from 'zod'; const applicationSchema = z.object({ - name: z.string(), + name: z.string().catch(''), website: z.string().url().nullable().catch(null), scopes: z.string().array().catch([]), redirect_uris: z.string().url().array().optional().catch(undefined), redirect_uri: z.string().url().optional().catch(undefined), + client_id: z.string().optional().catch(undefined), + client_secret: z.string().optional().catch(undefined), + client_secret_expires_at: z.number().optional().catch(0), }).transform((app) => { - const { name, website, scopes, redirect_uris, redirect_uri } = app; + const { redirect_uris, redirect_uri, ...rest } = app; return { - name, - website, - scopes, + ...rest, redirect_uris: redirect_uris || (redirect_uri ? [redirect_uri] : []), }; }); diff --git a/src/schemas/soapbox/soapbox-auth.ts b/src/schemas/soapbox/soapbox-auth.ts index 9fa3ef798..e51a8f794 100644 --- a/src/schemas/soapbox/soapbox-auth.ts +++ b/src/schemas/soapbox/soapbox-auth.ts @@ -9,8 +9,14 @@ const authUserSchema = z.object({ url: z.string().url(), }); +const authAppSchema = applicationSchema.and( + z.object({ + access_token: z.string().optional().catch(undefined), + }), +); + const soapboxAuthSchema = z.object({ - app: applicationSchema.optional(), + app: authAppSchema.optional(), tokens: z.record(z.string(), tokenSchema), users: z.record(z.string(), authUserSchema), me: z.string().url().optional(), diff --git a/src/schemas/token.ts b/src/schemas/token.ts index b32019a5a..2feaf30bd 100644 --- a/src/schemas/token.ts +++ b/src/schemas/token.ts @@ -5,6 +5,8 @@ const tokenSchema = z.object({ token_type: z.string(), scope: z.string(), created_at: z.number(), + id: z.coerce.string().optional().catch(undefined), // Pleroma (primary key) + me: z.string().url().optional().catch(undefined), // Pleroma (ActivityPub ID of user) }); type Token = z.infer; From 65c8c68e005766570322b564bfb77cccbc1ddcbc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 23 Oct 2024 13:03:24 -0500 Subject: [PATCH 4/7] Fix auth type errors in other files --- src/actions/auth.ts | 22 +- src/actions/me.test.ts | 121 ------ src/actions/me.ts | 6 +- src/components/sidebar-menu.tsx | 5 +- src/contexts/nostr-context.tsx | 2 +- src/features/auth-token-list/index.tsx | 6 +- .../ui/components/profile-dropdown.tsx | 19 +- src/hooks/useApi.ts | 2 +- src/hooks/useAuth.ts | 31 -- src/reducers/auth.test.ts | 353 ------------------ src/selectors/index.ts | 40 +- src/utils/auth.ts | 8 +- 12 files changed, 50 insertions(+), 565 deletions(-) delete mode 100644 src/actions/me.test.ts delete mode 100644 src/hooks/useAuth.ts delete mode 100644 src/reducers/auth.test.ts diff --git a/src/actions/auth.ts b/src/actions/auth.ts index e1e5fddfe..8847c6b32 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -92,8 +92,8 @@ const createAppToken = () => const app = getState().auth.app; const params = { - client_id: app.client_id!, - client_secret: app.client_secret!, + client_id: app?.client_id, + client_secret: app?.client_secret, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'client_credentials', scope: getScopes(getState()), @@ -109,8 +109,8 @@ const createUserToken = (username: string, password: string) => const app = getState().auth.app; const params = { - client_id: app.client_id!, - client_secret: app.client_secret!, + client_id: app?.client_id, + client_secret: app?.client_secret, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'password', username: username, @@ -126,8 +126,8 @@ export const otpVerify = (code: string, mfa_token: string) => (dispatch: AppDispatch, getState: () => RootState) => { const app = getState().auth.app; return api(getState, 'app').post('/oauth/mfa/challenge', { - client_id: app.client_id, - client_secret: app.client_secret, + client_id: app?.client_id, + client_secret: app?.client_secret, mfa_token: mfa_token, code: code, challenge_type: 'totp', @@ -208,12 +208,12 @@ export const logOut = (refresh = true) => if (!account) return dispatch(noOp); const params = { - client_id: state.auth.app.client_id!, - client_secret: state.auth.app.client_secret!, - token: state.auth.users.get(account.url)!.access_token, + client_id: state.auth.app?.client_id, + client_secret: state.auth.app?.client_secret, + token: state.auth.users[account.url]?.access_token, }; - return dispatch(revokeOAuthToken(params)) + return dispatch(revokeOAuthToken(params as Record)) .finally(() => { // Clear all stored cache from React Query queryClient.invalidateQueries(); @@ -246,7 +246,7 @@ export const switchAccount = (accountId: string, background = false) => export const fetchOwnAccounts = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - return state.auth.users.forEach((user) => { + return Object.values(state.auth.users).forEach((user) => { const account = selectAccount(state, user.id); if (!account) { dispatch(verifyCredentials(user.access_token, user.url)) diff --git a/src/actions/me.test.ts b/src/actions/me.test.ts deleted file mode 100644 index fe6ddd13a..000000000 --- a/src/actions/me.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { __stub } from 'soapbox/api'; -import { buildAccount } from 'soapbox/jest/factory'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; -import { AuthUserRecord, ReducerRecord } from 'soapbox/reducers/auth'; - -import { fetchMe, patchMe } from './me'; - -vi.mock('../../storage/kv-store', () => ({ - __esModule: true, - default: { - getItemOrError: vi.fn().mockReturnValue(Promise.resolve({})), - }, -})); - -let store: ReturnType; - -describe('fetchMe()', () => { - describe('without a token', () => { - beforeEach(() => { - const state = rootState; - store = mockStore(state); - }); - - it('dispatches the correct actions', async() => { - const expectedActions = [{ type: 'ME_FETCH_SKIP' }]; - await store.dispatch(fetchMe()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with a token', () => { - const accountUrl = 'accountUrl'; - const token = '123'; - - beforeEach(() => { - const state = { - ...rootState, - auth: ReducerRecord({ - me: accountUrl, - users: ImmutableMap({ - [accountUrl]: AuthUserRecord({ - 'access_token': token, - }), - }), - }), - entities: { - 'ACCOUNTS': { - store: { - [accountUrl]: buildAccount({ url: accountUrl }), - }, - lists: {}, - }, - }, - }; - - store = mockStore(state); - }); - - describe('with a successful API response', () => { - beforeEach(() => { - __stub((mock) => { - mock.onGet('/api/v1/accounts/verify_credentials').reply(200, {}); - }); - }); - - it('dispatches the correct actions', async() => { - const expectedActions = [ - { type: 'ME_FETCH_REQUEST' }, - { type: 'AUTH_ACCOUNT_REMEMBER_REQUEST', accountUrl }, - { type: 'ACCOUNTS_IMPORT', accounts: [] }, - { - type: 'AUTH_ACCOUNT_REMEMBER_SUCCESS', - account: {}, - accountUrl, - }, - { type: 'VERIFY_CREDENTIALS_REQUEST', token: '123' }, - { type: 'ACCOUNTS_IMPORT', accounts: [] }, - { type: 'VERIFY_CREDENTIALS_SUCCESS', token: '123', account: {} }, - ]; - await store.dispatch(fetchMe()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - }); -}); - -describe('patchMe()', () => { - beforeEach(() => { - const state = rootState; - store = mockStore(state); - }); - - describe('with a successful API response', () => { - beforeEach(() => { - __stub((mock) => { - mock.onPatch('/api/v1/accounts/update_credentials').reply(200, {}); - }); - }); - - it('dispatches the correct actions', async() => { - const expectedActions = [ - { type: 'ME_PATCH_REQUEST' }, - { type: 'ACCOUNTS_IMPORT', accounts: [] }, - { - type: 'ME_PATCH_SUCCESS', - me: {}, - }, - ]; - await store.dispatch(patchMe({})); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/src/actions/me.ts b/src/actions/me.ts index 7be77c52f..2933da913 100644 --- a/src/actions/me.ts +++ b/src/actions/me.ts @@ -33,11 +33,11 @@ const getMeUrl = (state: RootState) => { } }; -const getMeToken = (state: RootState) => { +function getMeToken(state: RootState): string | undefined { // Fallback for upgrading IDs to URLs const accountUrl = getMeUrl(state) || state.auth.me; - return state.auth.users.get(accountUrl!)?.access_token; -}; + return state.auth.users[accountUrl!]?.access_token; +} const fetchMe = () => (dispatch: AppDispatch, getState: () => RootState) => { diff --git a/src/components/sidebar-menu.tsx b/src/components/sidebar-menu.tsx index 67a838a28..f5ab9fcd3 100644 --- a/src/components/sidebar-menu.tsx +++ b/src/components/sidebar-menu.tsx @@ -15,8 +15,7 @@ import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbo import { useSettingsNotifications } from 'soapbox/hooks/useSettingsNotifications'; import { makeGetOtherAccounts } from 'soapbox/selectors'; -import type { List as ImmutableList } from 'immutable'; -import type { Account as AccountEntity } from 'soapbox/types/entities'; +import type { Account as AccountEntity } from 'soapbox/schemas/account'; const messages = defineMessages({ followers: { id: 'account.followers', defaultMessage: 'Followers' }, @@ -86,7 +85,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const features = useFeatures(); const me = useAppSelector((state) => state.me); const { account } = useAccount(me || undefined); - const otherAccounts: ImmutableList = useAppSelector((state) => getOtherAccounts(state)); + const otherAccounts = useAppSelector((state) => getOtherAccounts(state)); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const settings = useAppSelector((state) => getSettings(state)); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index 0e5b1a753..c9a70ed70 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -26,7 +26,7 @@ export const NostrProvider: React.FC = ({ children }) => { const [isRelayOpen, setIsRelayOpen] = useState(false); const url = instance.nostr?.relay; - const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users.get(auth.me!)?.id); + const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users[auth.me!]?.id); const signer = useMemo( () => accountPubkey ? NKeys.get(accountPubkey) ?? window.nostr : undefined, diff --git a/src/features/auth-token-list/index.tsx b/src/features/auth-token-list/index.tsx index 40ca864c5..caaf6a66f 100644 --- a/src/features/auth-token-list/index.tsx +++ b/src/features/auth-token-list/index.tsx @@ -73,9 +73,9 @@ const AuthTokenList: React.FC = () => { const dispatch = useAppDispatch(); const intl = useIntl(); const tokens = useAppSelector(state => state.security.get('tokens').reverse()); - const currentTokenId = useAppSelector(state => { - const currentToken = state.auth.tokens.valueSeq().find((token) => token.me === state.auth.me); + const currentTokenId = useAppSelector(state => { + const currentToken = Object.values(state.auth.tokens).find((token) => token.me === state.auth.me); return currentToken?.id; }); @@ -86,7 +86,7 @@ const AuthTokenList: React.FC = () => { const body = tokens ? (
{tokens.map((token) => ( - + ))}
) : ; diff --git a/src/features/ui/components/profile-dropdown.tsx b/src/features/ui/components/profile-dropdown.tsx index 4ef3e5983..80961b5ac 100644 --- a/src/features/ui/components/profile-dropdown.tsx +++ b/src/features/ui/components/profile-dropdown.tsx @@ -1,7 +1,7 @@ import { useFloating } from '@floating-ui/react'; import clsx from 'clsx'; import throttle from 'lodash/throttle'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; @@ -9,11 +9,11 @@ import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth'; import Account from 'soapbox/components/account'; import { MenuDivider } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector, useClickOutside, useFeatures } from 'soapbox/hooks'; -import { makeGetAccount } from 'soapbox/selectors'; +import { makeGetOtherAccounts } from 'soapbox/selectors'; import ThemeToggle from './theme-toggle'; -import type { Account as AccountEntity } from 'soapbox/types/entities'; +import type { Account as AccountEntity } from 'soapbox/schemas'; const messages = defineMessages({ add: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' }, @@ -34,8 +34,6 @@ type IMenuItem = { action?: (event: React.MouseEvent) => void; } -const getAccount = makeGetAccount(); - const ProfileDropdown: React.FC = ({ account, children }) => { const dispatch = useAppDispatch(); const features = useFeatures(); @@ -43,8 +41,9 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const [visible, setVisible] = useState(false); const { x, y, strategy, refs } = useFloating({ placement: 'bottom-end' }); - const authUsers = useAppSelector((state) => state.auth.users); - const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!)); + + const getOtherAccounts = useCallback(makeGetOtherAccounts(), []); + const otherAccounts = useAppSelector((state) => getOtherAccounts(state)); const handleLogOut = () => { dispatch(logOut()); @@ -71,7 +70,7 @@ const ProfileDropdown: React.FC = ({ account, children }) => { menu.push({ text: renderAccount(account), to: `/@${account.acct}` }); - otherAccounts.forEach((otherAccount: AccountEntity) => { + otherAccounts.forEach((otherAccount) => { if (otherAccount && otherAccount.id !== account.id) { menu.push({ text: renderAccount(otherAccount), @@ -98,13 +97,13 @@ const ProfileDropdown: React.FC = ({ account, children }) => { }); return menu; - }, [account, authUsers, features]); + }, [account, otherAccounts, features]); const toggleVisible = () => setVisible(!visible); useEffect(() => { fetchOwnAccountThrottled(); - }, [account, authUsers]); + }, [account, otherAccounts]); useClickOutside(refs, () => { setVisible(false); diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index b8b7dbf28..5d0baaf1b 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -9,7 +9,7 @@ import { useOwnAccount } from './useOwnAccount'; export function useApi(): MastodonClient { const { account } = useOwnAccount(); const authUserUrl = useAppSelector((state) => state.auth.me); - const accessToken = useAppSelector((state) => account ? state.auth.users.get(account.url)?.access_token : undefined); + const accessToken = useAppSelector((state) => account ? state.auth.users[account.url]?.access_token : undefined); const baseUrl = new URL(BuildConfig.BACKEND_URL || account?.url || authUserUrl || location.origin).origin; return useMemo(() => { diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts deleted file mode 100644 index acfdcefd6..000000000 --- a/src/hooks/useAuth.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo } from 'react'; - -import { SoapboxAuth, soapboxAuthSchema, AuthUser } from 'soapbox/schemas/soapbox/soapbox-auth'; -import { Token } from 'soapbox/schemas/token'; - -import { useAppSelector } from './useAppSelector'; - -export function useAuth() { - const raw = useAppSelector((state) => state.auth); - - const data = useMemo(() => { - try { - return soapboxAuthSchema.parse(raw.toJS()); - } catch { - return { tokens: {}, users: {} }; - } - }, [raw]); - - const users = useMemo(() => Object.values(data.users), []); - const tokens = useMemo(() => Object.values(data.tokens), []); - - const user = data.me ? data.users[data.me] : undefined; - - return { - users, - tokens, - accountId: user?.id, - accountUrl: user?.url, - accessToken: user?.access_token, - }; -} \ No newline at end of file diff --git a/src/reducers/auth.test.ts b/src/reducers/auth.test.ts deleted file mode 100644 index fbee6005f..000000000 --- a/src/reducers/auth.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { - AUTH_APP_CREATED, - AUTH_LOGGED_IN, - AUTH_LOGGED_OUT, - VERIFY_CREDENTIALS_SUCCESS, - VERIFY_CREDENTIALS_FAIL, - SWITCH_ACCOUNT, -} from 'soapbox/actions/auth'; -import { ME_FETCH_SKIP } from 'soapbox/actions/me'; -import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload'; -import { buildAccount } from 'soapbox/jest/factory'; - -import reducer, { AuthAppRecord, AuthTokenRecord, AuthUserRecord, ReducerRecord } from './auth'; - -describe('auth reducer', () => { - it('should return the initial state', () => { - expect(reducer(undefined, {} as any).toJS()).toMatchObject({ - app: {}, - users: {}, - tokens: {}, - me: null, - }); - }); - - describe('AUTH_APP_CREATED', () => { - it('should copy in the app', () => { - const token = { token_type: 'Bearer', access_token: 'ABCDEFG' }; - const action = { type: AUTH_APP_CREATED, app: token }; - - const result = reducer(undefined, action); - const expected = AuthAppRecord(token); - - expect(result.app).toEqual(expected); - }); - }); - - describe('AUTH_LOGGED_IN', () => { - it('should import the token', () => { - const token = { token_type: 'Bearer', access_token: 'ABCDEFG' }; - const action = { type: AUTH_LOGGED_IN, token }; - - const result = reducer(undefined, action); - const expected = ImmutableMap({ 'ABCDEFG': AuthTokenRecord(token) }); - - expect(result.tokens).toEqual(expected); - }); - - it('should merge the token with existing state', () => { - const state = ReducerRecord({ - tokens: ImmutableMap({ 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }) }), - }); - - const expected = ImmutableMap({ - 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }), - 'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }), - }); - - const action = { - type: AUTH_LOGGED_IN, - token: { token_type: 'Bearer', access_token: 'HIJKLMN' }, - }; - - const result = reducer(state, action); - expect(result.tokens).toEqual(expected); - }); - }); - - describe('AUTH_LOGGED_OUT', () => { - it('deletes the user', () => { - const action = { - type: AUTH_LOGGED_OUT, - account: buildAccount({ url: 'https://gleasonator.com/users/alex' }), - }; - - const state = ReducerRecord({ - users: ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const expected = ImmutableMap({ - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }); - - const result = reducer(state, action); - expect(result.users).toEqual(expected); - }); - - it('sets `me` to the next available user', () => { - const state = ReducerRecord({ - me: 'https://gleasonator.com/users/alex', - users: ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const action = { - type: AUTH_LOGGED_OUT, - account: buildAccount({ url: 'https://gleasonator.com/users/alex' }), - }; - - const result = reducer(state, action); - expect(result.me).toEqual('https://gleasonator.com/users/benis'); - }); - }); - - describe('VERIFY_CREDENTIALS_SUCCESS', () => { - it('should import the user', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const expected = ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - }); - - const result = reducer(undefined, action); - expect(result.users).toEqual(expected); - }); - - it('should set the account in the token', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const state = ReducerRecord({ - tokens: ImmutableMap({ 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }) }), - }); - - const expected = { - 'ABCDEFG': { - token_type: 'Bearer', - access_token: 'ABCDEFG', - account: '1234', - me: 'https://gleasonator.com/users/alex', - }, - }; - - const result = reducer(state, action); - expect(result.tokens.toJS()).toMatchObject(expected); - }); - - it('sets `me` to the account if unset', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const result = reducer(undefined, action); - expect(result.me).toEqual('https://gleasonator.com/users/alex'); - }); - - it('leaves `me` alone if already set', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const state = ReducerRecord({ me: 'https://gleasonator.com/users/benis' }); - - const result = reducer(state, action); - expect(result.me).toEqual('https://gleasonator.com/users/benis'); - }); - - it('deletes mismatched users', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const state = ReducerRecord({ - users: ImmutableMap({ - 'https://gleasonator.com/users/mk': AuthUserRecord({ id: '4567', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/mk' }), - 'https://gleasonator.com/users/curtis': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/curtis' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const expected = ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }); - - const result = reducer(state, action); - expect(result.users).toEqual(expected); - }); - - it('upgrades from an ID to a URL', () => { - const action = { - type: VERIFY_CREDENTIALS_SUCCESS, - token: 'ABCDEFG', - account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, - }; - - const state = ReducerRecord({ - me: '1234', - users: ImmutableMap({ - '1234': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG' }), - '5432': AuthUserRecord({ id: '5432', access_token: 'HIJKLMN' }), - }), - tokens: ImmutableMap({ - 'ABCDEFG': AuthTokenRecord({ access_token: 'ABCDEFG', account: '1234' }), - }), - }); - - const expected = { - me: 'https://gleasonator.com/users/alex', - users: { - 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, - '5432': { id: '5432', access_token: 'HIJKLMN' }, - }, - tokens: { - 'ABCDEFG': { access_token: 'ABCDEFG', account: '1234', me: 'https://gleasonator.com/users/alex' }, - }, - }; - - const result = reducer(state, action); - expect(result.toJS()).toMatchObject(expected); - }); - }); - - describe('VERIFY_CREDENTIALS_FAIL', () => { - it('should delete the failed token if it 403\'d', () => { - const state = ReducerRecord({ - tokens: ImmutableMap({ - 'ABCDEFG': AuthTokenRecord({ token_type: 'Bearer', access_token: 'ABCDEFG' }), - 'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }), - }), - }); - - const expected = ImmutableMap({ - 'HIJKLMN': AuthTokenRecord({ token_type: 'Bearer', access_token: 'HIJKLMN' }), - }); - - const action = { - type: VERIFY_CREDENTIALS_FAIL, - token: 'ABCDEFG', - error: { response: { status: 403 } }, - }; - - const result = reducer(state, action); - expect(result.tokens).toEqual(expected); - }); - - it('should delete any users associated with the failed token', () => { - const state = ReducerRecord({ - users: ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const expected = ImmutableMap({ - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }); - - const action = { - type: VERIFY_CREDENTIALS_FAIL, - token: 'ABCDEFG', - error: { response: { status: 403 } }, - }; - - const result = reducer(state, action); - expect(result.users).toEqual(expected); - }); - - it('should reassign `me` to the next in line', () => { - const state = ReducerRecord({ - me: 'https://gleasonator.com/users/alex', - users: ImmutableMap({ - 'https://gleasonator.com/users/alex': AuthUserRecord({ id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }), - 'https://gleasonator.com/users/benis': AuthUserRecord({ id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }), - }), - }); - - const action = { - type: VERIFY_CREDENTIALS_FAIL, - token: 'ABCDEFG', - error: { response: { status: 403 } }, - }; - - const result = reducer(state, action); - expect(result.me).toEqual('https://gleasonator.com/users/benis'); - }); - }); - - describe('SWITCH_ACCOUNT', () => { - it('sets the value of `me`', () => { - const action = { - type: SWITCH_ACCOUNT, - account: { url: 'https://gleasonator.com/users/benis' }, - }; - - const result = reducer(undefined, action); - expect(result.me).toEqual('https://gleasonator.com/users/benis'); - }); - }); - - describe('ME_FETCH_SKIP', () => { - it('sets `me` to null', () => { - const state = ReducerRecord({ me: 'https://gleasonator.com/users/alex' }); - const action = { type: ME_FETCH_SKIP }; - const result = reducer(state, action); - expect(result.me).toEqual(null); - }); - }); - - describe('MASTODON_PRELOAD_IMPORT', () => { - it('imports the user and token', async () => { - const data = await import('soapbox/__fixtures__/mastodon_initial_state.json'); - - const action = { - type: MASTODON_PRELOAD_IMPORT, - data, - }; - - const expected = { - me: 'https://mastodon.social/@benis911', - app: {}, - users: { - 'https://mastodon.social/@benis911': { - id: '106801667066418367', - access_token: 'Nh15V9JWyY5Fshf2OJ_feNvOIkTV7YGVfEJFr0Y0D6Q', - url: 'https://mastodon.social/@benis911', - }, - }, - tokens: { - 'Nh15V9JWyY5Fshf2OJ_feNvOIkTV7YGVfEJFr0Y0D6Q': { - access_token: 'Nh15V9JWyY5Fshf2OJ_feNvOIkTV7YGVfEJFr0Y0D6Q', - account: '106801667066418367', - me: 'https://mastodon.social/@benis911', - scope: 'read write follow push', - token_type: 'Bearer', - }, - }, - }; - - const result = reducer(undefined, action); - expect(result.toJS()).toMatchObject(expected); - }); - }); -}); diff --git a/src/selectors/index.ts b/src/selectors/index.ts index 6bd738e0e..a5d3076cc 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -11,7 +11,6 @@ import { getSettings } from 'soapbox/actions/settings'; import { Entities } from 'soapbox/entity-store/entities'; import { type MRFSimple } from 'soapbox/schemas/pleroma'; import { getDomain } from 'soapbox/utils/accounts'; -import { validId } from 'soapbox/utils/auth'; import ConfigDB from 'soapbox/utils/config-db'; import { getFeatures } from 'soapbox/utils/features'; import { shouldFilter } from 'soapbox/utils/timelines'; @@ -261,34 +260,27 @@ export const makeGetReport = () => { ); }; -const getAuthUserIds = createSelector([ - (state: RootState) => state.auth.users, -], authUsers => { - return authUsers.reduce((ids: ImmutableOrderedSet, authUser) => { - try { - const id = authUser.id; - return validId(id) ? ids.add(id) : ids; - } catch { - return ids; - } - }, ImmutableOrderedSet()); -}); - -export const makeGetOtherAccounts = () => { +export function makeGetOtherAccounts() { return createSelector([ (state: RootState) => state.entities[Entities.ACCOUNTS]?.store as EntityStore, - getAuthUserIds, + (state: RootState) => state.auth.users, (state: RootState) => state.me, ], - (accounts, authUserIds, me) => { - return authUserIds - .reduce((list: ImmutableList, id: string) => { - if (id === me) return list; - const account = accounts[id]; - return account ? list.push(account) : list; - }, ImmutableList()); + (store, authUsers, me): AccountSchema[] => { + const accountIds = Object.values(authUsers).map((authUser) => authUser.id); + + return accountIds.reduce((accounts, id: string) => { + if (id === me) return accounts; + + const account = store[id]; + if (account) { + accounts.push(account); + } + + return accounts; + }, []); }); -}; +} const getSimplePolicy = createSelector([ (state: RootState) => state.admin.configs, diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 73757fb80..34799cae5 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -30,13 +30,13 @@ export const isLoggedIn = (getState: () => RootState) => { return validId(getState().me); }; -export const getAppToken = (state: RootState) => state.auth.app.access_token as string; +export const getAppToken = (state: RootState) => state.auth.app?.access_token; export const getUserToken = (state: RootState, accountId?: string | false | null) => { if (!accountId) return; const accountUrl = selectAccount(state, accountId)?.url; if (!accountUrl) return; - return state.auth.users.get(accountUrl)?.access_token; + return state.auth.users[accountUrl]?.access_token; }; export const getAccessToken = (state: RootState) => { @@ -48,7 +48,7 @@ export const getAuthUserId = (state: RootState) => { const me = state.auth.me; return ImmutableList([ - state.auth.users.get(me!)?.id, + state.auth.users[me!]?.id, me, ].filter(id => id)).find(validId); }; @@ -57,7 +57,7 @@ export const getAuthUserUrl = (state: RootState) => { const me = state.auth.me; return ImmutableList([ - state.auth.users.get(me!)?.url, + state.auth.users[me!]?.url, me, ].filter(url => url)).find(isURL); }; From 9d07c85f918f18bd7c567df2102eba1435ce134b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 23 Oct 2024 13:06:05 -0500 Subject: [PATCH 5/7] Fix the page refreshing on login --- src/reducers/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index b5145a340..64934a665 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -207,7 +207,7 @@ export default function auth(oldState: SoapboxAuth = initialState, action: Unkno } // Reload the page when the user logs out or switches accounts. - if (action.type === AUTH_LOGGED_OUT || oldState.me !== state.me) { + if (action.type === AUTH_LOGGED_OUT || (oldState.me && (oldState.me !== state.me))) { location.replace('/'); } From 74be76d65bef8494986dc48ba3aa075900b022fe Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 23 Oct 2024 18:34:06 -0300 Subject: [PATCH 6/7] fix: set hasMore trending statuses inside its own if statement --- src/features/compose/components/search-results.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/compose/components/search-results.tsx b/src/features/compose/components/search-results.tsx index edf6e859d..87e85fd1f 100644 --- a/src/features/compose/components/search-results.tsx +++ b/src/features/compose/components/search-results.tsx @@ -160,6 +160,7 @@ const SearchResults = () => { )); resultsIds = results.statuses; } else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) { + hasMore = !!nextTrendingStatuses; searchResults = trendingStatuses.map((statusId: string) => ( // @ts-ignore { scrollKey={`${selectedFilter}:${value}`} isLoading={submitted && !loaded} showLoading={submitted && !loaded && searchResults?.isEmpty()} - hasMore={(!!nextTrendingStatuses) || hasMore} + hasMore={hasMore} onLoadMore={handleLoadMore} placeholderComponent={placeholderComponent} placeholderCount={20} From 4c462355a969b01f14e980927811cbf1060f74d2 Mon Sep 17 00:00:00 2001 From: danidfra Date: Thu, 24 Oct 2024 14:45:21 -0300 Subject: [PATCH 7/7] Implement video feature using only tailwind --- src/features/video/index.tsx | 118 ++++++++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 31 deletions(-) diff --git a/src/features/video/index.tsx b/src/features/video/index.tsx index 27efd8a2c..028c1188a 100644 --- a/src/features/video/index.tsx +++ b/src/features/video/index.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from import { defineMessages, useIntl } from 'react-intl'; import Blurhash from 'soapbox/components/blurhash'; -import Icon from 'soapbox/components/icon'; +import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media-aspect-ratio'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; @@ -138,11 +138,13 @@ const Video: React.FC = ({ const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(0.5); + const [preVolume, setPreVolume] = useState(0); const [paused, setPaused] = useState(true); const [dragging, setDragging] = useState(false); const [containerWidth, setContainerWidth] = useState(width); const [fullscreen, setFullscreen] = useState(false); const [hovered, setHovered] = useState(false); + const [seekHovered, setSeekHovered] = useState(false); const [muted, setMuted] = useState(false); const [buffer, setBuffer] = useState(0); @@ -387,12 +389,28 @@ const Video: React.FC = ({ const handleMouseLeave = () => { setHovered(false); }; + const handleSeekEnter = () => { + setSeekHovered(true); + }; + + const handleSeekLeave = () => { + setSeekHovered(false); + }; const toggleMute = () => { if (video.current) { const muted = !video.current.muted; setMuted(!muted); video.current.muted = muted; + + if (muted) { + setPreVolume(video.current.volume); + video.current.volume = 0; + setVolume(0); + } else { + video.current.volume = preVolume; + setVolume(preVolume); + } } }; @@ -463,17 +481,15 @@ const Video: React.FC = ({ return (
{!fullscreen && ( - + )}