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) => (
+ setIndex(i)}
+ className={classNames({
+ 'w-2 h-2 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
+ 'bg-gray-200 hover:bg-gray-300': i !== index,
+ 'bg-primary-600': i === index,
+ })}
+ />
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default AnnouncementsPanel;
diff --git a/app/soapbox/components/announcements/emoji.tsx b/app/soapbox/components/announcements/emoji.tsx
new file mode 100644
index 000000000..eb9683f08
--- /dev/null
+++ b/app/soapbox/components/announcements/emoji.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
+import { useSettings } from 'soapbox/hooks';
+import { joinPublicPath } from 'soapbox/utils/static';
+
+import type { Map as ImmutableMap } from 'immutable';
+
+interface IEmoji {
+ emoji: string;
+ emojiMap: ImmutableMap>;
+ hovered: boolean;
+}
+
+const Emoji: React.FC = ({ emoji, emojiMap, hovered }) => {
+ const autoPlayGif = useSettings().get('autoPlayGif');
+
+ // @ts-ignore
+ if (unicodeMapping[emoji]) {
+ // @ts-ignore
+ const { filename, shortCode } = unicodeMapping[emoji];
+ const title = shortCode ? `:${shortCode}:` : '';
+
+ return (
+
+ );
+ } else if (emojiMap.get(emoji as any)) {
+ const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+ const shortCode = `:${emoji}:`;
+
+ return (
+
+ );
+ } else {
+ return null;
+ }
+};
+
+export default Emoji;
diff --git a/app/soapbox/components/announcements/reaction.tsx b/app/soapbox/components/announcements/reaction.tsx
new file mode 100644
index 000000000..1e415c667
--- /dev/null
+++ b/app/soapbox/components/announcements/reaction.tsx
@@ -0,0 +1,66 @@
+import classNames from 'classnames';
+import React, { useState } from 'react';
+
+import AnimatedNumber from 'soapbox/components/animated-number';
+import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
+
+import Emoji from './emoji';
+
+import type { Map as ImmutableMap } from 'immutable';
+import type { AnnouncementReaction } from 'soapbox/types/entities';
+
+interface IReaction {
+ announcementId: string;
+ reaction: AnnouncementReaction;
+ emojiMap: ImmutableMap>;
+ addReaction: (id: string, name: string) => void;
+ removeReaction: (id: string, name: string) => void;
+ style: React.CSSProperties;
+}
+
+const Reaction: React.FC = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => {
+ const [hovered, setHovered] = useState(false);
+
+ const handleClick = () => {
+ if (reaction.me) {
+ removeReaction(announcementId, reaction.name);
+ } else {
+ addReaction(announcementId, reaction.name);
+ }
+ };
+
+ const handleMouseEnter = () => setHovered(true);
+
+ const handleMouseLeave = () => setHovered(false);
+
+ let shortCode = reaction.name;
+
+ // @ts-ignore
+ if (unicodeMapping[shortCode]) {
+ // @ts-ignore
+ shortCode = unicodeMapping[shortCode].shortCode;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+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 [
+
{placeholder}
-
-
,
+
+
{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 }) => {
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 (
-
+
{/*
diff --git a/app/soapbox/features/notifications/index.tsx b/app/soapbox/features/notifications/index.tsx
index 62e67fdb5..f97c17a7f 100644
--- a/app/soapbox/features/notifications/index.tsx
+++ b/app/soapbox/features/notifications/index.tsx
@@ -69,7 +69,7 @@ const Notifications = () => {
const handleLoadOlder = useCallback(debounce(() => {
const last = notifications.last();
dispatch(expandNotifications({ maxId: last && last.get('id') }));
- }, 300, { leading: true }), []);
+ }, 300, { leading: true }), [notifications]);
const handleScrollToTop = useCallback(debounce(() => {
dispatch(scrollTopNotifications(true));
diff --git a/app/soapbox/features/pinned_statuses/index.tsx b/app/soapbox/features/pinned_statuses/index.tsx
index 74d4d0f98..b5263ea4a 100644
--- a/app/soapbox/features/pinned_statuses/index.tsx
+++ b/app/soapbox/features/pinned_statuses/index.tsx
@@ -36,7 +36,7 @@ const PinnedStatuses = () => {
}
return (
-
+
{
setUsername(event.target.value)}
+ onChange={(event) => setUsername(event.target.value.trim())}
type='text'
placeholder={intl.formatMessage(messages.username)}
className='max-w-[200px]'
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
/>
{
type='password'
placeholder={intl.formatMessage(messages.password)}
className='max-w-[200px]'
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
/>
diff --git a/app/soapbox/features/public_timeline/index.tsx b/app/soapbox/features/public_timeline/index.tsx
index 8c435173b..6fe5afec8 100644
--- a/app/soapbox/features/public_timeline/index.tsx
+++ b/app/soapbox/features/public_timeline/index.tsx
@@ -64,7 +64,7 @@ const CommunityTimeline = () => {
}, [onlyMedia]);
return (
-
+
{showExplanationBox &&
diff --git a/app/soapbox/features/remote_timeline/index.tsx b/app/soapbox/features/remote_timeline/index.tsx
index e19284891..00e2effe2 100644
--- a/app/soapbox/features/remote_timeline/index.tsx
+++ b/app/soapbox/features/remote_timeline/index.tsx
@@ -65,7 +65,7 @@ const RemoteTimeline: React.FC
= ({ params }) => {
}, [onlyMedia]);
return (
-
+
{instance && }
{!pinned &&
diff --git a/app/soapbox/features/security/mfa/disable_otp_form.tsx b/app/soapbox/features/security/mfa/disable_otp_form.tsx
index ba0e5fbbf..63a551f31 100644
--- a/app/soapbox/features/security/mfa/disable_otp_form.tsx
+++ b/app/soapbox/features/security/mfa/disable_otp_form.tsx
@@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar';
import { Button, Form, FormGroup, Input, FormActions, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
-const messages = defineMessages({
+const messages = defineMessages({
mfa_setup_disable_button: { id: 'column.mfa_disable_button', defaultMessage: 'Disable' },
disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' },
mfaDisableSuccess: { id: 'mfa.disable.success_message', defaultMessage: 'MFA disabled' },
diff --git a/app/soapbox/features/security/mfa/enable_otp_form.tsx b/app/soapbox/features/security/mfa/enable_otp_form.tsx
index a5608bf18..98dae6519 100644
--- a/app/soapbox/features/security/mfa/enable_otp_form.tsx
+++ b/app/soapbox/features/security/mfa/enable_otp_form.tsx
@@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar';
import { Button, FormActions, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
-const messages = defineMessages({
+const messages = defineMessages({
mfaCancelButton: { id: 'column.mfa_cancel', defaultMessage: 'Cancel' },
mfaSetupButton: { id: 'column.mfa_setup', defaultMessage: 'Proceed to Setup' },
codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' },
diff --git a/app/soapbox/features/soapbox_config/components/color-with-picker.tsx b/app/soapbox/features/soapbox_config/components/color-with-picker.tsx
index 3fac5b9d2..c82e1bf11 100644
--- a/app/soapbox/features/soapbox_config/components/color-with-picker.tsx
+++ b/app/soapbox/features/soapbox_config/components/color-with-picker.tsx
@@ -28,12 +28,14 @@ const ColorWithPicker: React.FC = ({ buttonId, value, onChange
setPlacement(isMobile(window.innerWidth) ? 'bottom' : 'right');
};
- const onToggle: React.MouseEventHandler = () => {
+ const onToggle: React.MouseEventHandler = (e) => {
if (active) {
hidePicker();
} else {
showPicker();
}
+
+ e.stopPropagation();
};
return (
diff --git a/app/soapbox/features/status/components/status-interaction-bar.tsx b/app/soapbox/features/status/components/status-interaction-bar.tsx
index 57511767c..17f9163db 100644
--- a/app/soapbox/features/status/components/status-interaction-bar.tsx
+++ b/app/soapbox/features/status/components/status-interaction-bar.tsx
@@ -139,7 +139,7 @@ const StatusInteractionBar: React.FC = ({ status }): JSX.
return (
diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx
index ca8f6928d..59e225dec 100644
--- a/app/soapbox/features/status/index.tsx
+++ b/app/soapbox/features/status/index.tsx
@@ -792,7 +792,7 @@ class Status extends ImmutablePureComponent {
}
return (
-
+
diff --git a/app/soapbox/features/test_timeline/index.tsx b/app/soapbox/features/test_timeline/index.tsx
index 024f38204..13f2fcbef 100644
--- a/app/soapbox/features/test_timeline/index.tsx
+++ b/app/soapbox/features/test_timeline/index.tsx
@@ -38,7 +38,7 @@ const TestTimeline: React.FC = () => {
}, []);
return (
-
+
{
.then(() => dispatch(fetchMarker(['notifications'])))
.catch(console.error);
+ dispatch(fetchAnnouncements());
+
if (features.chats) {
dispatch(fetchChats());
}
diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts
index b4ae937bb..930fc81da 100644
--- a/app/soapbox/features/ui/util/async-components.ts
+++ b/app/soapbox/features/ui/util/async-components.ts
@@ -521,3 +521,7 @@ export function VerifySmsModal() {
export function FamiliarFollowersModal() {
return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/familiar_followers_modal');
}
+
+export function AnnouncementsPanel() {
+ return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
+}
diff --git a/app/soapbox/hooks/__tests__/useDimensions.test.ts b/app/soapbox/hooks/__tests__/useDimensions.test.ts
index 0adee42e4..78524ad77 100644
--- a/app/soapbox/hooks/__tests__/useDimensions.test.ts
+++ b/app/soapbox/hooks/__tests__/useDimensions.test.ts
@@ -21,10 +21,10 @@ describe('useDimensions()', () => {
act(() => {
const div = document.createElement('div');
- (result.current[0] as any)(div);
+ (result.current[1] as any)(div);
});
- expect(result.current[1]).toMatchObject({
+ expect(result.current[2]).toMatchObject({
width: 0,
height: 0,
});
@@ -35,7 +35,7 @@ describe('useDimensions()', () => {
act(() => {
const div = document.createElement('div');
- (result.current[0] as any)(div);
+ (result.current[1] as any)(div);
});
act(() => {
@@ -49,7 +49,7 @@ describe('useDimensions()', () => {
]);
});
- expect(result.current[1]).toMatchObject({
+ expect(result.current[2]).toMatchObject({
width: 200,
height: 200,
});
@@ -70,7 +70,7 @@ describe('useDimensions()', () => {
act(() => {
const div = document.createElement('div');
- (result.current[0] as any)(div);
+ (result.current[1] as any)(div);
});
expect(disconnect).toHaveBeenCalledTimes(0);
diff --git a/app/soapbox/hooks/useDimensions.ts b/app/soapbox/hooks/useDimensions.ts
index 8ba699925..2a265c4a6 100644
--- a/app/soapbox/hooks/useDimensions.ts
+++ b/app/soapbox/hooks/useDimensions.ts
@@ -1,7 +1,7 @@
-import { Ref, useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
type UseDimensionsRect = { width: number, height: number };
-type UseDimensionsResult = [Ref, any]
+type UseDimensionsResult = [Element | null, any, any]
const defaultState: UseDimensionsRect = {
width: 0,
@@ -9,7 +9,7 @@ const defaultState: UseDimensionsRect = {
};
const useDimensions = (): UseDimensionsResult => {
- const [element, ref] = useState(null);
+ const [element, setRef] = useState(null);
const [rect, setRect] = useState(defaultState);
const observer = useMemo(
@@ -32,7 +32,7 @@ const useDimensions = (): UseDimensionsResult => {
};
}, [element]);
- return [ref, rect];
+ return [element, setRef, rect];
};
export { useDimensions };
diff --git a/app/soapbox/normalizers/announcement.ts b/app/soapbox/normalizers/announcement.ts
new file mode 100644
index 000000000..0db9f3e00
--- /dev/null
+++ b/app/soapbox/normalizers/announcement.ts
@@ -0,0 +1,87 @@
+/**
+ * Announcement normalizer:
+ * Converts API announcements into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/announcement/}
+ */
+import {
+ Map as ImmutableMap,
+ List as ImmutableList,
+ Record as ImmutableRecord,
+ fromJS,
+} from 'immutable';
+
+import emojify from 'soapbox/features/emoji/emoji';
+import { normalizeEmoji } from 'soapbox/normalizers/emoji';
+import { makeEmojiMap } from 'soapbox/utils/normalizers';
+
+import { normalizeAnnouncementReaction } from './announcement_reaction';
+import { normalizeMention } from './mention';
+
+import type { AnnouncementReaction, Emoji, Mention } from 'soapbox/types/entities';
+
+// https://docs.joinmastodon.org/entities/announcement/
+export const AnnouncementRecord = ImmutableRecord({
+ id: '',
+ content: '',
+ starts_at: null as Date | null,
+ ends_at: null as Date | null,
+ all_day: false,
+ read: false,
+ published_at: Date,
+ reactions: ImmutableList(),
+ statuses: ImmutableMap(),
+ mentions: ImmutableList(),
+ tags: ImmutableList>(),
+ emojis: ImmutableList(),
+ updated_at: Date,
+
+ // Internal fields
+ contentHtml: '',
+});
+
+const normalizeMentions = (announcement: ImmutableMap) => {
+ return announcement.update('mentions', ImmutableList(), mentions => {
+ return mentions.map(normalizeMention);
+ });
+};
+
+// Normalize reactions
+const normalizeReactions = (announcement: ImmutableMap) => {
+ return announcement.update('reactions', ImmutableList(), reactions => {
+ return reactions.map((reaction: ImmutableMap) => normalizeAnnouncementReaction(reaction, announcement.get('id')));
+ });
+};
+
+// Normalize emojis
+const normalizeEmojis = (announcement: ImmutableMap) => {
+ return announcement.update('emojis', ImmutableList(), emojis => {
+ return emojis.map(normalizeEmoji);
+ });
+};
+
+const normalizeContent = (announcement: ImmutableMap) => {
+ const emojiMap = makeEmojiMap(announcement.get('emojis'));
+ const contentHtml = emojify(announcement.get('content'), emojiMap);
+
+ return announcement.set('contentHtml', contentHtml);
+};
+
+const normalizeStatuses = (announcement: ImmutableMap) => {
+ const statuses = announcement
+ .get('statuses', ImmutableList())
+ .reduce((acc: ImmutableMap, curr: ImmutableMap) => acc.set(curr.get('url'), `/@${curr.getIn(['account', 'acct'])}/${curr.get('id')}`), ImmutableMap());
+
+ return announcement.set('statuses', statuses);
+};
+
+export const normalizeAnnouncement = (announcement: Record) => {
+ return AnnouncementRecord(
+ ImmutableMap(fromJS(announcement)).withMutations(announcement => {
+ normalizeMentions(announcement);
+ normalizeReactions(announcement);
+ normalizeEmojis(announcement);
+ normalizeContent(announcement);
+ normalizeStatuses(announcement);
+ }),
+ );
+};
diff --git a/app/soapbox/normalizers/announcement_reaction.ts b/app/soapbox/normalizers/announcement_reaction.ts
new file mode 100644
index 000000000..56827d28c
--- /dev/null
+++ b/app/soapbox/normalizers/announcement_reaction.ts
@@ -0,0 +1,22 @@
+/**
+ * Announcement reaction normalizer:
+ * Converts API announcement emoji reactions into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/announcementreaction/}
+ */
+import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
+
+// https://docs.joinmastodon.org/entities/announcement/
+export const AnnouncementReactionRecord = ImmutableRecord({
+ name: '',
+ count: 0,
+ me: false,
+ url: null as string | null,
+ static_url: null as string | null,
+ announcement_id: '',
+});
+
+export const normalizeAnnouncementReaction = (announcementReaction: Record, announcementId?: string) => {
+ return AnnouncementReactionRecord(ImmutableMap(fromJS(announcementReaction)).withMutations(reaction => {
+ reaction.set('announcement_id', announcementId as any);
+ }));
+};
diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts
index b1b41e0d6..d25cbd014 100644
--- a/app/soapbox/normalizers/index.ts
+++ b/app/soapbox/normalizers/index.ts
@@ -1,6 +1,8 @@
export { AccountRecord, FieldRecord, normalizeAccount } from './account';
export { AdminAccountRecord, normalizeAdminAccount } from './admin_account';
export { AdminReportRecord, normalizeAdminReport } from './admin_report';
+export { AnnouncementRecord, normalizeAnnouncement } from './announcement';
+export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement_reaction';
export { AttachmentRecord, normalizeAttachment } from './attachment';
export { CardRecord, normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat';
diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts
index 5474fa395..554b40f5f 100644
--- a/app/soapbox/normalizers/status.ts
+++ b/app/soapbox/normalizers/status.ts
@@ -144,6 +144,11 @@ const fixQuote = (status: ImmutableMap) => {
});
};
+// Workaround for not yet implemented filtering from Mastodon 3.6
+const fixFiltered = (status: ImmutableMap) => {
+ status.delete('filtered');
+};
+
export const normalizeStatus = (status: Record) => {
return StatusRecord(
ImmutableMap(fromJS(status)).withMutations(status => {
@@ -155,6 +160,7 @@ export const normalizeStatus = (status: Record) => {
fixMentionsOrder(status);
addSelfMention(status);
fixQuote(status);
+ fixFiltered(status);
}),
);
};
diff --git a/app/soapbox/pages/home_page.tsx b/app/soapbox/pages/home_page.tsx
index 149fa90f4..e161bbe53 100644
--- a/app/soapbox/pages/home_page.tsx
+++ b/app/soapbox/pages/home_page.tsx
@@ -12,6 +12,7 @@ import {
CryptoDonatePanel,
BirthdayPanel,
CtaBanner,
+ AnnouncementsPanel,
} from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
@@ -74,6 +75,11 @@ const HomePage: React.FC = ({ children }) => {
{Component => }
)}
+ {me && features.announcements && (
+
+ {Component => }
+
+ )}
{features.trends && (
{Component => }
diff --git a/app/soapbox/pages/profile_page.tsx b/app/soapbox/pages/profile_page.tsx
index c3cb65ff4..4a209303d 100644
--- a/app/soapbox/pages/profile_page.tsx
+++ b/app/soapbox/pages/profile_page.tsx
@@ -116,6 +116,8 @@ const ProfilePage: React.FC = ({ params, children }) => {
activeItem = 'profile';
}
+ const showTabs = !['following', 'followers', 'pins'].some(path => pathname.includes(path));
+
return (
<>
@@ -128,7 +130,7 @@ const ProfilePage: React.FC = ({ params, children }) => {
{Component => }
- {account && (
+ {account && showTabs && (
)}
diff --git a/app/soapbox/reducers/__tests__/announcements.test.ts b/app/soapbox/reducers/__tests__/announcements.test.ts
new file mode 100644
index 000000000..2051f75d8
--- /dev/null
+++ b/app/soapbox/reducers/__tests__/announcements.test.ts
@@ -0,0 +1,42 @@
+import { List as ImmutableList, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
+
+import {
+ ANNOUNCEMENTS_FETCH_SUCCESS,
+ ANNOUNCEMENTS_UPDATE,
+} from 'soapbox/actions/announcements';
+
+import reducer from '../announcements';
+
+const announcements = require('soapbox/__fixtures__/announcements.json');
+
+describe('accounts reducer', () => {
+ it('should return the initial state', () => {
+ expect(reducer(undefined, {} as any)).toMatchObject({
+ items: ImmutableList(),
+ isLoading: false,
+ show: false,
+ unread: ImmutableSet(),
+ });
+ });
+
+ describe('ANNOUNCEMENTS_FETCH_SUCCESS', () => {
+ it('parses announcements as Records', () => {
+ const action = { type: ANNOUNCEMENTS_FETCH_SUCCESS, announcements };
+ const result = reducer(undefined, action).items;
+
+ expect(result.every((announcement) => ImmutableRecord.isRecord(announcement))).toBe(true);
+ });
+ });
+
+ describe('ANNOUNCEMENTS_UPDATE', () => {
+ it('updates announcements', () => {
+ const state = reducer(undefined, { type: ANNOUNCEMENTS_FETCH_SUCCESS, announcements: [announcements[0]] });
+
+ const action = { type: ANNOUNCEMENTS_UPDATE, announcement: { ...announcements[0], content: 'Updated to Soapbox v3.0.0.
' } };
+ const result = reducer(state, action).items;
+
+ expect(result.size === 1);
+ expect(result.first()?.content === 'Updated to Soapbox v3.0.0.
');
+ });
+ });
+});
diff --git a/app/soapbox/reducers/announcements.ts b/app/soapbox/reducers/announcements.ts
new file mode 100644
index 000000000..e6be16d2a
--- /dev/null
+++ b/app/soapbox/reducers/announcements.ts
@@ -0,0 +1,110 @@
+import { List as ImmutableList, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
+
+import {
+ ANNOUNCEMENTS_FETCH_REQUEST,
+ ANNOUNCEMENTS_FETCH_SUCCESS,
+ ANNOUNCEMENTS_FETCH_FAIL,
+ ANNOUNCEMENTS_UPDATE,
+ ANNOUNCEMENTS_REACTION_UPDATE,
+ ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+ ANNOUNCEMENTS_REACTION_ADD_FAIL,
+ ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+ ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+ ANNOUNCEMENTS_TOGGLE_SHOW,
+ ANNOUNCEMENTS_DELETE,
+ ANNOUNCEMENTS_DISMISS_SUCCESS,
+} from 'soapbox/actions/announcements';
+import { normalizeAnnouncement, normalizeAnnouncementReaction } from 'soapbox/normalizers';
+
+import type { AnyAction } from 'redux';
+import type{ Announcement, AnnouncementReaction, APIEntity } from 'soapbox/types/entities';
+
+const ReducerRecord = ImmutableRecord({
+ items: ImmutableList(),
+ isLoading: false,
+ show: false,
+ unread: ImmutableSet(),
+});
+
+type State = ReturnType;
+
+const updateReaction = (state: State, id: string, name: string, updater: (a: AnnouncementReaction) => AnnouncementReaction) => state.update('items', list => list.map(announcement => {
+ if (announcement.id === id) {
+ return announcement.update('reactions', reactions => {
+ const idx = reactions.findIndex(reaction => reaction.name === name);
+
+ if (idx > -1) {
+ return reactions.update(idx, reaction => updater(reaction!));
+ }
+
+ return reactions.push(updater(normalizeAnnouncementReaction({ name, count: 0 })));
+ });
+ }
+
+ return announcement;
+}));
+
+const updateReactionCount = (state: State, reaction: AnnouncementReaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count));
+
+const addReaction = (state: State, id: string, name: string) => updateReaction(state, id, name, (x: AnnouncementReaction) => x.set('me', true).update('count', y => y + 1));
+
+const removeReaction = (state: State, id: string, name: string) => updateReaction(state, id, name, (x: AnnouncementReaction) => x.set('me', false).update('count', y => y - 1));
+
+const sortAnnouncements = (list: ImmutableList) => list.sortBy(x => x.starts_at || x.published_at);
+
+const updateAnnouncement = (state: State, announcement: Announcement) => {
+ const idx = state.items.findIndex(x => x.id === announcement.id);
+
+ if (idx > -1) {
+ // Deep merge is used because announcements from the streaming API do not contain
+ // personalized data about which reactions have been selected by the given user,
+ // and that is information we want to preserve
+ return state.update('items', list => sortAnnouncements(list.update(idx, x => x!.mergeDeep(announcement))));
+ }
+
+ return state.update('items', list => sortAnnouncements(list.unshift(announcement)));
+};
+
+export default function announcementsReducer(state = ReducerRecord(), action: AnyAction) {
+ switch (action.type) {
+ case ANNOUNCEMENTS_TOGGLE_SHOW:
+ return state.withMutations(map => {
+ map.set('show', !map.show);
+ });
+ case ANNOUNCEMENTS_FETCH_REQUEST:
+ return state.set('isLoading', true);
+ case ANNOUNCEMENTS_FETCH_SUCCESS:
+ return state.withMutations(map => {
+ const items = ImmutableList((action.announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)));
+
+ map.set('items', items);
+ map.set('isLoading', false);
+ });
+ case ANNOUNCEMENTS_FETCH_FAIL:
+ return state.set('isLoading', false);
+ case ANNOUNCEMENTS_UPDATE:
+ return updateAnnouncement(state, normalizeAnnouncement(action.announcement));
+ case ANNOUNCEMENTS_REACTION_UPDATE:
+ return updateReactionCount(state, action.reaction);
+ case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
+ case ANNOUNCEMENTS_REACTION_REMOVE_FAIL:
+ return addReaction(state, action.id, action.name);
+ case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
+ case ANNOUNCEMENTS_REACTION_ADD_FAIL:
+ return removeReaction(state, action.id, action.name);
+ case ANNOUNCEMENTS_DISMISS_SUCCESS:
+ return updateAnnouncement(state, normalizeAnnouncement({ id: action.id, read: true }));
+ case ANNOUNCEMENTS_DELETE:
+ return state.update('items', list => {
+ const idx = list.findIndex(x => x.id === action.id);
+
+ if (idx > -1) {
+ return list.delete(idx);
+ }
+
+ return list;
+ });
+ default:
+ return state;
+ }
+}
\ No newline at end of file
diff --git a/app/soapbox/reducers/compose.ts b/app/soapbox/reducers/compose.ts
index 3b861fb58..d2fd1b3fe 100644
--- a/app/soapbox/reducers/compose.ts
+++ b/app/soapbox/reducers/compose.ts
@@ -460,8 +460,9 @@ export default function compose(state = ReducerRecord({ idempotencyKey: uuid(),
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('content_type', action.contentType || 'text/plain');
+ map.set('quote', action.status.get('quote'));
- if (action.v?.software === PLEROMA && hasIntegerMediaIds(action.status)) {
+ if (action.v?.software === PLEROMA && !action.withRedraft && hasIntegerMediaIds(action.status)) {
map.set('media_attachments', ImmutableList());
} else {
map.set('media_attachments', action.status.media_attachments);
diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts
index 72b33f785..fda62e79e 100644
--- a/app/soapbox/reducers/index.ts
+++ b/app/soapbox/reducers/index.ts
@@ -12,6 +12,7 @@ import admin from './admin';
import admin_log from './admin_log';
import alerts from './alerts';
import aliases from './aliases';
+import announcements from './announcements';
import auth from './auth';
import backups from './backups';
import carousels from './carousels';
@@ -124,6 +125,7 @@ const reducers = {
rules,
history,
carousels,
+ announcements,
};
// Build a default state from all reducers: it has the key and `undefined`
diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts
index 37572ae24..4a8a80858 100644
--- a/app/soapbox/types/entities.ts
+++ b/app/soapbox/types/entities.ts
@@ -2,6 +2,8 @@ import {
AdminAccountRecord,
AdminReportRecord,
AccountRecord,
+ AnnouncementRecord,
+ AnnouncementReactionRecord,
AttachmentRecord,
CardRecord,
ChatRecord,
@@ -26,6 +28,8 @@ import type { Record as ImmutableRecord } from 'immutable';
type AdminAccount = ReturnType;
type AdminReport = ReturnType;
+type Announcement = ReturnType;
+type AnnouncementReaction = ReturnType;
type Attachment = ReturnType;
type Card = ReturnType;
type Chat = ReturnType;
@@ -64,6 +68,8 @@ export {
AdminAccount,
AdminReport,
Account,
+ Announcement,
+ AnnouncementReaction,
Attachment,
Card,
Chat,
diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts
index 0afe0c254..7d4c13dbb 100644
--- a/app/soapbox/utils/features.ts
+++ b/app/soapbox/utils/features.ts
@@ -126,7 +126,7 @@ const getInstanceFeatures = (instance: Instance) => {
accountNotifies: any([
v.software === MASTODON && gte(v.compatVersion, '3.3.0'),
v.software === PLEROMA && gte(v.version, '2.4.50'),
- // v.software === TRUTHSOCIAL,
+ v.software === TRUTHSOCIAL,
]),
/**
@@ -142,6 +142,25 @@ const getInstanceFeatures = (instance: Instance) => {
*/
accountWebsite: v.software === TRUTHSOCIAL,
+ /**
+ * Can display announcements set by admins.
+ * @see GET /api/v1/announcements
+ * @see POST /api/v1/announcements/:id/dismiss
+ * @see {@link https://docs.joinmastodon.org/methods/announcements/}
+ */
+ announcements: any([
+ v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
+ v.software === PLEROMA && gte(v.version, '2.2.49'),
+ ]),
+
+ /**
+ * Can emoji react to announcements set by admins.
+ * @see PUT /api/v1/announcements/:id/reactions/:name
+ * @see DELETE /api/v1/announcements/:id/reactions/:name
+ * @see {@link https://docs.joinmastodon.org/methods/announcements/}
+ */
+ announcementsReactions: v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
+
/**
* Set your birthday and view upcoming birthdays.
* @see GET /api/v1/pleroma/birthdays
@@ -257,6 +276,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** Whether the accounts who favourited or emoji-reacted to a status can be viewed through the API. */
exposableReactions: any([
v.software === MASTODON,
+ v.software === TRUTHSOCIAL,
features.includes('exposable_reactions'),
]),
@@ -276,7 +296,10 @@ const getInstanceFeatures = (instance: Instance) => {
* Can edit and manage timeline filters (aka "muted words").
* @see {@link https://docs.joinmastodon.org/methods/accounts/filters/}
*/
- filters: v.software !== TRUTHSOCIAL,
+ filters: any([
+ v.software === MASTODON && lt(v.compatVersion, '3.6.0'),
+ v.software === PLEROMA,
+ ]),
/**
* Allows setting the focal point of a media attachment.
@@ -406,6 +429,7 @@ const getInstanceFeatures = (instance: Instance) => {
polls: any([
v.software === MASTODON && gte(v.version, '2.8.0'),
v.software === PLEROMA,
+ v.software === TRUTHSOCIAL,
]),
/**
diff --git a/app/styles/components/columns.scss b/app/styles/components/columns.scss
index 4d540e42a..e0f73e6d9 100644
--- a/app/styles/components/columns.scss
+++ b/app/styles/components/columns.scss
@@ -621,10 +621,12 @@
top: 12px;
right: 14px;
- .react-toggle-track-check,
+ .react-toggle-track-check {
+ left: 6px;
+ }
+
.react-toggle-track-x {
- height: 16px;
- color: white;
+ right: 8px;
}
}
diff --git a/app/styles/components/datepicker.scss b/app/styles/components/datepicker.scss
index ed5a5d839..6d7552a80 100644
--- a/app/styles/components/datepicker.scss
+++ b/app/styles/components/datepicker.scss
@@ -3,7 +3,7 @@
}
.react-datepicker__input-container > input {
- @apply dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500;
+ @apply dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-primary-500 focus:border-primary-500;
&.has-error {
@apply text-red-600 border-red-600;
@@ -132,3 +132,10 @@
.react-datepicker__year-text--keyboard-selected {
@apply bg-primary-50 hover:bg-primary-100 dark:bg-slate-700 dark:hover:bg-slate-600 text-primary-600 dark:text-primary-400;
}
+
+.react-datepicker__close-icon::after {
+ @apply bg-transparent text-gray-600 dark:text-gray-400 text-base;
+ font-family: 'Font Awesome 5 Free';
+ content: "";
+ font-weight: 900;
+}
diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss
index 242d91beb..b063e1d0a 100644
--- a/app/styles/components/status.scss
+++ b/app/styles/components/status.scss
@@ -78,6 +78,17 @@
padding: 5px;
}
}
+
+ &--quote {
+ ul,
+ ol {
+ @apply pl-4;
+ }
+
+ blockquote {
+ @apply pl-2;
+ }
+ }
}
.status__content > ul,
diff --git a/docs/administration/deploy-at-scale.md b/docs/administration/deploy-at-scale.md
index cbc231ac6..9d413fb0a 100644
--- a/docs/administration/deploy-at-scale.md
+++ b/docs/administration/deploy-at-scale.md
@@ -109,17 +109,4 @@ AKA "why don't links to my website show a preview when posted on Facebook/Twitte
Deploying with Nginx means that you forego the link preview functionality offered by Pleroma and Mastodon, since Soapbox has no knowledge of the backend whatsoever.
-This problem has no official solution, but we have some ideas:
-
-1. Serve different content to link crawlers based on their `user-agent`.
-2. Inject metadata into `index.html` somehow based on the URL.
-
-The first solution is probably the most straightforward, and can be achieved in Nginx like so:
-
-```nginx
-if ($http_user_agent ~* "googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp") {
- # TODO: route to backend?
-}
-```
-
-See [this snippet](https://gist.github.com/thoop/8165802) for more information.
+Our official solution is [Soapbox Worker](https://gitlab.com/soapbox-pub/soapbox-worker), a Cloudflare Worker that intercepts the reqest/response and injects metadata into the page by querying the API behind the scenes.
diff --git a/docs/contributing.md b/docs/contributing.md
index ba2fa09ac..47a7b747d 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -1,55 +1,34 @@
# Contributing to Soapbox
-When contributing to Soapbox, please first discuss the change you wish to make via issue,
-email, or any other method with the owners of this repository before making a change.
-
-## Project Contribution Flow
-
-It is recommended that you use the following guidelines to contribute to the Soapbox project:
-
-* Understand recommended [GitLab Flow](https://www.youtube.com/watch?v=InKNIvky2KE) methods on branch management
-* Use the following branch management process:
- * Pull a fork
- * Mirror the fork against the original repository, setting the mirror to only mirror to protected branches
- * Set the master branch in your fork to Protected
- * Never modify the master branch in your fork, so that your fork mirroring does not break
- * Pull branches in your fork to solve specific issues
- * Do merge requests only to the original repository master branch, so that your fork mirroring does not break
-* If you don't use the above policy, when your mirrored fork breaks mirroring, you can force your fork to back to successful mirroring using the following process:
- * Unprotect the master branch of your fork from force push
- * Use the following git commands from the cmd line of your local copy of your fork's master branch
- ```
- git remote add upstream /url/to/original/repo
- git fetch upstream
- git checkout master
- git reset --hard upstream/master
- git push origin master --force
- ```
- * Re-protect the master branch of your fork from force push
-
-## Pull Request Process
-
-1. Ensure any install or build dependencies are removed before the end of the layer when doing a
- build.
-2. Update the README.md with details of changes to the interface, this includes new environment
- variables, exposed ports, useful file locations and container parameters.
-3. Increase the version numbers in any examples files and the README.md to the new version that this
- Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).
-4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you
- do not have permission to do that, you may request the second reviewer to merge it for you.
-
-## Text Editor Tools
-
-If you're using a text editor like [Atom](https://atom.io/) or [Visual Studio Code](https://code.visualstudio.com/), you can install tools to help you get linter feedback while you write code for the Soapbox project.
-
-For Atom, you can install the following packages:
-
-* [linter](https://atom.io/packages/linter)
-* [linter-ui-default](https://atom.io/packages/linter-ui-default)
-* [linter-eslint](https://atom.io/packages/linter-eslint)
-* [linter-stylelint](https://atom.io/packages/linter-stylelint)
-
-For Visual Studio Code, you can install the following extensions:
-
-* [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
-* [vscode-stylelint](https://marketplace.visualstudio.com/items?itemName=stylelint.vscode-stylelint)
+Thank you for your interest in Soapbox!
+
+When contributing to Soapbox, please first discuss the change you wish to make by [opening an issue](https://gitlab.com/soapbox-pub/soapbox-fe/-/issues).
+
+## Opening an MR (merge request)
+
+1. Smash that "fork" button on GitLab to make a copy of the repo.
+2. Clone the repo locally, then begin work on a new branch (eg not `develop`).
+3. Push your branch to your fork.
+4. Once pushed, GitLab should provide you with a URL to open a new merge request right in your terminal. If not, do it [manually](https://gitlab.com/soapbox-pub/soapbox-fe/-/merge_requests/new).
+
+### Ensuring the CI pipeline succeeds
+
+When you push to a branch, the CI pipeline will run.
+
+[Soapbox uses GitLab CI](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/.gitlab-ci.yml) to lint, run tests, and verify changes.
+It's important this pipeline passes, otherwise we cannot merge the change.
+
+New users of gitlab.com may see a "detatched pipeline" error.
+If so, please check the following:
+
+1. Your GitLab email address is confirmed.
+2. You may have to have a credit card on file before the CI job will run.
+
+## Text editor
+
+We recommend developing Soapbox with [VSCodium](https://vscodium.com/) (or its proprietary ancestor, [VS Code](https://code.visualstudio.com/)).
+
+This will help give you feedback about your changes _in the editor itself_ before GitLab CI performs linting, etc.
+
+When this project is opened in Code it will automatically recommend extensions.
+See [`.vscode/extensions.json`](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/.vscode/extensions.json) for the full list.
diff --git a/docs/customization.md b/docs/customization.md
index 9c0a31301..2b5b38276 100644
--- a/docs/customization.md
+++ b/docs/customization.md
@@ -1,110 +1,26 @@
# Customizing Soapbox
-If you haven't already, [install Soapbox](../installing). But before you install soapbox, you should consider how Soapbox is installed, by default.
+Soapbox uses your own site's name and branding throughout the interface.
+This allows every Soapbox site to be different, and catered to a particular audience.
+Unlike Mastodon, which uses the "Mastodon" branding on all instances, Soapbox does not refer to itself in the user interface.
-Soapbox, by default, is installed to replace the default Pleroma front end. By extension, the Pleroma Masto front end continues to be available at the `/web` sub-URL, which you can reference, if you'd like, in the `promoPanel` section of `soapbox.json`
+## Backend settings
-There are two main places Soapbox gets its configuration:
+The site's name and description are **configured in the backend itself.**
+These are settings global to your website, and will also affect mobile apps and other frontends accessing your website.
-- `/opt/pleroma/config/prod.secret.exs`
+- On Mastodon, you can change it through the admin interface.
+- On Pleroma, it can be edited through AdminFE, or by editing `config/prod.secret.exs` on the server.
-- `/opt/pleroma/instance/static/instance/soapbox.json`
+These settings are exposed through the API under GET `/api/v1/instance`.
-Logos, branding, etc. take place in the `soapbox.json` file.
-For example:
+## Soapbox settings
-```json
-{
- "logo": "/instance/images/soapbox-logo.svg",
- "brandColor": "#0482d8",
- "promoPanel": {
- "items": [{
- "icon": "area-chart",
- "text": "Our Site stats",
- "url": "https://fediverse.network/example.com"
- }, {
- "icon": "comment-o",
- "text": "Our Site blog",
- "url": "https://blog.example.com"
- }]
- },
- "extensions": {
- "patron": false
- },
- "defaultSettings": {
- "autoPlayGif": false,
- "themeMode": "light"
- },
- "copyright": "♡2020. Copying is an act of love. Please copy and share.",
- "customCss": [
- "/instance/static/your_file_here.css"
- ],
- "navlinks": {
- "homeFooter": [
- { "title": "About", "url": "/about" },
- { "title": "Terms of Service", "url": "/about/tos" },
- { "title": "Privacy Policy", "url": "/about/privacy" },
- { "title": "DMCA", "url": "/about/dmca" },
- { "title": "Source Code", "url": "/about#opensource" }
- ]
- }
-}
-```
+Most settings are specific to your Soapbox installation and not the entire website.
+That includes the logo, default theme, and more.
-Customizable features include:
+- On Pleroma, admins can edit these settings directly from Soapbox. Just click "Soapbox config" in the sidebar, or navigate directly to `/soapbox/config`.
+- On Mastodon, admins need to upload a JSON file with the settings, and make it available at `https://yoursite.tld/instance/soapbox.json`.
-* Instance name
-* Site logo
-* Promo panel list items, e.g. blog site link
-* Favicon
-* About pages
-* Default user settings
-* Cascadomg Style Sheets (CSS)
-
-## Instance Name
-Instance name is edited during the Pleroma installation step or via AdminFE.
-
-## Instance Description
-Instance description is edited during the Pleroma installation step or via AdminFE.
-
-## Captcha on Registration Page
-Use of the Captcha feature on the registration page is configured during the Pleroma installation step or via AdminFE.
-
-## Site Logo, Brand Color, and Promo Panel List Items
-The site logo, brand color, and promo panel list items are customized by copying `soapbox.example.json` in the `static/instance` folder to `soapbox.json` and editing that file. It is recommended that you test your edited soapbox.json file in a JSON validator, such as [JSONLint](https://jsonlint.com/), before using it.
-
-The icon names for the promo panel list items can be source from [Line Awesome](https://icons8.com/line-awesome). Note that you should hover over or click a selected icon to see what the icon's real name is, e.g. `world`
-
-The site logo, in SVG format, is rendered to be able to allow the site theme colors to appear in the less than 100% opaque sections of the logo.
-The logo colors are rendered in a color that provides contrast for the site theme.
-
-The `navlinks` section of the `soapbox.json` file references the links that are displayed at the bottom of the Registration/Login, About, Terms of Service, Privacy Policy and Copyright Policy (DMCA) pages.
-
-The `brandColor` in `soapbox.json` refers to the main color upon which the look of soapbox-fe is defined.
-
-After editing your HTML files and folder names, save the file and refresh your browser.
-
-## Favicon
-The favicon is customized by dropping a favicon.png file into the `/static` folder and refreshing your browser.
-
-## About Pages
-Soapbox supports any number of custom HTML pages under `yoursite.com/about/:slug`.
-
-The finder will search `/opt/pleroma/instance/static/instance/about/:slug.html` to find your page.
-Use the name `index.html` for the root page.
-
-Example templates are available for editing in the `static/instance/about.example` folder, such as:
-* index.html
-* tos.html
-* privacy.html
-* dmca.html
-
-Simply rename `about.example` to `about`, or create your own.
-
-The `soapbox.json` file navlinks section's default URL values are pointing to the above file location, when the `about.example` folder is renamed to `about`
-These four template files have placeholders in them, e.g. "Your_Instance", that should be edited to match your Soapbox instance configuration, and will be meaningless to your users until you edit them.
-
-## Alternate Soapbox URL Root Location
-If you want to install Soapbox at an alternate URL, allowing you to potentially run more than 2 front ends on a Pleroma server, you can consider deploying the Nginx config created by @a1batross, available [here](https://git.mentality.rip/a1batross/soapbox-nginx-config/src/branch/master/soapbox.nginx)
-
-Tech support is limited for this level of customization
+If using Pleroma, these settings are exposed through the API under GET `/api/pleroma/frontend_configurations`.
+Otherwise, the settings need to be uploaded manually and made available at GET `/instance/soapbox.json`.
diff --git a/docs/development/developing-backend.md b/docs/development/developing-backend.md
new file mode 100644
index 000000000..af4400e9f
--- /dev/null
+++ b/docs/development/developing-backend.md
@@ -0,0 +1,76 @@
+# Developing a backend
+
+Soapbox expects backends to implement the [Mastodon API](https://docs.joinmastodon.org/methods/).
+
+At the very least:
+
+- [instance](https://docs.joinmastodon.org/methods/instance/)
+- [apps](https://docs.joinmastodon.org/methods/apps/)
+- [oauth](https://docs.joinmastodon.org/methods/apps/oauth/)
+- [accounts](https://docs.joinmastodon.org/methods/accounts/)
+- [statuses](https://docs.joinmastodon.org/methods/statuses/)
+
+Soapbox uses feature-detection on the instance to determine which features to show.
+By default, a minimal featureset is used.
+
+## Feature detection
+
+First thing, Soapbox fetches GET `/api/v1/instance` to identify the backend.
+The instance should respond with a `version` string:
+
+```js
+{
+ "title": "Soapbox",
+ "short_description": "hello world!",
+ // ...
+ "version": "2.7.2 (compatible; Pleroma 2.4.52+soapbox)"
+}
+```
+
+The version string should match this format:
+
+```
+COMPAT_VERSION (compatible; BACKEND_NAME VERSION)
+```
+
+The Regex used to parse it:
+
+```js
+/^([\w+.]*)(?: \(compatible; ([\w]*) (.*)\))?$/
+```
+
+- `COMPAT_VERSION` - The highest Mastodon API version this backend is compatible with. If you're not sure, use a lower version like `2.7.2`. It MUST follow [semver](https://semver.org/).
+- `BACKEND_NAME` - Human-readable name of the backend. No spaces!
+- `VERSION` - The actual version of the backend. It MUST follow [semver](https://semver.org/).
+
+Typically checks are done against `BACKEND_NAME` and `VERSION`.
+
+The version string is similar in purpose to a [User-Agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) string.
+The format was first invented by Pleroma, but is now widely used, including by Pixelfed, Mitra, and Soapbox BE.
+
+See [`features.ts`](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/app/soapbox/utils/features.ts) for the complete list of features.
+
+## Forks of other software
+
+If your software is a fork of another software, the version string should indicate that.
+Otherwise, Soapbox will use the minimal featureset.
+
+### Forks of Mastodon
+
+Mastodon forks do not need the compat section, and can simply append `+[NAME]` to the version string (eg Glitch Social):
+
+```
+3.2.0+glitch
+```
+
+### Forks of Pleroma
+
+For Pleroma forks, the fork name should be in the compat section (eg Soapbox BE):
+
+```
+2.7.2 (compatible; Pleroma 2.4.52+soapbox)
+```
+
+## Adding support for a new backend
+
+If the backend conforms to the above format, please modify [`features.ts`](https://gitlab.com/soapbox-pub/soapbox-fe/-/blob/develop/app/soapbox/utils/features.ts) and submit a merge request to enable features for your backend!
diff --git a/docs/development/how-it-works.md b/docs/development/how-it-works.md
index d0fa66f01..e5c4c7bc4 100644
--- a/docs/development/how-it-works.md
+++ b/docs/development/how-it-works.md
@@ -5,4 +5,20 @@ 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-develop.pleroma.social/backend/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.
diff --git a/docs/development/live-backend.md b/docs/development/live-backend.md
deleted file mode 100644
index 1857324db..000000000
--- a/docs/development/live-backend.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# 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:
-
-```
-cp .env.example .env
-```
-
-And edit `.env`, setting the configuration like this:
-
-```
-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.
diff --git a/docs/development/running-locally.md b/docs/development/running-locally.md
index 8a8c655b2..d11c59396 100644
--- a/docs/development/running-locally.md
+++ b/docs/development/running-locally.md
@@ -23,16 +23,22 @@ 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`
Create a `.env` file if you haven't already.
-```
+```sh
cp .env.example .env
```
And ensure that it contains `NODE_ENV=development`.
Try again.
+
+## Troubleshooting: it's not working!
+
+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/).
diff --git a/docs/store.md b/docs/store.md
deleted file mode 100644
index 994059c22..000000000
--- a/docs/store.md
+++ /dev/null
@@ -1,930 +0,0 @@
-# Redux Store Map
-
-A big part of what makes soapbox-fe function is the [Redux](https://redux.js.org/) store.
-Redux is basically a database of everything your frontend needs to know about in the form of a giant JSON object.
-
-To work with Redux, you will want to install the [Redux browser extension](https://extension.remotedev.io/).
-This will allow you to see the full Redux store when working in development.
-
-Due to the large size of the Redux store in soapbox-fe, it's worth documenting the purpose of each path.
-
-If it's not documented, it's because I inherited it from Mastodon and I don't know what it does yet.
-
-- `dropdown_menu`
-
- Sample:
- ```
- dropdown_menu: {
- openId: null,
- placement: null,
- keyboard: false
- }
- ```
-
-- `timelines`
-
- Sample:
- ```
- timelines: {
- home: {
- items: [
- '9uiMtlRMLHBnRg8tMG',
- '9uiLe5Q6Bsb8p8VslU',
- '9uiLMqdbtfE03Tc4uW',
- '9uiLEal13YvYUB8lN2',
- '9uiKwwSPdc0iZg1SUK',
- '9uiKq5TRiRJGVoEmau',
- '9uiKbTN4aHsmHgHtsO',
- '9ugVkEfNKtvGSpJGLI'
- ],
- totalQueuedItemsCount: 0,
- queuedItems: [],
- hasMore: true,
- unread: 0,
- isLoading: false,
- online: true,
- top: true,
- isPartial: false
- }
- }
- ```
-
-- `meta` - User-specific data that is _not_ a frontend setting (see: `settings`).
-
- Sample:
- ```
- meta: {
- pleroma: {
- unread_conversation_count: 0,
- hide_follows: false,
- hide_followers_count: false,
- background_image: 'https://dev.teci.world/media/74644a40461bb85fa41db02547b656fa382e0e2ada29021059ff2a2956c1bbab.jpg',
- confirmation_pending: false,
- is_moderator: false,
- deactivated: false,
- chat_token: 'SFMyNTY.g3QAAAACZAAEZGF0YW0AAAASOXRvMU5QeVM5OEo4Y2RpY1JFZAAGc2lnbmVkbgYAcH3yxnEB.qD9qQzEfRH4sfJQfPCJQKHayVUQ6_1m6t5iqE7jB17Q',
- allow_following_move: true,
- hide_follows_count: false,
- notification_settings: {
- followers: true,
- follows: true,
- non_followers: true,
- non_follows: true,
- privacy_option: false
- },
- hide_followers: false,
- relationship: {
- showing_reblogs: true,
- followed_by: false,
- subscribing: false,
- blocked_by: false,
- requested: false,
- domain_blocking: false,
- following: false,
- endorsed: false,
- blocking: false,
- muting: false,
- id: '9to1NPyS98J8cdicRE',
- muting_notifications: false
- },
- tags: [],
- hide_favorites: true,
- is_admin: true,
- skip_thread_containment: false
- }
- }
- ```
-
-- `pleroma` - Pleroma specific metadata about the user pulled from `/api/v1/accounts/verify_credentials` (excluding the pleroma_settings_store)
-
-- `alerts`
-
- Sample:
- ```
- alerts: []
- ```
-
-- `modal`
-
- Sample:
- ```
- modal: {
- modalType: null,
- modalProps: {}
- }
- ```
-- `user_lists`
-
- Sample:
- ```
- user_lists: {
- reblogged_by: {},
- blocks: {},
- groups_removed_accounts: {},
- following: {},
- follow_requests: {},
- groups: {},
- followers: {},
- mutes: {},
- favourited_by: {},
- birthday_reminders: {}
- }
- ```
-
-- `domain_lists`
-
- Sample:
- ```
- domain_lists: {
- blocks: {
- items: []
- }
- }
- ```
-
-- `status_lists`
-
- Sample:
- ```
- status_lists: {
- favourites: {
- next: null,
- loaded: false,
- items: [
- '9uh7FiM4hViVp59hSa',
- '9uhsxmGKEMBkWoykng'
- ]
- },
- pins: {
- next: null,
- loaded: false,
- items: []
- }
- }
- ```
-- `accounts` - Data for all accounts you've viewed since launching the page, so they don't have to be downloaded twice.
-
- Sample:
- ```
- accounts: {
- '9to1NPyS98J8cdicRE': {
- header_static: 'https://dev.teci.world/media/27272c6f53a8a535d2c11a98d3b3473833bf80192e82347548b9f1b6dc4027ab.jpg',
- display_name_html: 'crockwave',
- follow_requests_count: 0,
- bot: false,
- display_name: 'crockwave',
- created_at: '2020-04-07T16:29:04.000Z',
- locked: false,
- emojis: [],
- header: 'https://dev.teci.world/media/27272c6f53a8a535d2c11a98d3b3473833bf80192e82347548b9f1b6dc4027ab.jpg',
- url: 'https://dev.teci.world/users/curtis',
- note: '',
- acct: 'curtis',
- avatar_static: 'https://dev.teci.world/media/3e41f0e4e0b7e673959061f90c69a57ff547bd48ccca90df5d46be87a874febd.png',
- username: 'curtis',
- avatar: 'https://dev.teci.world/media/3e41f0e4e0b7e673959061f90c69a57ff547bd48ccca90df5d46be87a874febd.png',
- fields: [],
- pleroma: {
- unread_conversation_count: 0,
- hide_follows: false,
- hide_followers_count: false,
- background_image: 'https://dev.teci.world/media/74644a40461bb85fa41db02547b656fa382e0e2ada29021059ff2a2956c1bbab.jpg',
- confirmation_pending: false,
- is_moderator: false,
- deactivated: false,
- allow_following_move: true,
- hide_follows_count: false,
- notification_settings: {
- followers: true,
- follows: true,
- non_followers: true,
- non_follows: true,
- privacy_option: false
- },
- hide_followers: false,
- relationship: {
- showing_reblogs: true,
- followed_by: false,
- subscribing: false,
- blocked_by: false,
- requested: false,
- domain_blocking: false,
- following: false,
- endorsed: false,
- blocking: false,
- muting: false,
- id: '9to1NPyS98J8cdicRE',
- muting_notifications: false
- },
- tags: [],
- hide_favorites: true,
- is_admin: true,
- skip_thread_containment: false
- },
- source: {
- fields: [],
- note: '',
- pleroma: {
- actor_type: 'Person',
- discoverable: false,
- no_rich_text: false,
- show_role: true
- },
- privacy: 'public',
- sensitive: false
- },
- id: '9to1NPyS98J8cdicRE',
- note_emojified: ''
- }
- }
- ```
-
-- `accounts_counters`
-
- Sample:
- ```
- accounts_counters: {
- '9tyANut1gDEkHqrvo8': {
- followers_count: 0,
- following_count: 0,
- statuses_count: 11
- },
- '9toQ7nsnbhnTcNVBxI': {
- followers_count: 342,
- following_count: 800,
- statuses_count: 721
- },
- '9tqzs9mEQIBxYPBk0G': {
- followers_count: 0,
- following_count: 0,
- statuses_count: 48
- }
- }
- ```
-
-- `statuses` - Data for all statuses you've viewed since launching the page, so they don't have to be downloaded twice.
-
- Sample:
- ```
- statuses: {
- '9uVxGSYFo6ooon0ebQ': {
- in_reply_to_account_id: null,
- contentHtml: 'jpg test @curtis
',
- mentions: [
- {
- acct: 'curtis',
- id: '9to1NPyS98J8cdicRE',
- url: 'https://dev.teci.world/users/curtis',
- username: 'curtis'
- }
- ],
- created_at: '2020-04-28T21:10:16.000Z',
- spoiler_text: '',
- hidden: false,
- muted: false,
- uri: 'https://gleasonator.com/users/crockwave/statuses/104078260079111405',
- spoilerHtml: '',
- emojis: [],
- account: '9toTIlRPKG2j5obki8',
- reblogs_count: 0,
- url: 'https://gleasonator.com/@crockwave/posts/104078260079111405',
- application: {
- name: 'Web',
- website: null
- },
- card: null,
- in_reply_to_id: null,
- reblogged: false,
- visibility: 'public',
- bookmarked: false,
- reblog: null,
- media_attachments: [
- {
- description: null,
- id: '1375732379',
- pleroma: {
- mime_type: 'image/jpeg'
- },
- preview_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
- remote_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
- text_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
- type: 'image',
- url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg'
- }
- ],
- sensitive: false,
- replies_count: 0,
- language: null,
- pinned: false,
- tags: [],
- content: 'jpg test @curtis
',
- favourites_count: 0,
- pleroma: {
- direct_conversation_id: null,
- spoiler_text: {
- 'text/plain': ''
- },
- local: false,
- emoji_reactions: [],
- thread_muted: false,
- conversation_id: 1951,
- content: {
- 'text/plain': 'jpg test @curtis'
- },
- in_reply_to_account_acct: null,
- expires_at: null
- },
- favourited: false,
- id: '9uVxGSYFo6ooon0ebQ',
- search_index: 'jpg test @curtis',
- poll: null
- }
- }
- ```
-
-- `relationships`
-
- Sample:
- ```
- relationships: {}
- ```
-
-- `settings` - Any frontend configuration values that should be persisted to the backend database. This includes user preferences as well as metadata such as emoji usage counters. It uses [`pleroma_settings_store`](https://docs-develop.pleroma.social/backend/API/differences_in_mastoapi_responses/#accounts) to do it if it's available. If there's some other endpoint that handles your value, it doesn't belong here.
-
- Sample:
- ```
- settings: {
- autoPlayGif: true,
- displayMedia: 'default',
- deleteModal: true,
- unfollowModal: false,
- frequentlyUsedEmojis: {
- grinning: 1,
- 'star-struck': 1
- },
- onboarded: false,
- defaultPrivacy: 'private',
- demetricator: false,
- saved: true,
- notifications: {
- alerts: {
- favourite: true,
- follow: true,
- mention: true,
- poll: true,
- reblog: true
- },
- quickFilter: {
- active: 'all',
- advanced: false,
- show: true
- },
- shows: {
- favourite: true,
- follow: true,
- mention: true,
- poll: true,
- reblog: true
- },
- sounds: {
- favourite: true,
- follow: true,
- mention: true,
- poll: true,
- reblog: true
- },
- birthdays: {
- show: true
- }
- },
- theme: 'azure',
- 'public': {
- other: {
- onlyMedia: false
- },
- regex: {
- body: ''
- }
- },
- direct: {
- regex: {
- body: ''
- }
- },
- community: {
- other: {
- onlyMedia: false
- },
- regex: {
- body: ''
- }
- },
- boostModal: false,
- dyslexicFont: false,
- expandSpoilers: false,
- skinTone: 1,
- trends: {
- show: true
- },
- reduceMotion: false,
- columns: [
- {
- id: 'COMPOSE',
- params: {},
- uuid: '8200299a-f689-45ad-ad33-c9eb20b6286c'
- },
- {
- id: 'HOME',
- params: {},
- uuid: '1b1f69f4-d024-4d31-b5cd-b45fe77f4dc1'
- },
- {
- id: 'NOTIFICATIONS',
- params: {},
- uuid: 'e8c3904c-bf54-4047-baaa-aa786afebb3b'
- }
- ],
- systemFont: false,
- underlineLinks: false,
- home: {
- regex: {
- body: ''
- },
- shows: {
- reblog: true,
- reply: true
- }
- }
- }
- ```
-
-- `push_notifications`
-
- Sample:
- ```
- push_notifications: {
- subscription: null,
- alerts: {
- follow: false,
- favourite: false,
- reblog: false,
- mention: false,
- poll: false
- },
- isSubscribed: false,
- browserSupport: false
- }
- ```
-- `mutes`
-
- Sample:
- ```
- mutes: {
- 'new': {
- isSubmitting: false,
- account: null,
- notifications: true
- }
- }
- ```
-
-- `reports`
-
- Sample:
- ```
- reports: {
- 'new': {
- isSubmitting: false,
- account_id: null,
- status_ids: [],
- comment: '',
- forward: false
- }
- }
- ```
-
-- `contexts`
-
- Sample:
- ```
- contexts: {
- inReplyTos: {
- '9uhsxm9adOniBvpNIm': '9uh7FiM4hViVp59hSa',
- '9uiMtlRMLHBnRg8tMG': '9uiIk2f13yfg8mdfhg',
- '9uiLe5Q6Bsb8p8VslU': '9uiIk2f13yfg8mdfhg',
- '9uhBdzVeyImLnGTDZQ': '9uhB399i946ozmdRGC',
- '9uiKLrbohWVWp5k0Su': '9uiJzdGZLWjBy9Ca24',
- '9ui47WONBnvPhQalgu': '9ui47WHdaqXNMXROC0',
- '9ui5t93pL19HC0FppI': '9ui5qe5DXbA8XQiFyS',
- },
- replies: {
- '9uhsxm9adOniBvpNIm': [
- '9uhsxmGKEMBkWoykng'
- ],
- '9ui8gFu0tBewVfD38y': [
- '9ui8gG1SRVc8skgzkO'
- ],
- '9uiIk2f13yfg8mdfhg': [
- '9uiJxjFm7BylxVvHPc',
- '9uiJzdGZLWjBy9Ca24',
- '9uiLe5Q6Bsb8p8VslU',
- '9uiMtlRMLHBnRg8tMG'
- ],
- '9uiKLrbohWVWp5k0Su': [
- '9uiKbTN4aHsmHgHtsO'
- ],
- '9ui68mCA7SZwuSbfqi': [
- '9ui6Fz6cW4kGyiS3lo'
- ]
- }
- }
- ```
-- `compose`
-
- Sample:
- ```
- compose: {
- spoiler: false,
- focusDate: null,
- privacy: 'private',
- spoiler_text: '',
- in_reply_to: null,
- default_privacy: 'private',
- is_uploading: false,
- caretPosition: null,
- text: '',
- preselectDate: null,
- progress: 0,
- idempotencyKey: '046ddfb7-ce76-4dbd-ae43-e6e8417947fd',
- suggestions: [],
- resetFileKey: 53748,
- media_attachments: [],
- sensitive: false,
- default_sensitive: false,
- mounted: 0,
- is_composing: false,
- tagHistory: [],
- id: null,
- is_submitting: false,
- is_changing_upload: false,
- suggestion_token: null,
- poll: null
- }
- ```
-
-- `search`
-
- Sample:
- ```
- search: {
- value: '',
- submitted: false,
- hidden: false,
- results: {}
- }
- ```
-- `media_attachments`
-
- Sample:
- ```
- media_attachments: {
- accept_content_types: [
- '.jpg',
- '.jpeg',
- '.png',
- '.gif',
- '.webp',
- '.webm',
- '.mp4',
- '.m4v',
- '.mov',
- 'image/jpeg',
- 'image/png',
- 'image/gif',
- 'image/webp',
- 'video/webm',
- 'video/mp4',
- 'video/quicktime'
- ]
- }
- ```
-
-- `notifications`
-
- Sample:
- ```
- notifications: {
- items: [
- {
- id: '27',
- type: 'mention',
- account: '9uXUwPp1pwGsA2Qh3A',
- created_at: '2020-04-29T15:11:54.000Z',
- status: '9uXVnHKu7Lu9BrXvCC'
- },
- {
- id: '8',
- type: 'mention',
- account: '9toQ7nsnbhnTcNVBxI',
- created_at: '2020-04-27T19:16:44.000Z',
- status: '9uTicLRt0ZoVX25ZvE'
- },
- {
- id: '7',
- type: 'favourite',
- account: '9toQ7nsnbhnTcNVBxI',
- created_at: '2020-04-27T19:16:25.000Z',
- status: '9uThsXbbTg6luknEmG'
- }
- ],
- hasMore: true,
- top: false,
- unread: 0,
- isLoading: false,
- queuedNotifications: [],
- totalQueuedNotificationsCount: 0,
- lastRead: -1
- }
- ```
-
-- `height_cache`
-
- Sample:
- ```
- height_cache: {
- '9t06sd:home_timeline': {
- '9uXhrY530I85jJvpwW': 164.171875,
- '9uXVdgMQDqa1uGgESG': 300.140625,
- '9uXWs4FmHnJW17zncW': 852.171875,
- '9uXX4IfAXO0yBNhmQy': 166.171875,
- '9uXXThi8XzE56gCtE0': 145.140625
- }
- }
- ```
-
-- `custom_emojis`
-
- Sample:
- ```
- custom_emojis: [
- {
- category: 'Fun',
- shortcode: 'blank',
- static_url: 'https://dev.teci.world/emoji/blank.png',
- tags: [
- 'Fun'
- ],
- url: 'https://dev.teci.world/emoji/blank.png',
- visible_in_picker: true
- },
- {
- category: 'Gif,Fun',
- shortcode: 'firefox',
- static_url: 'https://dev.teci.world/emoji/Firefox.gif',
- tags: [
- 'Gif',
- 'Fun'
- ],
- url: 'https://dev.teci.world/emoji/Firefox.gif',
- visible_in_picker: true
- }
- ]
- ```
-
-- `lists`
-
- Sample:
- ```
- lists: {},
- ```
-
-- `listEditor`
-
- Sample:
- ```
- listEditor: {
- listId: null,
- isSubmitting: false,
- isChanged: false,
- title: '',
- accounts: {
- items: [],
- loaded: false,
- isLoading: false
- },
- suggestions: {
- value: '',
- items: []
- }
- }
- ```
-- `listAdder`
-
- Sample:
- ```
- listAdder: {
- accountId: null,
- lists: {
- items: [],
- loaded: false,
- isLoading: false
- }
- }
- ```
-
-- `filters`
-
- Sample:
- ```
- filters: [],
- ```
-
-- `conversations`
-
- Sample:
- ```
- conversations: {
- items: [],
- isLoading: false,
- hasMore: true,
- mounted: false
- }
- ```
-
-- `suggestions`
-
- Sample:
- ```
- suggestions: {
- items: [],
- isLoading: false
- },
- ```
-
-- `polls`
-
- Sample:
- ```
- polls: {}
- ```
-- `trends`
-
- Sample:
- ```
- trends: {
- items: [],
- isLoading: false
- }
- ```
-
-- `groups`
-
- Sample:
- ```
- groups: {}
- ```
-- `group_relationships`
-
- Sample:
- ```
- group_relationships: {}
- ```
-
-- `group_lists`
-
- Sample:
- ```
- group_lists: {
- featured: [],
- member: [],
- admin: []
- }
- ```
-
-- `group_editor`
-
- Sample:
- ```
- group_editor: {
- groupId: null,
- isSubmitting: false,
- isChanged: false,
- title: '',
- description: '',
- coverImage: null
- }
- ```
-
-- `sidebar`
-
- Sample:
- ```
- sidebar: {}
- ```
-
-- `patron` - Data related to [soapbox-patron](https://gitlab.com/soapbox-pub/soapbox-patron)
-
- Sample:
- ```
- patron: {}
- ```
-
-- `soapbox` - Soapbox specific configuration pulled from `/instance/soapbox.json`. The configuration file isn't required and this map can be empty.
-
- Sample:
- ```
- soapbox: {
- logo: 'https://support.wirelessmessaging.com/temp/tga/teci_social_logo.svg',
- promoPanel: {
- items: [
- {
- icon: 'comment-o',
- text: 'TECI blog',
- url: 'https://www.teci.world/blog'
- }
- ]
- },
- extensions: {
- patron: false
- }
- }
- ```
-
-- `instance` - Instance data pulled from `/api/v1/instance`
-
- Sample:
- ```
- instance: {
- avatar_upload_limit: 2000000,
- urls: {
- streaming_api: 'wss://dev.teci.world'
- },
- thumbnail: 'https://dev.teci.world/instance/thumbnail.jpeg',
- uri: 'https://dev.teci.world',
- background_upload_limit: 4000000,
- banner_upload_limit: 4000000,
- poll_limits: {
- max_expiration: 31536000,
- max_option_chars: 200,
- max_options: 20,
- min_expiration: 0
- },
- version: '2.7.2 (compatible; Pleroma 2.0.1)',
- title: 'TECI Dev',
- max_toot_chars: 5000,
- registrations: true,
- languages: [
- 'en'
- ],
- email: 'curtis.rock@gmail.com',
- description: 'A Pleroma instance, an alternative fediverse server',
- upload_limit: 16000000,
- stats: {
- domain_count: 161,
- status_count: 1,
- user_count: 5
- }
- }
- ```
-
-- `me` - The account ID of the currently logged in user, 'null' if loading, and 'false' if no user is logged in.
-
- Sample:
- ```
- me: '9to1NPyS98J8cdicRE'
- ```
-
-- `auth` - Data used for authentication
-
- Sample:
- ```
- auth: {
- app: {
- vapid_key: 'BEm4LT3n_cxFsGIqI-iG-Uea0OXgnjTtQAa4sPhkguP2rCbFfqL6xHOzo-cS3j9G7kG9eQ3deIQdkXbvTwgcLAk',
- token_type: 'Bearer',
- client_secret: 'ZuCeHoYy43MGifOnZyjWn82Kuq1YkeVAlwlxqvnGR6Q',
- redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
- created_at: 1587504650,
- name: 'SoapboxFE_2020-04-21T21:30:45.889Z',
- client_id: 'OyjobYI1RQcx3G6RIJ7brm2RmIy6M2hbme2oEwByjvI',
- expires_in: 600,
- scope: 'read write follow push admin',
- refresh_token: 'TNFBq7Dp6ryLkUoHHbnUp3y5c-U6ya_c7DcSnfM86wo',
- website: null,
- id: '23',
- access_token: 'aN65U4SXw2JjOeOyko1-w7KIxaJnOqtU-Z3izpdKqcg'
- },
- user: {
- access_token: 'UeWx_MgQckL993--BetNsJHcwxq1BVmtxc4qJtb-DM8',
- created_at: 1588607387,
- expires_in: 600,
- me: 'https://dev.teci.world/users/curtis',
- refresh_token: '2mbb3ZqZ9w8eeSiLRDC2SsQ86-UmVDrScmFXPx4opvw',
- scope: 'read write follow push admin',
- token_type: 'Bearer'
- }
- }
- ```
-- `app` - Map containing the app used to make app requests such as register/login and its access token.
-
-- `user` - Map containing the access token of the logged in user.