diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..527dfacad --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss", + "stylelint.vscode-stylelint", + "wix.vscode-import-cost" + ] +} diff --git a/README.md b/README.md index 0422c88d5..1027957d1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ It's not necessary to restart the Pleroma service. To remove Soapbox FE and revert to the default pleroma-fe, simply `rm /opt/pleroma/instance/static/index.html` (you can delete other stuff in there too, but be careful not to delete your own HTML files). +## :elephant: Deploy on Mastodon + +See [Installing Soapbox over Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/). + ## How does it work? Soapbox FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript. @@ -38,7 +42,23 @@ Soapbox FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Si It has a single HTML file, `index.html`, responsible only for loading the required JavaScript and CSS. It interacts with the backend through [XMLHttpRequest (XHR)](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest). -It incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/) used by Pleroma and Mastodon, but requires many [Pleroma-specific features](https://docs.pleroma.social/backend/development/API/differences_in_mastoapi_responses/) in order to function. +Here is a simplified example with Nginx: + +```nginx +location /api { + proxy_pass http://backend; +} + +location / { + root /opt/soapbox; + try_files $uri index.html; +} +``` + +(See [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/installation/mastodon.conf) for a full example.) + +Soapbox incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/), [Pleroma API](https://api.pleroma.social/), and more. +It detects features supported by the backend to provide the right experience for the backend. # Running locally @@ -65,8 +85,9 @@ yarn dev It will serve at `http://localhost:3036` by default. -It will proxy requests to the backend for you. -For Pleroma running on `localhost:4000` (the default) no other changes are required, just start a local Pleroma server and it should begin working. +You should see an input box - just enter the domain name of your instance to log in. + +Tip: you can even enter a local instance like `http://localhost:3000`! ### Troubleshooting: `ERROR: NODE_ENV must be set` @@ -79,26 +100,10 @@ cp .env.example .env And ensure that it contains `NODE_ENV=development`. Try again. -## Developing against a live backend - -You can also run Soapbox FE locally with a live production server as the backend. - -> **Note:** Whether or not this works depends on your production server. It does not seem to work with Cloudflare or VanwaNet. - -To do so, just copy the env file: +### Troubleshooting: it's not working! -```sh -cp .env.example .env -``` - -And edit `.env`, setting the configuration like this: - -```sh -BACKEND_URL="https://pleroma.example.com" -PROXY_HTTPS_INSECURE=true -``` - -You will need to restart the local development server for the changes to take effect. +Run `node -V` and compare your Node.js version with the version in [`.tool-versions`](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/.tool-versions). +If they don't match, try installing [asdf](https://asdf-vm.com/). ## Local Dev Configuration @@ -165,28 +170,26 @@ NODE_ENV=development # Contributing -We welcome contributions to this project. To contribute, first review the [Contributing doc](docs/contributing.md) - -Additional supporting documents include: -* [Soapbox History](docs/history.md) -* [Redux Store Map](docs/history.md) +We welcome contributions to this project. +To contribute, see [Contributing to Soapbox](docs/contributing.md). # Customization -Soapbox supports customization of the user interface, to allow per instance branding and other features. Current customization features include: - -* Instance name -* Site logo -* Favicon -* About page -* Terms of Service page -* Privacy Policy page -* Copyright Policy (DMCA) page -* Promo panel list items, e.g. blog site link -* Soapbox extensions, e.g. Patron module -* Default settings, e.g. default theme - -Customization details can be found in the [Customization doc](docs/customization.md) +Soapbox supports customization of the user interface, to allow per-instance branding and other features. +Some examples include: + +- Instance name +- Site logo +- Favicon +- About page +- Terms of Service page +- Privacy Policy page +- Copyright Policy (DMCA) page +- Promo panel list items, e.g. blog site link +- Soapbox extensions, e.g. Patron module +- Default settings, e.g. default theme + +More details can be found in [Customizing Soapbox](docs/customization.md). # License & Credits diff --git a/app/soapbox/__fixtures__/announcements.json b/app/soapbox/__fixtures__/announcements.json new file mode 100644 index 000000000..20e1960d0 --- /dev/null +++ b/app/soapbox/__fixtures__/announcements.json @@ -0,0 +1,44 @@ +[ + { + "id": "1", + "content": "

Updated to Soapbox v3.

", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-06-15T18:47:14.190Z", + "updated_at": "2022-06-15T18:47:18.339Z", + "read": true, + "mentions": [], + "statuses": [], + "tags": [], + "emojis": [], + "reactions": [ + { + "name": "📈", + "count": 476, + "me": true + } + ] + }, + { + "id": "2", + "content": "

Rolled back to Soapbox v2 for now.

", + "starts_at": null, + "ends_at": null, + "all_day": false, + "published_at": "2022-07-13T11:11:50.628Z", + "updated_at": "2022-07-13T11:11:50.628Z", + "read": true, + "mentions": [], + "statuses": [], + "tags": [], + "emojis": [], + "reactions": [ + { + "name": "📉", + "count": 420, + "me": false + } + ] + } +] \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/announcements.test.ts b/app/soapbox/actions/__tests__/announcements.test.ts new file mode 100644 index 000000000..978311585 --- /dev/null +++ b/app/soapbox/actions/__tests__/announcements.test.ts @@ -0,0 +1,113 @@ +import { List as ImmutableList } from 'immutable'; + +import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements'; +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { normalizeAnnouncement, normalizeInstance } from 'soapbox/normalizers'; + +import type { APIEntity } from 'soapbox/types/entities'; + +const announcements = require('soapbox/__fixtures__/announcements.json'); + +describe('fetchAnnouncements()', () => { + describe('with a successful API request', () => { + it('should fetch announcements from the API', async() => { + const state = rootState + .set('instance', normalizeInstance({ version: '3.5.3' })); + const store = mockStore(state); + + __stub((mock) => { + mock.onGet('/api/v1/announcements').reply(200, announcements); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_FETCH_REQUEST', skipLoading: true }, + { type: 'ANNOUNCEMENTS_FETCH_SUCCESS', announcements, skipLoading: true }, + { type: 'POLLS_IMPORT', polls: [] }, + { type: 'ACCOUNTS_IMPORT', accounts: [] }, + { type: 'STATUSES_IMPORT', statuses: [], expandSpoilers: false }, + ]; + await store.dispatch(fetchAnnouncements()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('dismissAnnouncement', () => { + describe('with a successful API request', () => { + it('should mark announcement as dismissed', async() => { + const store = mockStore(rootState); + + __stub((mock) => { + mock.onPost('/api/v1/announcements/1/dismiss').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_DISMISS_REQUEST', id: '1' }, + { type: 'ANNOUNCEMENTS_DISMISS_SUCCESS', id: '1' }, + ]; + await store.dispatch(dismissAnnouncement('1')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('addReaction', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState + .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)))) + .setIn(['announcements', 'isLoading'], false); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + it('should add reaction to a post', async() => { + __stub((mock) => { + mock.onPut('/api/v1/announcements/2/reactions/📉').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_REACTION_ADD_REQUEST', id: '2', name: '📉', skipLoading: true }, + { type: 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS', id: '2', name: '📉', skipLoading: true }, + ]; + await store.dispatch(addReaction('2', '📉')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); + +describe('removeReaction', () => { + let store: ReturnType; + + beforeEach(() => { + const state = rootState + .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)))) + .setIn(['announcements', 'isLoading'], false); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + it('should remove reaction from a post', async() => { + __stub((mock) => { + mock.onDelete('/api/v1/announcements/2/reactions/📉').reply(200); + }); + + const expectedActions = [ + { type: 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST', id: '2', name: '📉', skipLoading: true }, + { type: 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS', id: '2', name: '📉', skipLoading: true }, + ]; + await store.dispatch(removeReaction('2', '📉')); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); diff --git a/app/soapbox/actions/__tests__/me.test.ts b/app/soapbox/actions/__tests__/me.test.ts new file mode 100644 index 000000000..6d37fc68c --- /dev/null +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -0,0 +1,115 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; + +import { + fetchMe, patchMe, +} from '../me'; + +jest.mock('../../storage/kv_store', () => ({ + __esModule: true, + default: { + getItemOrError: jest.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 + .set('auth', ImmutableMap({ + me: accountUrl, + users: ImmutableMap({ + [accountUrl]: ImmutableMap({ + 'access_token': token, + }), + }), + })) + .set('accounts', ImmutableMap({ + [accountUrl]: { + url: accountUrl, + }, + }) as any); + 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); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/actions/__tests__/suggestions.test.ts b/app/soapbox/actions/__tests__/suggestions.test.ts index 3c8d0c95a..a153208a7 100644 --- a/app/soapbox/actions/__tests__/suggestions.test.ts +++ b/app/soapbox/actions/__tests__/suggestions.test.ts @@ -57,6 +57,7 @@ describe('fetchSuggestions()', () => { avatar_static: response[0].account_avatar, id: response[0].account_id, note: response[0].note, + should_refetch: true, verified: response[0].verified, display_name: response[0].display_name, }], diff --git a/app/soapbox/actions/announcements.ts b/app/soapbox/actions/announcements.ts new file mode 100644 index 000000000..410de3cd9 --- /dev/null +++ b/app/soapbox/actions/announcements.ts @@ -0,0 +1,197 @@ +import api from 'soapbox/api'; +import { getFeatures } from 'soapbox/utils/features'; + +import { importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; +export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; +export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; +export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; +export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; + +export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; +export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; +export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; + +export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; +export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; + +export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; +export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; + +export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; + +export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; + +const noOp = () => {}; + +export const fetchAnnouncements = (done = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance } = getState(); + const features = getFeatures(instance); + + if (!features.announcements) return null; + + dispatch(fetchAnnouncementsRequest()); + + return api(getState).get('/api/v1/announcements').then(response => { + dispatch(fetchAnnouncementsSuccess(response.data)); + dispatch(importFetchedStatuses(response.data.map(({ statuses }: APIEntity) => statuses))); + }).catch(error => { + dispatch(fetchAnnouncementsFail(error)); + }).finally(() => { + done(); + }); + }; + +export const fetchAnnouncementsRequest = () => ({ + type: ANNOUNCEMENTS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchAnnouncementsSuccess = (announcements: APIEntity) => ({ + type: ANNOUNCEMENTS_FETCH_SUCCESS, + announcements, + skipLoading: true, +}); + +export const fetchAnnouncementsFail = (error: AxiosError) => ({ + type: ANNOUNCEMENTS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const updateAnnouncements = (announcement: APIEntity) => ({ + type: ANNOUNCEMENTS_UPDATE, + announcement: announcement, +}); + +export const dismissAnnouncement = (announcementId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(dismissAnnouncementRequest(announcementId)); + + return api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { + dispatch(dismissAnnouncementSuccess(announcementId)); + }).catch(error => { + dispatch(dismissAnnouncementFail(announcementId, error)); + }); + }; + +export const dismissAnnouncementRequest = (announcementId: string) => ({ + type: ANNOUNCEMENTS_DISMISS_REQUEST, + id: announcementId, +}); + +export const dismissAnnouncementSuccess = (announcementId: string) => ({ + type: ANNOUNCEMENTS_DISMISS_SUCCESS, + id: announcementId, +}); + +export const dismissAnnouncementFail = (announcementId: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_DISMISS_FAIL, + id: announcementId, + error, +}); + +export const addReaction = (announcementId: string, name: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const announcement = getState().announcements.items.find(x => x.get('id') === announcementId); + + let alreadyAdded = false; + + if (announcement) { + const reaction = announcement.reactions.find(x => x.name === name); + + if (reaction && reaction.me) { + alreadyAdded = true; + } + } + + if (!alreadyAdded) { + dispatch(addReactionRequest(announcementId, name, alreadyAdded)); + } + + return api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(announcementId, name, err)); + } + }); + }; + +export const addReactionRequest = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionSuccess = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionFail = (announcementId: string, name: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const removeReaction = (announcementId: string, name: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(removeReactionRequest(announcementId, name)); + + return api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(removeReactionSuccess(announcementId, name)); + }).catch(err => { + dispatch(removeReactionFail(announcementId, name, err)); + }); + }; + +export const removeReactionRequest = (announcementId: string, name: string) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionSuccess = (announcementId: string, name: string) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionFail = (announcementId: string, name: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const updateReaction = (reaction: APIEntity) => ({ + type: ANNOUNCEMENTS_REACTION_UPDATE, + reaction, +}); + +export const toggleShowAnnouncements = () => ({ + type: ANNOUNCEMENTS_TOGGLE_SHOW, +}); + +export const deleteAnnouncement = (id: string) => ({ + type: ANNOUNCEMENTS_DELETE, + id, +}); diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index e21974116..d3252e7fb 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -207,9 +207,19 @@ export const loadCredentials = (token: string, accountUrl: string) => }) .catch(() => dispatch(verifyCredentials(token, accountUrl))); +/** Trim the username and strip the leading @. */ +const normalizeUsername = (username: string): string => { + const trimmed = username.trim(); + if (trimmed[0] === '@') { + return trimmed.slice(1); + } else { + return trimmed; + } +}; + export const logIn = (username: string, password: string) => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { - return dispatch(createUserToken(username, password)); + return dispatch(createUserToken(normalizeUsername(username), password)); }).catch((error: AxiosError) => { if ((error.response?.data as any).error === 'mfa_required') { // If MFA is required, throw the error and handle it in the component. diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index 61b4e9b63..c0f79c6b8 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -2,6 +2,7 @@ import { defineMessages } from 'react-intl'; import snackbar from 'soapbox/actions/snackbar'; import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; import api from '../api'; @@ -28,6 +29,12 @@ const fetchFilters = () => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.filters) return; + dispatch({ type: FILTERS_FETCH_REQUEST, skipLoading: true, diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index 87c6cca85..20041180b 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -40,12 +40,17 @@ export function importFetchedAccount(account: APIEntity) { return importFetchedAccounts([account]); } -export function importFetchedAccounts(accounts: APIEntity[]) { +export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) { + const { should_refetch } = args; const normalAccounts: APIEntity[] = []; const processAccount = (account: APIEntity) => { if (!account.id) return; + if (should_refetch) { + account.should_refetch = true; + } + normalAccounts.push(account); if (account.moved) { diff --git a/app/soapbox/actions/me.ts b/app/soapbox/actions/me.ts index 074f5d4cc..e76399d21 100644 --- a/app/soapbox/actions/me.ts +++ b/app/soapbox/actions/me.ts @@ -41,13 +41,13 @@ const fetchMe = () => const accountUrl = getMeUrl(state); if (!token) { - dispatch({ type: ME_FETCH_SKIP }); return noOp(); + dispatch({ type: ME_FETCH_SKIP }); + return noOp(); } dispatch(fetchMeRequest()); - return dispatch(loadCredentials(token, accountUrl)).catch(error => { - dispatch(fetchMeFail(error)); - }); + return dispatch(loadCredentials(token, accountUrl)) + .catch(error => dispatch(fetchMeFail(error))); }; /** Update the auth account in IndexedDB for Mastodon, etc. */ diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index c47667197..c77daa6ac 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -3,6 +3,12 @@ import messages from 'soapbox/locales/messages'; import { connectStream } from '../stream'; +import { + deleteAnnouncement, + fetchAnnouncements, + updateAnnouncements, + updateReaction as updateAnnouncementsReaction, +} from './announcements'; import { updateConversations } from './conversations'; import { fetchFilters } from './filters'; import { updateNotificationsQueue, expandNotifications } from './notifications'; @@ -100,13 +106,24 @@ const connectTimelineStream = ( case 'pleroma:follow_relationships_update': dispatch(updateFollowRelationships(JSON.parse(data.payload))); break; + case 'announcement': + dispatch(updateAnnouncements(JSON.parse(data.payload))); + break; + case 'announcement.reaction': + dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); + break; + case 'announcement.delete': + dispatch(deleteAnnouncement(data.payload)); + break; } }, }; }); const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => void) => - dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({} as any, done)))); + dispatch(expandHomeTimeline({}, () => + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); diff --git a/app/soapbox/actions/suggestions.ts b/app/soapbox/actions/suggestions.ts index 86743f5aa..600dffb7d 100644 --- a/app/soapbox/actions/suggestions.ts +++ b/app/soapbox/actions/suggestions.ts @@ -91,7 +91,7 @@ const fetchTruthSuggestions = (params: Record = {}) => const next = getLinks(response).refs.find(link => link.rel === 'next')?.uri; const accounts = suggestedProfiles.map(mapSuggestedProfileToAccount); - dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedAccounts(accounts, { should_refetch: true })); dispatch({ type: SUGGESTIONS_TRUTH_FETCH_SUCCESS, suggestions: suggestedProfiles, next, skipLoading: true }); return suggestedProfiles; }) diff --git a/app/soapbox/components/animated-number.tsx b/app/soapbox/components/animated-number.tsx new file mode 100644 index 000000000..0f6908fde --- /dev/null +++ b/app/soapbox/components/animated-number.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { FormattedNumber } from 'react-intl'; +import { TransitionMotion, spring } from 'react-motion'; + +import { useSettings } from 'soapbox/hooks'; + +const obfuscatedCount = (count: number) => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +interface IAnimatedNumber { + value: number; + obfuscate?: boolean; +} + +const AnimatedNumber: React.FC = ({ value, obfuscate }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const [direction, setDirection] = useState(1); + const [displayedValue, setDisplayedValue] = useState(value); + + useEffect(() => { + if (displayedValue !== undefined) { + if (value > displayedValue) setDirection(1); + else if (value < displayedValue) setDirection(-1); + } + setDisplayedValue(value); + }, [value]); + + const willEnter = () => ({ y: -1 * direction }); + + const willLeave = () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }); + + if (reduceMotion) { + return obfuscate ? <>{obfuscatedCount(displayedValue)} : ; + } + + const styles = [{ + key: `${displayedValue}`, + data: displayedValue, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + + {items => ( + + {items.map(({ key, data, style }) => ( + 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : } + ))} + + )} + + ); +}; + +export default AnimatedNumber; \ No newline at end of file diff --git a/app/soapbox/components/announcements/announcement-content.tsx b/app/soapbox/components/announcements/announcement-content.tsx new file mode 100644 index 000000000..f4265d1fd --- /dev/null +++ b/app/soapbox/components/announcements/announcement-content.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; + +import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; + +interface IAnnouncementContent { + announcement: AnnouncementEntity; +} + +const AnnouncementContent: React.FC = ({ announcement }) => { + const history = useHistory(); + + const node = useRef(null); + + useEffect(() => { + updateLinks(); + }); + + const onMentionClick = (mention: MentionEntity, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/@${mention.acct}`); + } + }; + + const onHashtagClick = (hashtag: string, e: MouseEvent) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/tags/${hashtag}`); + } + }; + + const onStatusClick = (status: string, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + history.push(status); + } + }; + + const updateLinks = () => { + if (!node.current) return; + + const links = node.current.querySelectorAll('a'); + + links.forEach(link => { + // Skip already processed + if (link.classList.contains('status-link')) return; + + // Add attributes + link.classList.add('status-link'); + link.setAttribute('rel', 'nofollow noopener'); + link.setAttribute('target', '_blank'); + + const mention = announcement.mentions.find(mention => link.href === `${mention.url}`); + + // Add event listeners on mentions, hashtags and statuses + if (mention) { + link.addEventListener('click', onMentionClick.bind(link, mention), false); + link.setAttribute('title', mention.acct); + } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { + link.addEventListener('click', onHashtagClick.bind(link, link.text), false); + } else { + const status = announcement.statuses.get(link.href); + if (status) { + link.addEventListener('click', onStatusClick.bind(this, status), false); + } + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + } + }); + }; + + return ( +
+ ); +}; + +export default AnnouncementContent; diff --git a/app/soapbox/components/announcements/announcement.tsx b/app/soapbox/components/announcements/announcement.tsx new file mode 100644 index 000000000..f6344f7b5 --- /dev/null +++ b/app/soapbox/components/announcements/announcement.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { FormattedDate } from 'react-intl'; + +import { Stack, Text } from 'soapbox/components/ui'; +import { useFeatures } from 'soapbox/hooks'; + +import AnnouncementContent from './announcement-content'; +import ReactionsBar from './reactions-bar'; + +import type { Map as ImmutableMap } from 'immutable'; +import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities'; + +interface IAnnouncement { + announcement: AnnouncementEntity; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; + emojiMap: ImmutableMap>; +} + +const Announcement: React.FC = ({ announcement, addReaction, removeReaction, emojiMap }) => { + const features = useFeatures(); + + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); + const skipTime = announcement.all_day; + + return ( + + {hasTimeRange && ( + + + {' '} + - + {' '} + + + )} + + + + {features.announcementsReactions && ( + + )} + + ); +}; + +export default Announcement; diff --git a/app/soapbox/components/announcements/announcements-panel.tsx b/app/soapbox/components/announcements/announcements-panel.tsx new file mode 100644 index 000000000..200615dab --- /dev/null +++ b/app/soapbox/components/announcements/announcements-panel.tsx @@ -0,0 +1,69 @@ +import classNames from 'classnames'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; +import { createSelector } from 'reselect'; + +import { addReaction as addReactionAction, removeReaction as removeReactionAction } from 'soapbox/actions/announcements'; +import { Card, HStack, Widget } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import Announcement from './announcement'; + +import type { RootState } from 'soapbox/store'; + +const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => (items as ImmutableList>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap>())); + +const AnnouncementsPanel = () => { + const dispatch = useAppDispatch(); + const emojiMap = useAppSelector(state => customEmojiMap(state)); + const [index, setIndex] = useState(0); + + const announcements = useAppSelector((state) => state.announcements.items); + + const addReaction = (id: string, name: string) => dispatch(addReactionAction(id, name)); + const removeReaction = (id: string, name: string) => dispatch(removeReactionAction(id, name)); + + if (announcements.size === 0) return null; + + const handleChangeIndex = (index: number) => { + setIndex(index % announcements.size); + }; + + return ( + }> + + + {announcements.map((announcement) => ( + + )).reverse()} + + {announcements.size > 1 && ( + + {announcements.map((_, i) => ( + + ); +}; + +export default Reaction; diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx new file mode 100644 index 000000000..66b5f3f83 --- /dev/null +++ b/app/soapbox/components/announcements/reactions-bar.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames'; +import React from 'react'; +import { TransitionMotion, spring } from 'react-motion'; + +import { Icon } from 'soapbox/components/ui'; +import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container'; +import { useSettings } from 'soapbox/hooks'; + +import Reaction from './reaction'; + +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; +import type { AnnouncementReaction } from 'soapbox/types/entities'; + +interface IReactionsBar { + announcementId: string; + reactions: ImmutableList; + emojiMap: ImmutableMap>; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; +} + +const ReactionsBar: React.FC = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const handleEmojiPick = (data: Emoji) => { + addReaction(announcementId, data.native.replace(/:/g, '')); + }; + + const willEnter = () => ({ scale: reduceMotion ? 1 : 0 }); + + const willLeave = () => ({ scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }); + + const visibleReactions = reactions.filter(x => x.count > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.name, + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} + + {visibleReactions.size < 8 && } />} +
+ )} +
+ ); +}; + +export default ReactionsBar; diff --git a/app/soapbox/components/autosuggest_input.tsx b/app/soapbox/components/autosuggest_input.tsx index cd2c74a07..fc702cf5f 100644 --- a/app/soapbox/components/autosuggest_input.tsx +++ b/app/soapbox/components/autosuggest_input.tsx @@ -1,3 +1,4 @@ +import Portal from '@reach/portal'; import classNames from 'classnames'; import { List as ImmutableList } from 'immutable'; import React from 'react'; @@ -176,7 +177,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { + onSuggestionClick: React.EventHandler = (e) => { const index = Number(e.currentTarget?.getAttribute('data-index')); const suggestion = this.props.suggestions.get(index); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); @@ -221,6 +222,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {inner}
@@ -267,8 +269,22 @@ export default class AutosuggestInput extends ImmutablePureComponent + return [ +
- -
, + +
{suggestions.map(this.renderSuggestion)} @@ -318,8 +335,8 @@ export default class AutosuggestInput extends ImmutablePureComponent -
- ); + , + ]; } } diff --git a/app/soapbox/components/birthday_input.tsx b/app/soapbox/components/birthday_input.tsx index e263fb02e..fc9521ca0 100644 --- a/app/soapbox/components/birthday_input.tsx +++ b/app/soapbox/components/birthday_input.tsx @@ -109,7 +109,7 @@ const BirthdayInput: React.FC = ({ value, onChange, required }) ); }; - const handleChange = (date: Date) => onChange(new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10)); + const handleChange = (date: Date) => onChange(date ? new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) : ''); return (
@@ -123,6 +123,7 @@ const BirthdayInput: React.FC = ({ value, onChange, required }) maxDate={maxDate} required={required} renderCustomHeader={renderCustomHeader} + isClearable={!required} />)}
diff --git a/app/soapbox/components/hover_ref_wrapper.tsx b/app/soapbox/components/hover_ref_wrapper.tsx index 2090543cc..7e103f3e5 100644 --- a/app/soapbox/components/hover_ref_wrapper.tsx +++ b/app/soapbox/components/hover_ref_wrapper.tsx @@ -1,12 +1,13 @@ import classNames from 'classnames'; import debounce from 'lodash/debounce'; import React, { useRef } from 'react'; -import { useDispatch } from 'react-redux'; +import { fetchAccount } from 'soapbox/actions/accounts'; import { openProfileHoverCard, closeProfileHoverCard, } from 'soapbox/actions/profile_hover_card'; +import { useAppDispatch } from 'soapbox/hooks'; import { isMobile } from 'soapbox/is_mobile'; const showProfileHoverCard = debounce((dispatch, ref, accountId) => { @@ -21,12 +22,13 @@ interface IHoverRefWrapper { /** Makes a profile hover card appear when the wrapped element is hovered. */ export const HoverRefWrapper: React.FC = ({ accountId, children, inline = false, className }) => { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const ref = useRef(null); const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div'; const handleMouseEnter = () => { if (!isMobile(window.innerWidth)) { + dispatch(fetchAccount(accountId)); showProfileHoverCard(dispatch, ref, accountId); } }; diff --git a/app/soapbox/components/pullable.tsx b/app/soapbox/components/pullable.tsx index 03840d2c1..88394b828 100644 --- a/app/soapbox/components/pullable.tsx +++ b/app/soapbox/components/pullable.tsx @@ -13,7 +13,6 @@ interface IPullable { */ const Pullable: React.FC = ({ children }) =>( diff --git a/app/soapbox/components/quoted-status.tsx b/app/soapbox/components/quoted-status.tsx index e00ca10e5..5bd4effc4 100644 --- a/app/soapbox/components/quoted-status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -143,7 +143,7 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => {renderReplyMentions()} diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index c876219f4..f60ec2ac8 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -35,7 +35,7 @@ const Card = React.forwardRef(({ children, variant, size className={classNames({ 'space-y-4': true, 'bg-white dark:bg-slate-800 text-black dark:text-white shadow-lg dark:shadow-inset overflow-hidden': variant === 'rounded', - [sizes[size]]: true, + [sizes[size]]: variant === 'rounded', }, className)} > {children} diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 81b7fca1e..e0e86a398 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import React from 'react'; import { useHistory } from 'react-router-dom'; @@ -42,25 +41,19 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR } }; - const renderChildren = () => { - if (transparent) { - return
{children}
; - } - - return ( - - {withHeader ? ( - - - - ) : null} + const renderChildren = () => ( + + {withHeader ? ( + + + + ) : null} - - {children} - - - ); - }; + + {children} + + + ); return (
diff --git a/app/soapbox/components/ui/hstack/hstack.tsx b/app/soapbox/components/ui/hstack/hstack.tsx index 2a021d903..f05f991ec 100644 --- a/app/soapbox/components/ui/hstack/hstack.tsx +++ b/app/soapbox/components/ui/hstack/hstack.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { forwardRef } from 'react'; const justifyContentOptions = { between: 'justify-between', @@ -32,6 +32,8 @@ interface IHStack { alignItems?: 'top' | 'bottom' | 'center' | 'start', /** Extra class names on the
element. */ className?: string, + /** Children */ + children?: React.ReactNode, /** Horizontal alignment of children. */ justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around', /** Size of the gap between elements. */ @@ -43,12 +45,13 @@ interface IHStack { } /** Horizontal row of child elements. */ -const HStack: React.FC = (props) => { +const HStack = forwardRef((props, ref) => { const { space, alignItems, grow, justifyContent, className, ...filteredProps } = props; return (
= (props) => { }, className)} /> ); -}; +}); export default HStack; diff --git a/app/soapbox/features/bookmarks/index.tsx b/app/soapbox/features/bookmarks/index.tsx index 269aa8d1f..c645a56d1 100644 --- a/app/soapbox/features/bookmarks/index.tsx +++ b/app/soapbox/features/bookmarks/index.tsx @@ -36,7 +36,7 @@ const Bookmarks: React.FC = () => { const emptyMessage = ; return ( - +
diff --git a/app/soapbox/features/chats/components/chat-list.tsx b/app/soapbox/features/chats/components/chat-list.tsx index 5d9c422a8..825b64871 100644 --- a/app/soapbox/features/chats/components/chat-list.tsx +++ b/app/soapbox/features/chats/components/chat-list.tsx @@ -7,7 +7,7 @@ import { createSelector } from 'reselect'; import { fetchChats, expandChats } from 'soapbox/actions/chats'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; -import { Text } from 'soapbox/components/ui'; +import { Card, Text } from 'soapbox/components/ui'; import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; import { useAppSelector } from 'soapbox/hooks'; @@ -53,6 +53,8 @@ const ChatList: React.FC = ({ onClickChat, useWindowScroll = false }) const hasMore = useAppSelector(state => !!state.chats.next); const isLoading = useAppSelector(state => state.chats.isLoading); + const isEmpty = chatIds.size === 0; + const handleLoadMore = useCallback(() => { if (hasMore && !isLoading) { dispatch(expandChats()); @@ -63,28 +65,30 @@ const ChatList: React.FC = ({ onClickChat, useWindowScroll = false }) return dispatch(fetchChats()) as any; }; + const renderEmpty = () => isLoading ? : ( + + {intl.formatMessage(messages.emptyMessage)} + + ); + return ( - ( - - )} - components={{ - ScrollSeekPlaceholder: () => , - Footer: () => hasMore ? : null, - EmptyPlaceholder: () => { - if (isLoading) { - return ; - } else { - return {intl.formatMessage(messages.emptyMessage)}; - } - }, - }} - /> + {isEmpty ? renderEmpty() : ( + ( + + )} + components={{ + ScrollSeekPlaceholder: () => , + Footer: () => hasMore ? : null, + EmptyPlaceholder: renderEmpty, + }} + /> + )} ); }; diff --git a/app/soapbox/features/community_timeline/index.tsx b/app/soapbox/features/community_timeline/index.tsx index 60670a160..8c0adc2cb 100644 --- a/app/soapbox/features/community_timeline/index.tsx +++ b/app/soapbox/features/community_timeline/index.tsx @@ -43,7 +43,7 @@ const CommunityTimeline = () => { }, [onlyMedia]); return ( - + - + > + {button || } +
= ({ status, hideActions, onCanc /> ({ frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), }); -const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ +const mapDispatchToProps = (dispatch, props) => ({ onSkinTone: skinTone => { dispatch(changeSetting(['skinTone'], skinTone)); }, @@ -75,8 +75,8 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ onPickEmoji: emoji => { dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks - if (onPickEmoji) { - onPickEmoji(emoji); + if (props.onPickEmoji) { + props.onPickEmoji(emoji); } }, }); diff --git a/app/soapbox/features/direct_timeline/index.tsx b/app/soapbox/features/direct_timeline/index.tsx index 139a1077e..9da60dd9f 100644 --- a/app/soapbox/features/direct_timeline/index.tsx +++ b/app/soapbox/features/direct_timeline/index.tsx @@ -40,7 +40,7 @@ const DirectTimeline = () => { }; return ( - + { }; }; + const handleBirthdayChange = (date: string) => { + updateData('birthday', date); + }; + const handleHideNetworkChange: React.ChangeEventHandler = e => { const hide = e.target.checked; @@ -315,12 +320,9 @@ const EditProfile: React.FC = () => { } > - )} diff --git a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx index d7f439fa2..e01a32e9e 100644 --- a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx +++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx @@ -7,7 +7,7 @@ import { render, screen, waitFor } from '../../../jest/test-helpers'; import FeedCarousel from '../feed-carousel'; jest.mock('../../../hooks/useDimensions', () => ({ - useDimensions: () => [null, { width: 200 }], + useDimensions: () => [{ scrollWidth: 190 }, null, { width: 100 }], })); (window as any).ResizeObserver = class ResizeObserver { diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index d64c6f8f0..477c17f01 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -44,7 +44,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { { const dispatch = useAppDispatch(); const features = useFeatures(); - const [cardRef, { width }] = useDimensions(); + const [cardRef, setCardRef, { width }] = useDimensions(); const [pageSize, setPageSize] = useState(0); const [currentPage, setCurrentPage] = useState(1); @@ -70,7 +70,8 @@ const FeedCarousel = () => { const avatars = useAppSelector((state) => state.carousels.avatars); const isLoading = useAppSelector((state) => state.carousels.isLoading); const hasError = useAppSelector((state) => state.carousels.error); - const numberOfPages = Math.floor(avatars.length / pageSize); + const numberOfPages = Math.ceil(avatars.length / pageSize); + const widthPerAvatar = (cardRef?.scrollWidth || 0) / avatars.length; const hasNextPage = currentPage < numberOfPages && numberOfPages > 1; const hasPrevPage = currentPage > 1 && numberOfPages > 1; @@ -80,9 +81,9 @@ const FeedCarousel = () => { useEffect(() => { if (width) { - setPageSize(Math.round(width / (80 + 15))); + setPageSize(Math.round(width / widthPerAvatar)); } - }, [width]); + }, [width, widthPerAvatar]); useEffect(() => { if (features.feedUserFiltering) { @@ -109,7 +110,7 @@ const FeedCarousel = () => { } return ( - +
{hasPrevPage && (
@@ -117,7 +118,7 @@ const FeedCarousel = () => { @@ -130,6 +131,7 @@ const FeedCarousel = () => { space={8} className='z-0 flex transition-all duration-200 ease-linear scroll' style={{ transform: `translateX(-${(currentPage - 1) * 100}%)` }} + ref={setCardRef} > {isLoading ? ( new Array(pageSize).fill(0).map((_, idx) => ( @@ -153,7 +155,7 @@ const FeedCarousel = () => { diff --git a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx index b888e80e3..f2cffd85e 100644 --- a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx +++ b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx @@ -27,7 +27,7 @@ const SuggestionItem = ({ accountId }: { accountId: string }) => { {account.acct} diff --git a/app/soapbox/features/followers/index.js b/app/soapbox/features/followers/index.js index 7a77e46a5..a25fb0d7d 100644 --- a/app/soapbox/features/followers/index.js +++ b/app/soapbox/features/followers/index.js @@ -118,7 +118,7 @@ class Followers extends ImmutablePureComponent { } return ( - + + + { }, [isPartial]); return ( - + { ); return ( - + {/*