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 7a028afe2..c6bf88edb 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/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} 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/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 && ( - + )}