Merge remote-tracking branch 'soapbox/develop' into styles
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
b294769dfe
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"editorconfig.editorconfig",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"stylelint.vscode-stylelint",
|
||||
"wix.vscode-import-cost"
|
||||
]
|
||||
}
|
81
README.md
81
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
|
||||
### Troubleshooting: it's not working!
|
||||
|
||||
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:
|
||||
|
||||
```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:
|
||||
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
|
||||
- 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)
|
||||
More details can be found in [Customizing Soapbox](docs/customization.md).
|
||||
|
||||
# License & Credits
|
||||
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
[
|
||||
{
|
||||
"id": "1",
|
||||
"content": "<p>Updated to Soapbox v3.</p>",
|
||||
"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": "<p>Rolled back to Soapbox v2 for now.</p>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
|
@ -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<typeof mockStore>;
|
||||
|
||||
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<typeof mockStore>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<typeof mockStore>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}],
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -91,7 +91,7 @@ const fetchTruthSuggestions = (params: Record<string, any> = {}) =>
|
|||
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;
|
||||
})
|
||||
|
|
|
@ -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<IAnimatedNumber> = ({ value, obfuscate }) => {
|
||||
const reduceMotion = useSettings().get('reduceMotion');
|
||||
|
||||
const [direction, setDirection] = useState(1);
|
||||
const [displayedValue, setDisplayedValue] = useState<number>(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)}</> : <FormattedNumber value={displayedValue} />;
|
||||
}
|
||||
|
||||
const styles = [{
|
||||
key: `${displayedValue}`,
|
||||
data: displayedValue,
|
||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
||||
}];
|
||||
|
||||
return (
|
||||
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
|
||||
{items => (
|
||||
<span className='inline-flex flex-col items-stretch relative overflow-hidden'>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedNumber;
|
|
@ -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<IAnnouncementContent> = ({ announcement }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const node = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
className='translate text-sm'
|
||||
ref={node}
|
||||
dangerouslySetInnerHTML={{ __html: announcement.contentHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementContent;
|
|
@ -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<string, ImmutableMap<string, string>>;
|
||||
}
|
||||
|
||||
const Announcement: React.FC<IAnnouncement> = ({ 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 (
|
||||
<Stack className='w-full' space={2}>
|
||||
{hasTimeRange && (
|
||||
<Text theme='muted'>
|
||||
<FormattedDate
|
||||
value={startsAt}
|
||||
hour12={false}
|
||||
year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'}
|
||||
/>
|
||||
{' '}
|
||||
-
|
||||
{' '}
|
||||
<FormattedDate
|
||||
value={endsAt}
|
||||
hour12={false}
|
||||
year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
|
||||
month={skipEndDate ? undefined : 'short'}
|
||||
day={skipEndDate ? undefined : '2-digit'}
|
||||
hour={skipTime ? undefined : '2-digit'}
|
||||
minute={skipTime ? undefined : '2-digit'}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<AnnouncementContent announcement={announcement} />
|
||||
|
||||
{features.announcementsReactions && (
|
||||
<ReactionsBar
|
||||
reactions={announcement.reactions}
|
||||
announcementId={announcement.id}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Announcement;
|
|
@ -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<ImmutableMap<string, string>>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap<string, ImmutableMap<string, string>>()));
|
||||
|
||||
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 (
|
||||
<Widget title={<FormattedMessage id='announcements.title' defaultMessage='Announcements' />}>
|
||||
<Card className='relative' size='md' variant='rounded'>
|
||||
<ReactSwipeableViews animateHeight index={index} onChangeIndex={handleChangeIndex}>
|
||||
{announcements.map((announcement) => (
|
||||
<Announcement
|
||||
key={announcement.id}
|
||||
announcement={announcement}
|
||||
emojiMap={emojiMap}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
/>
|
||||
)).reverse()}
|
||||
</ReactSwipeableViews>
|
||||
{announcements.size > 1 && (
|
||||
<HStack space={2} alignItems='center' justifyContent='center' className='relative'>
|
||||
{announcements.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
tabIndex={0}
|
||||
onClick={() => 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,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Card>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsPanel;
|
|
@ -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<string, ImmutableMap<string, string>>;
|
||||
hovered: boolean;
|
||||
}
|
||||
|
||||
const Emoji: React.FC<IEmoji> = ({ 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 (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione block m-0'
|
||||
alt={emoji}
|
||||
title={title}
|
||||
src={joinPublicPath(`packs/emoji/${filename}.svg`)}
|
||||
/>
|
||||
);
|
||||
} else if (emojiMap.get(emoji as any)) {
|
||||
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
|
||||
const shortCode = `:${emoji}:`;
|
||||
|
||||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione block m-0'
|
||||
alt={shortCode}
|
||||
title={shortCode}
|
||||
src={filename as string}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default Emoji;
|
|
@ -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<string, ImmutableMap<string, string>>;
|
||||
addReaction: (id: string, name: string) => void;
|
||||
removeReaction: (id: string, name: string) => void;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Reaction: React.FC<IReaction> = ({ 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 (
|
||||
<button
|
||||
className={classNames('flex shrink-0 items-center gap-1.5 bg-gray-100 dark:bg-primary-900 rounded-sm px-1.5 py-1 transition-colors', {
|
||||
'bg-gray-200 dark:bg-primary-800': hovered,
|
||||
'bg-primary-200 dark:bg-primary-500': reaction.me,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={`:${shortCode}:`}
|
||||
style={style}
|
||||
>
|
||||
<span className='block h-4 w-4'>
|
||||
<Emoji hovered={hovered} emoji={reaction.name} emojiMap={emojiMap} />
|
||||
</span>
|
||||
<span className='block min-w-[9px] text-center text-xs font-medium text-primary-600 dark:text-white'>
|
||||
<AnimatedNumber value={reaction.count} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reaction;
|
|
@ -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<AnnouncementReaction>;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
addReaction: (id: string, name: string) => void;
|
||||
removeReaction: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
const ReactionsBar: React.FC<IReactionsBar> = ({ 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 (
|
||||
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
|
||||
{items => (
|
||||
<div className={classNames('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<Reaction
|
||||
key={key}
|
||||
reaction={data}
|
||||
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
|
||||
announcementId={announcementId}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} button={<Icon className='h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-white' src={require('@tabler/icons/plus.svg')} />} />}
|
||||
</div>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactionsBar;
|
|
@ -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<IAutosugges
|
|||
this.setState({ focused: true });
|
||||
}
|
||||
|
||||
onSuggestionClick: React.MouseEventHandler = (e) => {
|
||||
onSuggestionClick: React.EventHandler<React.MouseEvent | React.TouchEvent> = (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<IAutosugges
|
|||
'bg-gray-100 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-gray-700': i === selectedSuggestion,
|
||||
})}
|
||||
onMouseDown={this.onSuggestionClick}
|
||||
onTouchEnd={this.onSuggestionClick}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
|
@ -267,8 +269,22 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
));
|
||||
};
|
||||
|
||||
setPortalPosition() {
|
||||
if (!this.input) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { top, height, left, width } = this.input.getBoundingClientRect();
|
||||
|
||||
if (this.props.resultsPosition === 'below') {
|
||||
return { left, width, top: top + height };
|
||||
}
|
||||
|
||||
return { left, width, top, transform: 'translate(0, -100%)' };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, resultsPosition } = this.props;
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style: React.CSSProperties = { direction: 'ltr' };
|
||||
|
||||
|
@ -278,8 +294,8 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative w-full'>
|
||||
return [
|
||||
<div key='input' className='relative w-full'>
|
||||
<label className='sr-only'>{placeholder}</label>
|
||||
|
||||
<input
|
||||
|
@ -303,11 +319,12 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
maxLength={maxLength}
|
||||
data-testid='autosuggest-input'
|
||||
/>
|
||||
|
||||
<div className={classNames({
|
||||
'absolute w-full z-50 shadow bg-white dark:bg-slate-800 rounded-lg py-1': true,
|
||||
'top-full': resultsPosition === 'below',
|
||||
'bottom-full': resultsPosition === 'above',
|
||||
</div>,
|
||||
<Portal key='portal'>
|
||||
<div
|
||||
style={this.setPortalPosition()}
|
||||
className={classNames({
|
||||
'fixed w-full z-[1001] shadow bg-white dark:bg-slate-800 rounded-lg py-1': true,
|
||||
hidden: !visible,
|
||||
block: visible,
|
||||
})}
|
||||
|
@ -318,8 +335,8 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
|
||||
{this.renderMenu()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</Portal>,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -109,7 +109,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ 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 (
|
||||
<div className='mt-1 relative rounded-md shadow-sm'>
|
||||
|
@ -123,6 +123,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
|
|||
maxDate={maxDate}
|
||||
required={required}
|
||||
renderCustomHeader={renderCustomHeader}
|
||||
isClearable={!required}
|
||||
/>)}
|
||||
</BundleContainer>
|
||||
</div>
|
||||
|
|
|
@ -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<IHoverRefWrapper> = ({ accountId, children, inline = false, className }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile(window.innerWidth)) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
showProfileHoverCard(dispatch, ref, accountId);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -13,7 +13,6 @@ interface IPullable {
|
|||
*/
|
||||
const Pullable: React.FC<IPullable> = ({ children }) =>(
|
||||
<PullToRefresh
|
||||
pullingContent={undefined}
|
||||
// @ts-ignore
|
||||
refreshingContent={null}
|
||||
>
|
||||
|
|
|
@ -143,7 +143,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
|
|||
{renderReplyMentions()}
|
||||
|
||||
<Text
|
||||
className='break-words'
|
||||
className='break-words status__content status__content--quote'
|
||||
size='sm'
|
||||
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
|
||||
/>
|
||||
|
|
|
@ -35,7 +35,7 @@ const Card = React.forwardRef<HTMLDivElement, ICard>(({ 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}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -42,13 +41,8 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
}
|
||||
};
|
||||
|
||||
const renderChildren = () => {
|
||||
if (transparent) {
|
||||
return <div className={classNames('text-black dark:text-white', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant='rounded' className={className}>
|
||||
const renderChildren = () => (
|
||||
<Card variant={transparent ? undefined : 'rounded'} className={className}>
|
||||
{withHeader ? (
|
||||
<CardHeader onBackClick={handleBackClick}>
|
||||
<CardTitle title={label} />
|
||||
|
@ -60,7 +54,6 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
|
||||
|
|
|
@ -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 <div> 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<IHStack> = (props) => {
|
||||
const HStack = forwardRef<HTMLDivElement, IHStack>((props, ref) => {
|
||||
const { space, alignItems, grow, justifyContent, className, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...filteredProps}
|
||||
ref={ref}
|
||||
className={classNames('flex', {
|
||||
// @ts-ignore
|
||||
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
|
||||
|
@ -60,6 +63,6 @@ const HStack: React.FC<IHStack> = (props) => {
|
|||
}, className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default HStack;
|
||||
|
|
|
@ -36,7 +36,7 @@ const Bookmarks: React.FC = () => {
|
|||
const emptyMessage = <FormattedMessage id='empty_column.bookmarks' defaultMessage="You don't have any bookmarks yet. When you add one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column transparent>
|
||||
<Column transparent withHeader={false}>
|
||||
<div className='px-4 pt-4 sm:p-0'>
|
||||
<SubNavigation message={intl.formatMessage(messages.heading)} />
|
||||
</div>
|
||||
|
|
|
@ -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<IChatList> = ({ 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,8 +65,15 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false })
|
|||
return dispatch(fetchChats()) as any;
|
||||
};
|
||||
|
||||
const renderEmpty = () => isLoading ? <PlaceholderChat /> : (
|
||||
<Card className='mt-2' variant='rounded' size='lg'>
|
||||
<Text>{intl.formatMessage(messages.emptyMessage)}</Text>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{isEmpty ? renderEmpty() : (
|
||||
<Virtuoso
|
||||
className='chat-list'
|
||||
useWindowScroll={useWindowScroll}
|
||||
|
@ -76,15 +85,10 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false })
|
|||
components={{
|
||||
ScrollSeekPlaceholder: () => <PlaceholderChat />,
|
||||
Footer: () => hasMore ? <PlaceholderChat /> : null,
|
||||
EmptyPlaceholder: () => {
|
||||
if (isLoading) {
|
||||
return <PlaceholderChat />;
|
||||
} else {
|
||||
return <Text>{intl.formatMessage(messages.emptyMessage)}</Text>;
|
||||
}
|
||||
},
|
||||
EmptyPlaceholder: renderEmpty,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -43,7 +43,7 @@ const CommunityTimeline = () => {
|
|||
}, [onlyMedia]);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent>
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<Timeline
|
||||
|
|
|
@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
onPickEmoji: PropTypes.func.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
button: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -352,20 +353,14 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active, loading, placement } = this.state;
|
||||
|
||||
return (
|
||||
<div className='relative' onKeyDown={this.handleKeyDown}>
|
||||
<IconButton
|
||||
<div
|
||||
ref={this.setTargetRef}
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': true,
|
||||
'pulse-loading': active && loading,
|
||||
})}
|
||||
alt='😀'
|
||||
src={require('@tabler/icons/mood-happy.svg')}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-expanded={active}
|
||||
|
@ -373,7 +368,16 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
onClick={this.onToggle}
|
||||
onKeyDown={this.onToggle}
|
||||
tabIndex={0}
|
||||
/>
|
||||
>
|
||||
{button || <IconButton
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': true,
|
||||
'pulse-loading': active && loading,
|
||||
})}
|
||||
alt='😀'
|
||||
src={require('@tabler/icons/mood-happy.svg')}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Overlay show={active} placement={placement} target={this.findTarget}>
|
||||
<EmojiPickerMenu
|
||||
|
|
|
@ -43,6 +43,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCanc
|
|||
/>
|
||||
|
||||
<Text
|
||||
className='break-words'
|
||||
size='sm'
|
||||
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
|
||||
direction={isRtl(status.search_index) ? 'rtl' : 'ltr'}
|
||||
|
|
|
@ -67,7 +67,7 @@ const mapStateToProps = state => ({
|
|||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -40,7 +40,7 @@ const DirectTimeline = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent>
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<ColumnHeader
|
||||
icon='envelope'
|
||||
active={hasUnread}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|||
import { updateNotificationSettings } from 'soapbox/actions/accounts';
|
||||
import { patchMe } from 'soapbox/actions/me';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import BirthdayInput from 'soapbox/components/birthday_input';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import {
|
||||
Button,
|
||||
|
@ -232,6 +233,10 @@ const EditProfile: React.FC = () => {
|
|||
};
|
||||
};
|
||||
|
||||
const handleBirthdayChange = (date: string) => {
|
||||
updateData('birthday', date);
|
||||
};
|
||||
|
||||
const handleHideNetworkChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
const hide = e.target.checked;
|
||||
|
||||
|
@ -315,12 +320,9 @@ const EditProfile: React.FC = () => {
|
|||
<FormGroup
|
||||
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
<BirthdayInput
|
||||
value={data.birthday}
|
||||
onChange={handleTextChange('birthday')}
|
||||
placeholder='YYYY-MM-DD'
|
||||
pattern='\d{4}-\d{2}-\d{2}'
|
||||
onChange={handleBirthdayChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -44,7 +44,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
|
|||
<img
|
||||
src={avatar.account_avatar}
|
||||
className={classNames({
|
||||
' w-14 h-14 min-w-[56px] rounded-full ring-2 ring-offset-4 dark:ring-offset-slate-800': true,
|
||||
'w-14 h-14 min-w-[56px] rounded-full ring-2 ring-offset-4 dark:ring-offset-slate-800': true,
|
||||
'ring-transparent': !isSelected,
|
||||
'ring-primary-600': isSelected,
|
||||
})}
|
||||
|
@ -62,7 +62,7 @@ const FeedCarousel = () => {
|
|||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const [cardRef, { width }] = useDimensions();
|
||||
const [cardRef, setCardRef, { width }] = useDimensions();
|
||||
|
||||
const [pageSize, setPageSize] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(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 (
|
||||
<Card variant='rounded' size='lg' ref={cardRef} className='relative' data-testid='feed-carousel'>
|
||||
<Card variant='rounded' size='lg' className='relative' data-testid='feed-carousel'>
|
||||
<div>
|
||||
{hasPrevPage && (
|
||||
<div>
|
||||
|
@ -117,7 +118,7 @@ const FeedCarousel = () => {
|
|||
<button
|
||||
data-testid='prev-page'
|
||||
onClick={handlePrevPage}
|
||||
className='bg-white/85 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
|
||||
className='bg-white/50 dark:bg-gray-900/50 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/chevron-left.svg')} className='text-black dark:text-white h-6 w-6' />
|
||||
</button>
|
||||
|
@ -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 = () => {
|
|||
<button
|
||||
data-testid='next-page'
|
||||
onClick={handleNextPage}
|
||||
className='bg-white/85 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
|
||||
className='bg-white/50 dark:bg-gray-900/50 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/chevron-right.svg')} className='text-black dark:text-white h-6 w-6' />
|
||||
</button>
|
||||
|
|
|
@ -27,7 +27,7 @@ const SuggestionItem = ({ accountId }: { accountId: string }) => {
|
|||
<Stack space={3}>
|
||||
<img
|
||||
src={account.avatar}
|
||||
className='mx-auto block w-16 h-16 min-w-[56px] rounded-full'
|
||||
className='mx-auto block w-16 h-16 min-w-[56px] rounded-full object-cover'
|
||||
alt={account.acct}
|
||||
/>
|
||||
|
||||
|
|
|
@ -118,7 +118,7 @@ class Followers extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} withHeader={false} transparent>
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<ScrollableList
|
||||
scrollKey='followers'
|
||||
hasMore={hasMore}
|
||||
|
|
|
@ -118,7 +118,7 @@ class Following extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} withHeader={false} transparent>
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<ScrollableList
|
||||
scrollKey='following'
|
||||
hasMore={hasMore}
|
||||
|
|
|
@ -112,7 +112,7 @@ class HashtagTimeline extends React.PureComponent {
|
|||
const { id } = this.props.params;
|
||||
|
||||
return (
|
||||
<Column label={`#${id}`} transparent>
|
||||
<Column label={`#${id}`} transparent withHeader={false}>
|
||||
<ColumnHeader active={hasUnread} title={this.title()} />
|
||||
<Timeline
|
||||
scrollKey='hashtag_timeline'
|
||||
|
|
|
@ -59,7 +59,7 @@ const HomeTimeline: React.FC = () => {
|
|||
}, [isPartial]);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent>
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<Timeline
|
||||
scrollKey='home_timeline'
|
||||
|
|
|
@ -85,7 +85,7 @@ const ListTimeline: React.FC = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<Column label={title} heading={title} transparent>
|
||||
<Column label={title} heading={title} transparent withHeader={false}>
|
||||
{/* <HomeColumnHeader activeItem='lists' activeSubItem={id} active={hasUnread}>
|
||||
<div className='column-header__links'>
|
||||
<button className='text-btn column-header__setting-btn' tabIndex='0' onClick={handleEditClick}>
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -36,7 +36,7 @@ const PinnedStatuses = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent withHeader={false}>
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
scrollKey='pinned_statuses'
|
||||
|
|
|
@ -128,10 +128,13 @@ const Header = () => {
|
|||
<Input
|
||||
required
|
||||
value={username}
|
||||
onChange={(event) => 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'
|
||||
/>
|
||||
|
||||
<Input
|
||||
|
@ -141,6 +144,9 @@ const Header = () => {
|
|||
type='password'
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
className='max-w-[200px]'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
/>
|
||||
|
||||
<Link to='/reset-password'>
|
||||
|
|
|
@ -64,7 +64,7 @@ const CommunityTimeline = () => {
|
|||
}, [onlyMedia]);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent>
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
|
||||
<PinnedHostsPicker />
|
||||
{showExplanationBox && <div className='mb-4'>
|
||||
|
|
|
@ -65,7 +65,7 @@ const RemoteTimeline: React.FC<IRemoteTimeline> = ({ params }) => {
|
|||
}, [onlyMedia]);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} heading={instance} transparent>
|
||||
<Column label={intl.formatMessage(messages.title)} heading={instance} transparent withHeader={false}>
|
||||
{instance && <PinnedHostsPicker host={instance} />}
|
||||
{!pinned && <HStack className='mb-4 px-2' space={2}>
|
||||
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/x.svg')} onClick={handleCloseClick} />
|
||||
|
|
|
@ -28,12 +28,14 @@ const ColorWithPicker: React.FC<IColorWithPicker> = ({ 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 (
|
||||
|
|
|
@ -139,7 +139,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
|
|||
return (
|
||||
<HStack space={0.5} className='emoji-react p-1' alignItems='center' key={i}>
|
||||
<Emoji
|
||||
className='emoji-react__emoji w-5 h-5 flex-none cursor-pointer'
|
||||
className={classNames('emoji-react__emoji w-5 h-5 flex-none', { 'cursor-pointer': features.exposableReactions })}
|
||||
emoji={e.get('name')}
|
||||
onClick={features.exposableReactions ? handleOpenReactionsModal(e) : undefined}
|
||||
/>
|
||||
|
|
|
@ -792,7 +792,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(titleMessage, { username })} transparent>
|
||||
<Column label={intl.formatMessage(titleMessage, { username })} transparent withHeader={false}>
|
||||
<div className='px-4 pt-4 sm:p-0'>
|
||||
<SubNavigation message={intl.formatMessage(titleMessage, { username })} />
|
||||
</div>
|
||||
|
|
|
@ -38,7 +38,7 @@ const TestTimeline: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent>
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<SubNavigation message={intl.formatMessage(messages.title)} />
|
||||
<Timeline
|
||||
scrollKey={`${timelineId}_timeline`}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
|
|||
|
||||
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
||||
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
||||
import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
||||
import { fetchChats } from 'soapbox/actions/chats';
|
||||
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
|
||||
import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
|
||||
|
@ -451,6 +452,8 @@ const UI: React.FC = ({ children }) => {
|
|||
.then(() => dispatch(fetchMarker(['notifications'])))
|
||||
.catch(console.error);
|
||||
|
||||
dispatch(fetchAnnouncements());
|
||||
|
||||
if (features.chats) {
|
||||
dispatch(fetchChats());
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<HTMLDivElement>, 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<Element | null>(null);
|
||||
const [element, setRef] = useState<Element | null>(null);
|
||||
const [rect, setRect] = useState<UseDimensionsRect>(defaultState);
|
||||
|
||||
const observer = useMemo(
|
||||
|
@ -32,7 +32,7 @@ const useDimensions = (): UseDimensionsResult => {
|
|||
};
|
||||
}, [element]);
|
||||
|
||||
return [ref, rect];
|
||||
return [element, setRef, rect];
|
||||
};
|
||||
|
||||
export { useDimensions };
|
||||
|
|
|
@ -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<AnnouncementReaction>(),
|
||||
statuses: ImmutableMap<string, string>(),
|
||||
mentions: ImmutableList<Mention>(),
|
||||
tags: ImmutableList<ImmutableMap<string, any>>(),
|
||||
emojis: ImmutableList<Emoji>(),
|
||||
updated_at: Date,
|
||||
|
||||
// Internal fields
|
||||
contentHtml: '',
|
||||
});
|
||||
|
||||
const normalizeMentions = (announcement: ImmutableMap<string, any>) => {
|
||||
return announcement.update('mentions', ImmutableList(), mentions => {
|
||||
return mentions.map(normalizeMention);
|
||||
});
|
||||
};
|
||||
|
||||
// Normalize reactions
|
||||
const normalizeReactions = (announcement: ImmutableMap<string, any>) => {
|
||||
return announcement.update('reactions', ImmutableList(), reactions => {
|
||||
return reactions.map((reaction: ImmutableMap<string, any>) => normalizeAnnouncementReaction(reaction, announcement.get('id')));
|
||||
});
|
||||
};
|
||||
|
||||
// Normalize emojis
|
||||
const normalizeEmojis = (announcement: ImmutableMap<string, any>) => {
|
||||
return announcement.update('emojis', ImmutableList(), emojis => {
|
||||
return emojis.map(normalizeEmoji);
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeContent = (announcement: ImmutableMap<string, any>) => {
|
||||
const emojiMap = makeEmojiMap(announcement.get('emojis'));
|
||||
const contentHtml = emojify(announcement.get('content'), emojiMap);
|
||||
|
||||
return announcement.set('contentHtml', contentHtml);
|
||||
};
|
||||
|
||||
const normalizeStatuses = (announcement: ImmutableMap<string, any>) => {
|
||||
const statuses = announcement
|
||||
.get('statuses', ImmutableList())
|
||||
.reduce((acc: ImmutableMap<string, string>, curr: ImmutableMap<string, any>) => acc.set(curr.get('url'), `/@${curr.getIn(['account', 'acct'])}/${curr.get('id')}`), ImmutableMap());
|
||||
|
||||
return announcement.set('statuses', statuses);
|
||||
};
|
||||
|
||||
export const normalizeAnnouncement = (announcement: Record<string, any>) => {
|
||||
return AnnouncementRecord(
|
||||
ImmutableMap(fromJS(announcement)).withMutations(announcement => {
|
||||
normalizeMentions(announcement);
|
||||
normalizeReactions(announcement);
|
||||
normalizeEmojis(announcement);
|
||||
normalizeContent(announcement);
|
||||
normalizeStatuses(announcement);
|
||||
}),
|
||||
);
|
||||
};
|
|
@ -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<string, any>, announcementId?: string) => {
|
||||
return AnnouncementReactionRecord(ImmutableMap(fromJS(announcementReaction)).withMutations(reaction => {
|
||||
reaction.set('announcement_id', announcementId as any);
|
||||
}));
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -144,6 +144,11 @@ const fixQuote = (status: ImmutableMap<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
// Workaround for not yet implemented filtering from Mastodon 3.6
|
||||
const fixFiltered = (status: ImmutableMap<string, any>) => {
|
||||
status.delete('filtered');
|
||||
};
|
||||
|
||||
export const normalizeStatus = (status: Record<string, any>) => {
|
||||
return StatusRecord(
|
||||
ImmutableMap(fromJS(status)).withMutations(status => {
|
||||
|
@ -155,6 +160,7 @@ export const normalizeStatus = (status: Record<string, any>) => {
|
|||
fixMentionsOrder(status);
|
||||
addSelfMention(status);
|
||||
fixQuote(status);
|
||||
fixFiltered(status);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 => <Component />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
{me && features.announcements && (
|
||||
<BundleContainer fetchComponent={AnnouncementsPanel}>
|
||||
{Component => <Component key='announcements-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
{features.trends && (
|
||||
<BundleContainer fetchComponent={TrendsPanel}>
|
||||
{Component => <Component limit={3} />}
|
||||
|
|
|
@ -116,6 +116,8 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
|
|||
activeItem = 'profile';
|
||||
}
|
||||
|
||||
const showTabs = !['following', 'followers', 'pins'].some(path => pathname.includes(path));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
|
@ -128,7 +130,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
|
|||
{Component => <Component username={username} account={account} />}
|
||||
</BundleContainer>
|
||||
|
||||
{account && (
|
||||
{account && showTabs && (
|
||||
<Tabs items={tabItems} activeItem={activeItem} />
|
||||
)}
|
||||
|
||||
|
|
|
@ -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: '<p>Updated to Soapbox v3.0.0.</p>' } };
|
||||
const result = reducer(state, action).items;
|
||||
|
||||
expect(result.size === 1);
|
||||
expect(result.first()?.content === '<p>Updated to Soapbox v3.0.0.</p>');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<Announcement>(),
|
||||
isLoading: false,
|
||||
show: false,
|
||||
unread: ImmutableSet<string>(),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
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<Announcement>) => 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<Announcement>((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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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<typeof AdminAccountRecord>;
|
||||
type AdminReport = ReturnType<typeof AdminReportRecord>;
|
||||
type Announcement = ReturnType<typeof AnnouncementRecord>;
|
||||
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
|
||||
type Attachment = ReturnType<typeof AttachmentRecord>;
|
||||
type Card = ReturnType<typeof CardRecord>;
|
||||
type Chat = ReturnType<typeof ChatRecord>;
|
||||
|
@ -64,6 +68,8 @@ export {
|
|||
AdminAccount,
|
||||
AdminReport,
|
||||
Account,
|
||||
Announcement,
|
||||
AnnouncementReaction,
|
||||
Attachment,
|
||||
Card,
|
||||
Chat,
|
||||
|
|
|
@ -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,
|
||||
]),
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -78,6 +78,17 @@
|
|||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&--quote {
|
||||
ul,
|
||||
ol {
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply pl-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status__content > ul,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
Thank you for your interest in Soapbox!
|
||||
|
||||
## Project Contribution Flow
|
||||
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).
|
||||
|
||||
It is recommended that you use the following guidelines to contribute to the Soapbox project:
|
||||
## Opening an MR (merge request)
|
||||
|
||||
* 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
|
||||
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).
|
||||
|
||||
## Pull Request Process
|
||||
### Ensuring the CI pipeline succeeds
|
||||
|
||||
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.
|
||||
When you push to a branch, the CI pipeline will run.
|
||||
|
||||
## Text Editor Tools
|
||||
[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.
|
||||
|
||||
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.
|
||||
New users of gitlab.com may see a "detatched pipeline" error.
|
||||
If so, please check the following:
|
||||
|
||||
For Atom, you can install the following packages:
|
||||
1. Your GitLab email address is confirmed.
|
||||
2. You may have to have a credit card on file before the CI job will run.
|
||||
|
||||
* [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)
|
||||
## Text editor
|
||||
|
||||
For Visual Studio Code, you can install the following extensions:
|
||||
We recommend developing Soapbox with [VSCodium](https://vscodium.com/) (or its proprietary ancestor, [VS Code](https://code.visualstudio.com/)).
|
||||
|
||||
* [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
|
||||
* [vscode-stylelint](https://marketplace.visualstudio.com/items?itemName=stylelint.vscode-stylelint)
|
||||
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.
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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!
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
|
@ -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/).
|
||||
|
|
930
docs/store.md
930
docs/store.md
|
@ -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: '<p>jpg test <span class="h-card"><a href="https://dev.teci.world/users/curtis" class="u-url mention">@<span>curtis</span></a></span></p>',
|
||||
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: '<p>jpg test <span class="h-card"><a href="https://dev.teci.world/users/curtis" class="u-url mention">@<span>curtis</span></a></span></p>',
|
||||
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.
|
Loading…
Reference in New Issue