Merge remote-tracking branch 'origin/develop' into accounts-scss
This commit is contained in:
commit
d64b8d9b16
|
@ -5,4 +5,4 @@
|
|||
/tmp/**
|
||||
/coverage/**
|
||||
/custom/**
|
||||
!.eslintrc.js
|
||||
!.eslintrc.cjs
|
||||
|
|
|
@ -43,7 +43,7 @@ module.exports = {
|
|||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
'import/extensions': ['.js', '.jsx', '.ts', '.tsx'],
|
||||
'import/extensions': ['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx'],
|
||||
'import/ignore': [
|
||||
'node_modules',
|
||||
'\\.(css|scss|json)$',
|
||||
|
@ -54,12 +54,12 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
polyfills: [
|
||||
'es:all',
|
||||
'fetch',
|
||||
'IntersectionObserver',
|
||||
'Promise',
|
||||
'URL',
|
||||
'URLSearchParams',
|
||||
'es:all', // core-js
|
||||
'IntersectionObserver', // npm:intersection-observer
|
||||
'Promise', // core-js
|
||||
'ResizeObserver', // npm:resize-observer-polyfill
|
||||
'URL', // core-js
|
||||
'URLSearchParams', // core-js
|
||||
],
|
||||
},
|
||||
|
|
@ -48,10 +48,12 @@ lint-js:
|
|||
changes:
|
||||
- "**/*.js"
|
||||
- "**/*.jsx"
|
||||
- "**/*.cjs"
|
||||
- "**/*.mjs"
|
||||
- "**/*.ts"
|
||||
- "**/*.tsx"
|
||||
- ".eslintignore"
|
||||
- ".eslintrc.js"
|
||||
- ".eslintrc.cjs"
|
||||
|
||||
lint-sass:
|
||||
stage: test
|
||||
|
@ -72,7 +74,7 @@ jest:
|
|||
- "app/soapbox/**/*"
|
||||
- "webpack/**/*"
|
||||
- "custom/**/*"
|
||||
- "jest.config.js"
|
||||
- "jest.config.cjs"
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- ".gitlab-ci.yml"
|
||||
|
@ -147,9 +149,9 @@ pages:
|
|||
|
||||
docker:
|
||||
stage: deploy
|
||||
image: docker:20.10.22
|
||||
image: docker:20.10.23
|
||||
services:
|
||||
- docker:20.10.22-dind
|
||||
- docker:20.10.23-dind
|
||||
tags:
|
||||
- dind
|
||||
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"*.js": "eslint --cache",
|
||||
"*.cjs": "eslint --cache",
|
||||
"*.mjs": "eslint --cache",
|
||||
"*.ts": "eslint --cache",
|
||||
"*.tsx": "eslint --cache",
|
||||
"app/styles/**/*.scss": "stylelint"
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"css.validate": false,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"files.associations": {
|
||||
|
@ -15,5 +16,6 @@
|
|||
"fileMatch": ["renovate.json"],
|
||||
"url": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
]
|
||||
],
|
||||
"scss.validate": false
|
||||
}
|
||||
|
|
32
CHANGELOG.md
32
CHANGELOG.md
|
@ -6,16 +6,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Admin: redirect the homepage to any URL.
|
||||
- Compatibility: added compatibility with Friendica.
|
||||
- Posts: bot badge on statuses from bot accounts.
|
||||
- Compatibility: improved browser support for older browsers.
|
||||
- Events: allow to repost events in event menu.
|
||||
- Groups: Initial support for groups.
|
||||
|
||||
### Changed
|
||||
- Chats: improved display of media attachments.
|
||||
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
|
||||
|
||||
### Fixed
|
||||
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.
|
||||
- Chats: don't display "copy" button for messages without text.
|
||||
- Posts: don't have to click the play button twice for embedded videos.
|
||||
- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header.
|
||||
- Modals: fix media modal automatically switching to video.
|
||||
|
||||
### Removed
|
||||
- Admin: single user mode. Now the homepage can be redirected to any URL.
|
||||
|
||||
## [3.1.0] - 2023-01-13
|
||||
|
||||
### Added
|
||||
- Compatibility: rudimentary support for Takahē.
|
||||
- UI: added backdrop blur behind modals.
|
||||
- Admin: let admins configure media preview for attachment thumbnails.
|
||||
- Login: accept `?server` param in external login, eg `fe.soapbox.pub/login/external?server=gleasonator.com`.
|
||||
- Backups: restored Pleroma backups functionality.
|
||||
- Export: restored "Export data" to CSV.
|
||||
|
||||
### Changed
|
||||
- Posts: letterbox images to 19:6 again.
|
||||
- Status Info: moved context (repost, pinned) to improve UX.
|
||||
- Posts: remove file icon from empty link previews.
|
||||
- Settings: moved "Import data" under settings.
|
||||
- Composer: add more descriptive discard confirmation message.
|
||||
|
||||
### Fixed
|
||||
- Layout: use accent color for "floating action button" (mobile compose button).
|
||||
|
@ -27,10 +55,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Posts: fix monospace font in Markdown code blocks.
|
||||
- Modals: fix action buttons overflow
|
||||
- Editing: don't insert edited posts to the top of the feed.
|
||||
- Editing: don't display edited posts as pending posts.
|
||||
- Modals: close modal when navigating to a different page.
|
||||
- Modals: fix "View context" button in media modal.
|
||||
- Posts: let unauthenticated users to translate posts if allowed by backend.
|
||||
- Chats: fix jumpy scrollbar.
|
||||
- Composer: fix alignment of icon in submit button.
|
||||
- Login: add a border around QR codes.
|
||||
- Composer: don't display action button in reply indicator.
|
||||
|
||||
## [3.0.0] - 2022-12-25
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<link href="/manifest.json" rel="manifest">
|
||||
<!--server-generated-meta-->
|
||||
<%= snippets %>
|
||||
|
|
|
@ -20,8 +20,8 @@ import KVStore from 'soapbox/storage/kv-store';
|
|||
import toast from 'soapbox/toast';
|
||||
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import { normalizeUsername } from 'soapbox/utils/input';
|
||||
import { getScopes } from 'soapbox/utils/scopes';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import api, { baseClient } from '../api';
|
||||
|
@ -50,17 +50,12 @@ const customApp = custom('app');
|
|||
|
||||
export const messages = defineMessages({
|
||||
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
|
||||
awaitingApproval: { id: 'auth.awaiting_approval', defaultMessage: 'Your account is awaiting approval' },
|
||||
invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' },
|
||||
});
|
||||
|
||||
const noOp = () => new Promise(f => f(undefined));
|
||||
|
||||
const getScopes = (state: RootState) => {
|
||||
const instance = state.instance;
|
||||
const { scopes } = getFeatures(instance);
|
||||
return scopes;
|
||||
};
|
||||
|
||||
const createAppAndToken = () =>
|
||||
(dispatch: AppDispatch) =>
|
||||
dispatch(getAuthApp()).then(() =>
|
||||
|
@ -193,6 +188,8 @@ export const logIn = (username: string, password: string) =>
|
|||
if ((error.response?.data as any)?.error === 'mfa_required') {
|
||||
// If MFA is required, throw the error and handle it in the component.
|
||||
throw error;
|
||||
} else if ((error.response?.data as any)?.identifier === 'awaiting_approval') {
|
||||
toast.error(messages.awaitingApproval);
|
||||
} else {
|
||||
// Return "wrong password" message.
|
||||
toast.error(messages.invalidCredentials);
|
||||
|
|
|
@ -46,6 +46,7 @@ const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
|||
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST';
|
||||
|
||||
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||
|
@ -86,7 +87,7 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
|
|||
const messages = defineMessages({
|
||||
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
|
||||
exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
|
||||
exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit} seconds)' },
|
||||
exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' },
|
||||
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
|
||||
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
|
||||
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
|
||||
|
@ -288,6 +289,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
|||
poll: compose.poll,
|
||||
scheduled_at: compose.schedule,
|
||||
to,
|
||||
group_id: compose.privacy === 'group' ? compose.group_id : null,
|
||||
};
|
||||
|
||||
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
|
||||
|
@ -470,6 +472,15 @@ const undoUploadCompose = (composeId: string, media_id: string) => ({
|
|||
media_id: media_id,
|
||||
});
|
||||
|
||||
const groupCompose = (composeId: string, groupId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_GROUP_POST,
|
||||
id: composeId,
|
||||
group_id: groupId,
|
||||
});
|
||||
};
|
||||
|
||||
const clearComposeSuggestions = (composeId: string) => {
|
||||
if (cancelFetchComposeSuggestionsAccounts) {
|
||||
cancelFetchComposeSuggestionsAccounts();
|
||||
|
@ -722,7 +733,7 @@ const eventDiscussionCompose = (composeId: string, status: Status) =>
|
|||
const instance = state.instance;
|
||||
const { explicitAddressing } = getFeatures(instance);
|
||||
|
||||
dispatch({
|
||||
return dispatch({
|
||||
type: COMPOSE_EVENT_REPLY,
|
||||
id: composeId,
|
||||
status: status,
|
||||
|
@ -749,6 +760,7 @@ export {
|
|||
COMPOSE_UPLOAD_FAIL,
|
||||
COMPOSE_UPLOAD_PROGRESS,
|
||||
COMPOSE_UPLOAD_UNDO,
|
||||
COMPOSE_GROUP_POST,
|
||||
COMPOSE_SUGGESTIONS_CLEAR,
|
||||
COMPOSE_SUGGESTIONS_READY,
|
||||
COMPOSE_SUGGESTION_SELECT,
|
||||
|
@ -801,6 +813,7 @@ export {
|
|||
uploadComposeSuccess,
|
||||
uploadComposeFail,
|
||||
undoUploadCompose,
|
||||
groupCompose,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
readyComposeSuggestionsEmojis,
|
||||
|
|
|
@ -3,7 +3,7 @@ import axios from 'axios';
|
|||
import * as BuildConfig from 'soapbox/build-config';
|
||||
import { isURL } from 'soapbox/utils/auth';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import { getScopes } from 'soapbox/utils/scopes';
|
||||
|
||||
import { createApp } from './apps';
|
||||
|
||||
|
@ -11,8 +11,7 @@ import type { AppDispatch, RootState } from 'soapbox/store';
|
|||
|
||||
const createProviderApp = () => {
|
||||
return async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const { scopes } = getFeatures(state.instance);
|
||||
const scopes = getScopes(getState());
|
||||
|
||||
const params = {
|
||||
client_name: sourceCode.displayName,
|
||||
|
@ -29,8 +28,7 @@ export const prepareRequest = (provider: string) => {
|
|||
return async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : '';
|
||||
|
||||
const state = getState();
|
||||
const { scopes } = getFeatures(state.instance);
|
||||
const scopes = getScopes(getState());
|
||||
const app = await dispatch(createProviderApp());
|
||||
const { client_id, redirect_uri } = app;
|
||||
|
||||
|
|
|
@ -15,10 +15,11 @@ import sourceCode from 'soapbox/utils/code';
|
|||
import { getWalletAndSign } from 'soapbox/utils/ethereum';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import { getQuirks } from 'soapbox/utils/quirks';
|
||||
import { getInstanceScopes } from 'soapbox/utils/scopes';
|
||||
|
||||
import { baseClient } from '../api';
|
||||
|
||||
import type { AppDispatch } from 'soapbox/store';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { Instance } from 'soapbox/types/entities';
|
||||
|
||||
const fetchExternalInstance = (baseURL?: string) => {
|
||||
|
@ -37,25 +38,23 @@ const fetchExternalInstance = (baseURL?: string) => {
|
|||
};
|
||||
|
||||
const createExternalApp = (instance: Instance, baseURL?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
// Mitra: skip creating the auth app
|
||||
if (getQuirks(instance).noApps) return new Promise(f => f({}));
|
||||
|
||||
const { scopes } = getFeatures(instance);
|
||||
|
||||
const params = {
|
||||
client_name: sourceCode.displayName,
|
||||
client_name: sourceCode.displayName,
|
||||
redirect_uris: `${window.location.origin}/login/external`,
|
||||
website: sourceCode.homepage,
|
||||
scopes,
|
||||
website: sourceCode.homepage,
|
||||
scopes: getInstanceScopes(instance),
|
||||
};
|
||||
|
||||
return dispatch(createApp(params, baseURL));
|
||||
};
|
||||
|
||||
const externalAuthorize = (instance: Instance, baseURL: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
const { scopes } = getFeatures(instance);
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
const scopes = getInstanceScopes(instance);
|
||||
|
||||
return dispatch(createExternalApp(instance, baseURL)).then((app) => {
|
||||
const { client_id, redirect_uri } = app as Record<string, string>;
|
||||
|
@ -76,7 +75,7 @@ const externalAuthorize = (instance: Instance, baseURL: string) =>
|
|||
};
|
||||
|
||||
const externalEthereumLogin = (instance: Instance, baseURL?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const loginMessage = instance.login_message;
|
||||
|
||||
return getWalletAndSign(loginMessage).then(({ wallet, signature }) => {
|
||||
|
@ -89,7 +88,7 @@ const externalEthereumLogin = (instance: Instance, baseURL?: string) =>
|
|||
client_secret: client_secret,
|
||||
password: signature as string,
|
||||
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||
scope: getFeatures(instance).scopes,
|
||||
scope: getInstanceScopes(instance),
|
||||
};
|
||||
|
||||
return dispatch(obtainOAuthToken(params, baseURL))
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -5,42 +5,44 @@ import type { APIEntity } from 'soapbox/types/entities';
|
|||
|
||||
const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
|
||||
const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
|
||||
const GROUP_IMPORT = 'GROUP_IMPORT';
|
||||
const GROUPS_IMPORT = 'GROUPS_IMPORT';
|
||||
const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||
const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP';
|
||||
|
||||
export function importAccount(account: APIEntity) {
|
||||
return { type: ACCOUNT_IMPORT, account };
|
||||
}
|
||||
const importAccount = (account: APIEntity) =>
|
||||
({ type: ACCOUNT_IMPORT, account });
|
||||
|
||||
export function importAccounts(accounts: APIEntity[]) {
|
||||
return { type: ACCOUNTS_IMPORT, accounts };
|
||||
}
|
||||
const importAccounts = (accounts: APIEntity[]) =>
|
||||
({ type: ACCOUNTS_IMPORT, accounts });
|
||||
|
||||
export function importStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importGroup = (group: APIEntity) =>
|
||||
({ type: GROUP_IMPORT, group });
|
||||
|
||||
const importGroups = (groups: APIEntity[]) =>
|
||||
({ type: GROUPS_IMPORT, groups });
|
||||
|
||||
const importStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
|
||||
};
|
||||
}
|
||||
|
||||
export function importStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
|
||||
};
|
||||
}
|
||||
|
||||
export function importPolls(polls: APIEntity[]) {
|
||||
return { type: POLLS_IMPORT, polls };
|
||||
}
|
||||
const importPolls = (polls: APIEntity[]) =>
|
||||
({ type: POLLS_IMPORT, polls });
|
||||
|
||||
export function importFetchedAccount(account: APIEntity) {
|
||||
return importFetchedAccounts([account]);
|
||||
}
|
||||
const importFetchedAccount = (account: APIEntity) =>
|
||||
importFetchedAccounts([account]);
|
||||
|
||||
export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) {
|
||||
const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => {
|
||||
const { should_refetch } = args;
|
||||
const normalAccounts: APIEntity[] = [];
|
||||
|
||||
|
@ -61,10 +63,27 @@ export function importFetchedAccounts(accounts: APIEntity[], args = { should_ref
|
|||
accounts.forEach(processAccount);
|
||||
|
||||
return importAccounts(normalAccounts);
|
||||
}
|
||||
};
|
||||
|
||||
export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const importFetchedGroup = (group: APIEntity) =>
|
||||
importFetchedGroups([group]);
|
||||
|
||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||
const normalGroups: APIEntity[] = [];
|
||||
|
||||
const processGroup = (group: APIEntity) => {
|
||||
if (!group.id) return;
|
||||
|
||||
normalGroups.push(group);
|
||||
};
|
||||
|
||||
groups.forEach(processGroup);
|
||||
|
||||
return importGroups(normalGroups);
|
||||
};
|
||||
|
||||
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
// Skip broken statuses
|
||||
if (isBroken(status)) return;
|
||||
|
||||
|
@ -96,10 +115,13 @@ export function importFetchedStatus(status: APIEntity, idempotencyKey?: string)
|
|||
dispatch(importFetchedPoll(status.poll));
|
||||
}
|
||||
|
||||
if (status.group?.id) {
|
||||
dispatch(importFetchedGroup(status.group));
|
||||
}
|
||||
|
||||
dispatch(importFetchedAccount(status.account));
|
||||
dispatch(importStatus(status, idempotencyKey));
|
||||
};
|
||||
}
|
||||
|
||||
// Sometimes Pleroma can return an empty account,
|
||||
// or a repost can appear of a deleted account. Skip these statuses.
|
||||
|
@ -117,8 +139,8 @@ const isBroken = (status: APIEntity) => {
|
|||
}
|
||||
};
|
||||
|
||||
export function importFetchedStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importFetchedStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const accounts: APIEntity[] = [];
|
||||
const normalStatuses: APIEntity[] = [];
|
||||
const polls: APIEntity[] = [];
|
||||
|
@ -146,6 +168,10 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
|||
if (status.poll?.id) {
|
||||
polls.push(status.poll);
|
||||
}
|
||||
|
||||
if (status.group?.id) {
|
||||
dispatch(importFetchedGroup(status.group));
|
||||
}
|
||||
}
|
||||
|
||||
statuses.forEach(processStatus);
|
||||
|
@ -154,23 +180,37 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
|||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
};
|
||||
}
|
||||
|
||||
export function importFetchedPoll(poll: APIEntity) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const importFetchedPoll = (poll: APIEntity) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
dispatch(importPolls([poll]));
|
||||
};
|
||||
}
|
||||
|
||||
export function importErrorWhileFetchingAccountByUsername(username: string) {
|
||||
return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username };
|
||||
}
|
||||
const importErrorWhileFetchingAccountByUsername = (username: string) =>
|
||||
({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username });
|
||||
|
||||
export {
|
||||
ACCOUNT_IMPORT,
|
||||
ACCOUNTS_IMPORT,
|
||||
GROUP_IMPORT,
|
||||
GROUPS_IMPORT,
|
||||
STATUS_IMPORT,
|
||||
STATUSES_IMPORT,
|
||||
POLLS_IMPORT,
|
||||
ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
|
||||
importAccount,
|
||||
importAccounts,
|
||||
importGroup,
|
||||
importGroups,
|
||||
importStatus,
|
||||
importStatuses,
|
||||
importPolls,
|
||||
importFetchedAccount,
|
||||
importFetchedAccounts,
|
||||
importFetchedGroup,
|
||||
importFetchedGroups,
|
||||
importFetchedStatus,
|
||||
importFetchedStatuses,
|
||||
importFetchedPoll,
|
||||
importErrorWhileFetchingAccountByUsername,
|
||||
};
|
||||
|
|
|
@ -47,7 +47,7 @@ const MAX_QUEUED_NOTIFICATIONS = 40;
|
|||
|
||||
defineMessages({
|
||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
||||
group: { id: 'notifications.group', defaultMessage: '{count, plural, one {# notification} other {# notifications}}' },
|
||||
});
|
||||
|
||||
const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||
|
@ -83,6 +83,10 @@ const submitSearch = (filter?: SearchFilter) =>
|
|||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
}
|
||||
|
||||
if (response.data.groups) {
|
||||
dispatch(importFetchedGroups(response.data.groups));
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
|
@ -139,6 +143,10 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
|
|||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
if (data.groups) {
|
||||
dispatch(importFetchedGroups(data.groups));
|
||||
}
|
||||
|
||||
dispatch(expandSearchSuccess(data, value, type));
|
||||
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
|
|
|
@ -50,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL';
|
|||
const fetchOAuthTokens = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: FETCH_TOKENS_REQUEST });
|
||||
return api(getState).get('/api/oauth_tokens.json').then(({ data: tokens }) => {
|
||||
return api(getState).get('/api/oauth_tokens').then(({ data: tokens }) => {
|
||||
dispatch({ type: FETCH_TOKENS_SUCCESS, tokens });
|
||||
}).catch(() => {
|
||||
dispatch({ type: FETCH_TOKENS_FAIL });
|
||||
|
|
|
@ -156,6 +156,8 @@ const defaultSettings = ImmutableMap({
|
|||
}),
|
||||
}),
|
||||
|
||||
groups: ImmutableMap({}),
|
||||
|
||||
trends: ImmutableMap({
|
||||
show: true,
|
||||
}),
|
||||
|
|
|
@ -219,6 +219,9 @@ const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, don
|
|||
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
||||
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
|
||||
|
||||
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
|
||||
expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
|
||||
|
||||
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
|
||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||
max_id: maxId,
|
||||
|
@ -309,6 +312,7 @@ export {
|
|||
expandAccountMediaTimeline,
|
||||
expandListTimeline,
|
||||
expandGroupTimeline,
|
||||
expandGroupMediaTimeline,
|
||||
expandHashtagTimeline,
|
||||
expandTimelineRequest,
|
||||
expandTimelineSuccess,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||
|
@ -8,9 +9,11 @@ import { useAppSelector, useOnScreen } from 'soapbox/hooks';
|
|||
import { getAcct } from 'soapbox/utils/accounts';
|
||||
import { displayFqn } from 'soapbox/utils/state';
|
||||
|
||||
import Badge from './badge';
|
||||
import RelativeTimestamp from './relative-timestamp';
|
||||
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
||||
|
||||
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IInstanceFavicon {
|
||||
|
@ -18,6 +21,10 @@ interface IInstanceFavicon {
|
|||
disabled?: boolean,
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
|
||||
});
|
||||
|
||||
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
|
||||
const history = useHistory();
|
||||
|
||||
|
@ -47,11 +54,17 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
|
|||
|
||||
interface IProfilePopper {
|
||||
condition: boolean,
|
||||
wrapper: (children: any) => React.ReactElement<any, any>
|
||||
wrapper: (children: React.ReactNode) => React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children }): any =>
|
||||
condition ? wrapper(children) : children;
|
||||
const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children }) => {
|
||||
return (
|
||||
<>
|
||||
{condition ? wrapper(children) : children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export interface IAccount {
|
||||
account: AccountEntity,
|
||||
|
@ -75,6 +88,7 @@ export interface IAccount {
|
|||
withLinkToProfile?: boolean,
|
||||
withRelationship?: boolean,
|
||||
showEdit?: boolean,
|
||||
approvalStatus?: StatusApprovalStatus,
|
||||
emoji?: string,
|
||||
note?: string,
|
||||
}
|
||||
|
@ -99,6 +113,7 @@ const Account = ({
|
|||
withLinkToProfile = true,
|
||||
withRelationship = true,
|
||||
showEdit = false,
|
||||
approvalStatus,
|
||||
emoji,
|
||||
note,
|
||||
}: IAccount) => {
|
||||
|
@ -145,6 +160,8 @@ const Account = ({
|
|||
return null;
|
||||
};
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
React.useEffect(() => {
|
||||
const style: React.CSSProperties = {};
|
||||
const actionWidth = actionRef.current?.clientWidth || 0;
|
||||
|
@ -217,6 +234,8 @@ const Account = ({
|
|||
/>
|
||||
|
||||
{account.verified && <VerificationBadge />}
|
||||
|
||||
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
|
||||
</HStack>
|
||||
</LinkEl>
|
||||
</ProfilePopper>
|
||||
|
@ -243,6 +262,18 @@ const Account = ({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
||||
<Text tag='span' theme='muted' size='sm'>
|
||||
{approvalStatus === 'pending'
|
||||
? <FormattedMessage id='status.approval.pending' defaultMessage='Pending approval' />
|
||||
: <FormattedMessage id='status.approval.rejected' defaultMessage='Rejected' />}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEdit ? (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
|
|
@ -30,6 +30,7 @@ interface IAutosuggesteTextarea {
|
|||
onFocus: () => void,
|
||||
onBlur?: () => void,
|
||||
condensed?: boolean,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
|
||||
|
@ -156,7 +157,8 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
if (lastTokenUpdated && !valueUpdated) {
|
||||
return false;
|
||||
} else {
|
||||
return super.shouldComponentUpdate!(nextProps, nextState, undefined);
|
||||
// https://stackoverflow.com/a/35962835
|
||||
return super.shouldComponentUpdate!.bind(this)(nextProps, nextState, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { Account } from 'soapbox/types/entities';
|
|||
interface IDisplayName {
|
||||
account: Account
|
||||
withSuffix?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true }) => {
|
||||
|
|
|
@ -26,7 +26,9 @@ const mapStateToProps = (state: RootState) => {
|
|||
};
|
||||
};
|
||||
|
||||
type Props = ReturnType<typeof mapStateToProps>;
|
||||
interface Props extends ReturnType<typeof mapStateToProps> {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type State = {
|
||||
hasError: boolean,
|
||||
|
@ -213,4 +215,4 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(ErrorBoundary as any);
|
||||
export default connect(mapStateToProps)(ErrorBoundary);
|
||||
|
|
|
@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge';
|
|||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
|
||||
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
|
||||
});
|
||||
|
@ -56,7 +56,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
|
|||
{floatingAction && action}
|
||||
</div>
|
||||
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
|
||||
</div>
|
||||
<Stack className='p-2.5' space={2}>
|
||||
<HStack space={2} alignItems='center' justifyContent='between'>
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Avatar, HStack, Icon, Stack, Text } from './ui';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
});
|
||||
|
||||
interface IGroupCard {
|
||||
group: GroupEntity
|
||||
}
|
||||
|
||||
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<Stack className='bg-white dark:bg-primary-900 border border-solid border-gray-300 dark:border-primary-800 rounded-lg sm:rounded-xl'>
|
||||
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative -m-[1px] mb-0 rounded-t-lg sm:rounded-t-xl'>
|
||||
{group.header && <img className='h-full w-full object-cover rounded-t-lg sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupCard;
|
|
@ -15,7 +15,11 @@ const getNotifTotals = (state: RootState): number => {
|
|||
return notifications + reports + approvals;
|
||||
};
|
||||
|
||||
const Helmet: React.FC = ({ children }) => {
|
||||
interface IHelmet {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Helmet: React.FC<IHelmet> = ({ children }) => {
|
||||
const instance = useInstance();
|
||||
const { unreadChatsCount } = useStatContext();
|
||||
const unreadCount = useAppSelector((state) => getNotifTotals(state) + unreadChatsCount);
|
||||
|
|
|
@ -18,6 +18,7 @@ interface IHoverRefWrapper {
|
|||
accountId: string,
|
||||
inline?: boolean,
|
||||
className?: string,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
/** Makes a profile hover card appear when the wrapped element is hovered. */
|
||||
|
|
|
@ -17,6 +17,7 @@ interface IHoverStatusWrapper {
|
|||
statusId: any,
|
||||
inline: boolean,
|
||||
className?: string,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
/** Makes a status hover card appear when the wrapped element is hovered. */
|
||||
|
|
|
@ -7,7 +7,11 @@ import { SelectDropdown } from '../features/forms';
|
|||
import Icon from './icon';
|
||||
import { HStack, Select } from './ui';
|
||||
|
||||
const List: React.FC = ({ children }) => (
|
||||
interface IList {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const List: React.FC<IList> = ({ children }) => (
|
||||
<div className='space-y-0.5'>{children}</div>
|
||||
);
|
||||
|
||||
|
@ -17,6 +21,7 @@ interface IListItem {
|
|||
onClick?(): void,
|
||||
onSelect?(): void
|
||||
isSelected?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelect, isSelected }) => {
|
||||
|
|
|
@ -16,7 +16,7 @@ import type { ReducerCompose } from 'soapbox/reducers/compose';
|
|||
import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/compose-event';
|
||||
|
||||
const messages = defineMessages({
|
||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' },
|
||||
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
|
||||
});
|
||||
|
||||
|
@ -42,6 +42,7 @@ interface IModalRoot {
|
|||
onCancel?: () => void,
|
||||
onClose: (type?: ModalType) => void,
|
||||
type: ModalType,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type }) => {
|
||||
|
@ -79,10 +80,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: isEditing
|
||||
? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' />
|
||||
: <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
|
||||
: <FormattedMessage id='confirmations.cancel.heading' defaultMessage='Discard post' />,
|
||||
message: isEditing
|
||||
? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' />
|
||||
: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
|
||||
: <FormattedMessage id='confirmations.cancel.message' defaultMessage='Are you sure you want to cancel creating this post?' />,
|
||||
confirm: intl.formatMessage(messages.confirm),
|
||||
onConfirm: () => {
|
||||
dispatch(closeModal('COMPOSE'));
|
||||
|
@ -128,10 +129,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = Array.from(ref.current!.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
|
||||
const index = focusable.indexOf(e.target);
|
||||
const index = focusable.indexOf(e.target as Element);
|
||||
|
||||
let element;
|
||||
|
||||
|
@ -247,10 +248,9 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
<div
|
||||
role='dialog'
|
||||
className={classNames({
|
||||
'my-2 mx-auto relative pointer-events-none flex items-center': true,
|
||||
'my-2 mx-auto relative pointer-events-none flex items-center min-h-[calc(100%-3.5rem)]': true,
|
||||
'p-4 md:p-0': type !== 'MEDIA',
|
||||
})}
|
||||
style={{ minHeight: 'calc(100% - 3.5rem)' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,7 @@ interface IPullToRefresh {
|
|||
onRefresh?: () => Promise<any>;
|
||||
refreshingContent?: JSX.Element | string;
|
||||
pullingContent?: JSX.Element | string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -28,12 +28,12 @@ const messages = defineMessages({
|
|||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||
soapboxConfig: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
|
||||
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
|
||||
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
|
||||
accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
groups: { id: 'column.groups', defaultMessage: 'Groups' },
|
||||
events: { id: 'column.events', defaultMessage: 'Events' },
|
||||
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
|
||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
||||
|
@ -208,6 +208,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{features.groups && (
|
||||
<SidebarLink
|
||||
to='/groups'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={intl.formatMessage(messages.groups)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.lists && (
|
||||
<SidebarLink
|
||||
to='/lists'
|
||||
|
@ -305,15 +314,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{features.import && (
|
||||
<SidebarLink
|
||||
to='/settings/import'
|
||||
icon={require('@tabler/icons/cloud-upload.svg')}
|
||||
text={intl.formatMessage(messages.importData)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<SidebarLink
|
||||
|
|
|
@ -135,6 +135,14 @@ const SidebarNavigation = () => {
|
|||
|
||||
{renderMessagesLink()}
|
||||
|
||||
{features.groups && (
|
||||
<SidebarNavigationLink
|
||||
to='/groups'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarNavigationLink
|
||||
to={`/@${account.acct}`}
|
||||
icon={require('@tabler/icons/user.svg')}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
|||
import { launchChat } from 'soapbox/actions/chats';
|
||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { editEvent } from 'soapbox/actions/events';
|
||||
import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups';
|
||||
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
|
@ -24,7 +25,7 @@ import copy from 'soapbox/utils/copy';
|
|||
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||
import type { Account, Status } from 'soapbox/types/entities';
|
||||
import type { Account, Group, Status } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -81,6 +82,18 @@ const messages = defineMessages({
|
|||
redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||
replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' },
|
||||
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
|
||||
groupModKick: { id: 'status.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModBlock: { id: 'status.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||
deleteFromGroupHeading: { id: 'confirmations.delete_from_group.heading', defaultMessage: 'Delete from group' },
|
||||
deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' },
|
||||
kickFromGroupHeading: { id: 'confirmations.kick_from_group.heading', defaultMessage: 'Kick group member' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kickFromGroupConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Block group member' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||
blockFromGroupConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
});
|
||||
|
||||
interface IStatusActionBar {
|
||||
|
@ -103,6 +116,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const dispatch = useAppDispatch();
|
||||
|
||||
const me = useAppSelector(state => state.me);
|
||||
const groupRelationship = useAppSelector(state => status.group ? state.group_relationships.get((status.group as Group).id) : null);
|
||||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
@ -285,6 +299,39 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
|
||||
};
|
||||
|
||||
const handleDeleteFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKickFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.kickFromGroupHeading),
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickFromGroupConfirm),
|
||||
onConfirm: () => dispatch(groupKick((status.group as Group).id, account.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.blockFromGroupHeading),
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockFromGroupConfirm),
|
||||
onConfirm: () => dispatch(groupBlock((status.group as Group).id, account.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const _makeMenu = (publicStatus: boolean) => {
|
||||
const mutingConversation = status.muted;
|
||||
const ownAccount = status.getIn(['account', 'id']) === me;
|
||||
|
@ -425,6 +472,26 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
});
|
||||
}
|
||||
|
||||
if (status.group && groupRelationship?.role && ['admin', 'moderator'].includes(groupRelationship.role)) {
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModDelete),
|
||||
action: handleDeleteFromGroup,
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
});
|
||||
// TODO: figure out when an account is not in the group anymore
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.get('username') }),
|
||||
action: handleKickFromGroup,
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.get('username') }),
|
||||
action: handleBlockFromGroup,
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isStaff) {
|
||||
menu.push(null);
|
||||
|
||||
|
@ -491,6 +558,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const menu = _makeMenu(publicStatus);
|
||||
let reblogIcon = require('@tabler/icons/repeat.svg');
|
||||
let replyTitle;
|
||||
let replyDisabled = false;
|
||||
|
||||
if (status.visibility === 'direct') {
|
||||
reblogIcon = require('@tabler/icons/mail.svg');
|
||||
|
@ -498,6 +566,11 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
reblogIcon = require('@tabler/icons/lock.svg');
|
||||
}
|
||||
|
||||
if ((status.group as Group)?.membership_required && !groupRelationship?.member) {
|
||||
replyDisabled = true;
|
||||
replyTitle = intl.formatMessage(messages.replies_disabled_group);
|
||||
}
|
||||
|
||||
const reblogMenu = [{
|
||||
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog),
|
||||
action: handleReblogClick,
|
||||
|
@ -543,6 +616,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
onClick={handleReplyClick}
|
||||
count={replyCount}
|
||||
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
||||
disabled={replyDisabled}
|
||||
/>
|
||||
|
||||
{(features.quotePosts && me) ? (
|
||||
|
|
|
@ -85,6 +85,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
|||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',
|
||||
{
|
||||
'text-black dark:text-white': active && emoji,
|
||||
'hover:text-gray-600 dark:hover:text-white': !filteredProps.disabled,
|
||||
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && !emoji && color === COLORS.accent,
|
||||
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && !emoji && color === COLORS.success,
|
||||
'space-x-1': !text,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useRef, useLayoutEffect, useMemo } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -103,7 +103,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
|
|||
const maybeSetCollapsed = (): void => {
|
||||
if (!node.current) return;
|
||||
|
||||
if (collapsable && onClick && !collapsed && status.spoiler_text.length === 0) {
|
||||
if (collapsable && onClick && !collapsed) {
|
||||
if (node.current.clientHeight > MAX_HEIGHT) {
|
||||
setCollapsed(true);
|
||||
}
|
||||
|
@ -119,7 +119,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
maybeSetCollapsed();
|
||||
maybeSetOnlyEmoji();
|
||||
updateStatusLinks();
|
||||
|
|
|
@ -46,6 +46,8 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
|||
divideType?: 'space' | 'border',
|
||||
/** Whether to display ads. */
|
||||
showAds?: boolean,
|
||||
/** Whether to show group information. */
|
||||
showGroup?: boolean,
|
||||
}
|
||||
|
||||
/** Feed of statuses, built atop ScrollableList. */
|
||||
|
@ -59,6 +61,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
isLoading,
|
||||
isPartial,
|
||||
showAds = false,
|
||||
showGroup = true,
|
||||
...other
|
||||
}) => {
|
||||
const { data: ads } = useAds();
|
||||
|
@ -135,6 +138,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showGroup={showGroup}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -167,6 +171,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showGroup={showGroup}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
|
|
@ -50,7 +50,14 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
|
|||
// The typical case with a reply-to and a list of mentions.
|
||||
const accounts = to.slice(0, 2).map(account => {
|
||||
const link = (
|
||||
<Link to={`/@${account.acct}`} className='reply-mentions__account' onClick={(e) => e.stopPropagation()}>@{account.username}</Link>
|
||||
<Link
|
||||
key={account.id}
|
||||
to={`/@${account.acct}`}
|
||||
className='reply-mentions__account'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{account.username}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (hoverable) {
|
||||
|
@ -79,6 +86,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
|
|||
defaultMessage='<hover>Replying to</hover> {accounts}'
|
||||
values={{
|
||||
accounts: <FormattedList type='conjunction' value={accounts} />,
|
||||
// @ts-ignore wtf?
|
||||
hover: (children: React.ReactNode) => {
|
||||
if (hoverable) {
|
||||
return (
|
||||
|
|
|
@ -26,6 +26,7 @@ import { Card, Stack, Text } from './ui';
|
|||
|
||||
import type {
|
||||
Account as AccountEntity,
|
||||
Group as GroupEntity,
|
||||
Status as StatusEntity,
|
||||
} from 'soapbox/types/entities';
|
||||
|
||||
|
@ -51,6 +52,7 @@ export interface IStatus {
|
|||
hideActionBar?: boolean,
|
||||
hoverable?: boolean,
|
||||
variant?: 'default' | 'rounded',
|
||||
showGroup?: boolean,
|
||||
withDismiss?: boolean,
|
||||
accountAction?: React.ReactElement,
|
||||
}
|
||||
|
@ -71,6 +73,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
unread,
|
||||
hideActionBar,
|
||||
variant = 'rounded',
|
||||
showGroup = true,
|
||||
withDismiss,
|
||||
} = props;
|
||||
|
||||
|
@ -90,6 +93,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
const actualStatus = getActualStatus(status);
|
||||
const isReblog = status.reblog && typeof status.reblog === 'object';
|
||||
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
|
||||
const group = actualStatus.group as GroupEntity | null;
|
||||
|
||||
// Track height changes we know about to compensate scrolling.
|
||||
useEffect(() => {
|
||||
|
@ -244,6 +248,25 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
}
|
||||
/>
|
||||
);
|
||||
} else if (showGroup && group) {
|
||||
return (
|
||||
<StatusInfo
|
||||
avatarSize={avatarSize}
|
||||
to={`/groups/${group.id}`}
|
||||
icon={<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />}
|
||||
text={
|
||||
<Text size='xs' theme='muted' weight='medium'>
|
||||
<FormattedMessage
|
||||
id='status.group'
|
||||
defaultMessage='Posted in {group}'
|
||||
values={{ group: (
|
||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
) }}
|
||||
/>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -252,8 +275,10 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
if (hidden) {
|
||||
return (
|
||||
<div ref={node}>
|
||||
{actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])}
|
||||
{actualStatus.content}
|
||||
<>
|
||||
{actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])}
|
||||
{actualStatus.content}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -346,6 +371,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
showEdit={!!actualStatus.edited_at}
|
||||
showProfileHoverCard={hoverable}
|
||||
withLinkToProfile={hoverable}
|
||||
approvalStatus={actualStatus.approval_status}
|
||||
avatarSize={avatarSize}
|
||||
/>
|
||||
|
||||
|
|
|
@ -92,7 +92,7 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
|
|||
return (
|
||||
<div
|
||||
className={classNames('absolute z-40', {
|
||||
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center items-center': !visible,
|
||||
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center': !visible,
|
||||
'bg-gray-800/75 inset-0': !visible,
|
||||
'bottom-1 right-1': visible,
|
||||
})}
|
||||
|
@ -107,64 +107,66 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
|
|||
size='sm'
|
||||
/>
|
||||
) : (
|
||||
<div className='text-center w-3/4 mx-auto space-y-4' ref={ref}>
|
||||
<div className='space-y-1'>
|
||||
<Text theme='white' weight='semibold'>
|
||||
{intl.formatMessage(isUnderReview ? messages.underReviewTitle : messages.sensitiveTitle)}
|
||||
</Text>
|
||||
<div className='flex justify-center items-center max-h-screen'>
|
||||
<div className='text-center w-3/4 mx-auto space-y-4' ref={ref}>
|
||||
<div className='space-y-1'>
|
||||
<Text theme='white' weight='semibold'>
|
||||
{intl.formatMessage(isUnderReview ? messages.underReviewTitle : messages.sensitiveTitle)}
|
||||
</Text>
|
||||
|
||||
<Text theme='white' size='sm' weight='medium'>
|
||||
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
|
||||
</Text>
|
||||
<Text theme='white' size='sm' weight='medium'>
|
||||
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
|
||||
</Text>
|
||||
|
||||
{status.spoiler_text && (
|
||||
<div className='py-4 italic'>
|
||||
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
|
||||
“<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />”
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{status.spoiler_text && (
|
||||
<div className='py-4 italic'>
|
||||
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
|
||||
“<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />”
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HStack alignItems='center' justifyContent='center' space={2}>
|
||||
{isUnderReview ? (
|
||||
<>
|
||||
{links.get('support') && (
|
||||
<a
|
||||
href={links.get('support')}
|
||||
target='_blank'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/headset.svg')}
|
||||
<HStack alignItems='center' justifyContent='center' space={2}>
|
||||
{isUnderReview ? (
|
||||
<>
|
||||
{links.get('support') && (
|
||||
<a
|
||||
href={links.get('support')}
|
||||
target='_blank'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{intl.formatMessage(messages.contact)}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/headset.svg')}
|
||||
>
|
||||
{intl.formatMessage(messages.contact)}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/eye.svg')}
|
||||
onClick={toggleVisibility}
|
||||
>
|
||||
{intl.formatMessage(messages.show)}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/eye.svg')}
|
||||
onClick={toggleVisibility}
|
||||
>
|
||||
{intl.formatMessage(messages.show)}
|
||||
</Button>
|
||||
|
||||
{(isUnderReview && isOwnStatus) ? (
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
{(isUnderReview && isOwnStatus) ? (
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,7 @@ import { translateStatus, undoStatusTranslation } from 'soapbox/actions/statuses
|
|||
import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import { isLocal } from 'soapbox/utils/accounts';
|
||||
|
||||
import { Stack } from './ui';
|
||||
import { Icon, Stack } from './ui';
|
||||
|
||||
import type { Account, Status } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -44,16 +44,21 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
|
|||
|
||||
if (!features.translations || !renderTranslate || !supportsLanguages) return null;
|
||||
|
||||
const buttonClassName = 'flex items-center gap-0.5 w-fit px-2 py-1 border-gray-600 hover:border-gray-700 dark:hover:border-gray-500 border-solid border text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 text-start text-sm rounded-full';
|
||||
|
||||
if (status.translation) {
|
||||
const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' });
|
||||
const languageName = languageNames.of(status.language!);
|
||||
const provider = status.translation.get('provider');
|
||||
|
||||
return (
|
||||
<Stack className='text-gray-700 dark:text-gray-600 text-sm' alignItems='start'>
|
||||
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
|
||||
<Stack className='text-gray-700 dark:text-gray-600 text-sm' space={1} alignItems='start'>
|
||||
<span>
|
||||
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
|
||||
</span>
|
||||
|
||||
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue hover:underline' onClick={handleTranslate}>
|
||||
<button className={buttonClassName} onClick={handleTranslate}>
|
||||
<Icon className='h-5 w-5 stroke-[1.25]' src={require('@tabler/icons/language.svg')} strokeWidth={1.25} />
|
||||
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
|
||||
</button>
|
||||
</Stack>
|
||||
|
@ -61,7 +66,8 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue text-start text-sm hover:underline' onClick={handleTranslate}>
|
||||
<button className={buttonClassName} onClick={handleTranslate}>
|
||||
<Icon className='h-5 w-5' src={require('@tabler/icons/language.svg')} strokeWidth={1.25} />
|
||||
<FormattedMessage id='status.translate' defaultMessage='Translate' />
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -66,7 +66,7 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
|
|||
return <Icon src={icon} className='w-4 h-4' />;
|
||||
};
|
||||
|
||||
const handleClick = React.useCallback((event) => {
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((event) => {
|
||||
if (onClick && !disabled) {
|
||||
onClick(event);
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ interface ICardHeader {
|
|||
backHref?: string,
|
||||
onBackClick?: (event: React.MouseEvent) => void
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -91,6 +92,8 @@ const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
|
|||
interface ICardBody {
|
||||
/** Classnames for the <div> element. */
|
||||
className?: string
|
||||
/** Children to appear inside the card. */
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** A card's body. */
|
||||
|
|
|
@ -46,6 +46,8 @@ export interface IColumn {
|
|||
className?: string,
|
||||
/** Ref forwarded to column. */
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
/** Children to display in the column. */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
|
|
|
@ -2,8 +2,12 @@ import React from 'react';
|
|||
|
||||
import HStack from '../hstack/hstack';
|
||||
|
||||
interface IFormActions {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Container element to house form actions. */
|
||||
const FormActions: React.FC = ({ children }) => (
|
||||
const FormActions: React.FC<IFormActions> = ({ children }) => (
|
||||
<HStack space={2} justifyContent='end'>
|
||||
{children}
|
||||
</HStack>
|
||||
|
|
|
@ -14,6 +14,8 @@ interface IFormGroup {
|
|||
hintText?: React.ReactNode,
|
||||
/** Input errors. */
|
||||
errors?: string[]
|
||||
/** Elements to display within the FormGroup. */
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Input container with label. Renders the child. */
|
||||
|
|
|
@ -5,11 +5,13 @@ interface IForm {
|
|||
onSubmit?: (event: React.FormEvent) => void,
|
||||
/** Class name override for the <form> element. */
|
||||
className?: string,
|
||||
/** Elements to display within the Form. */
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
/** Form element with custom styles. */
|
||||
const Form: React.FC<IForm> = ({ onSubmit, children, ...filteredProps }) => {
|
||||
const handleSubmit = React.useCallback((event) => {
|
||||
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (onSubmit) {
|
||||
|
|
|
@ -2,10 +2,21 @@ import classNames from 'clsx';
|
|||
import React from 'react';
|
||||
import StickyBox from 'react-sticky-box';
|
||||
|
||||
interface LayoutComponent extends React.FC {
|
||||
Sidebar: React.FC,
|
||||
interface ISidebar {
|
||||
children: React.ReactNode
|
||||
}
|
||||
interface IAside {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
interface ILayout {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface LayoutComponent extends React.FC<ILayout> {
|
||||
Sidebar: React.FC<ISidebar>,
|
||||
Main: React.FC<React.HTMLAttributes<HTMLDivElement>>,
|
||||
Aside: React.FC,
|
||||
Aside: React.FC<IAside>,
|
||||
}
|
||||
|
||||
/** Layout container, to hold Sidebar, Main, and Aside. */
|
||||
|
@ -18,7 +29,7 @@ const Layout: LayoutComponent = ({ children }) => (
|
|||
);
|
||||
|
||||
/** Left sidebar container in the UI. */
|
||||
const Sidebar: React.FC = ({ children }) => (
|
||||
const Sidebar: React.FC<ISidebar> = ({ children }) => (
|
||||
<div className='hidden lg:block lg:col-span-3'>
|
||||
<StickyBox offsetTop={80} className='pb-4'>
|
||||
{children}
|
||||
|
@ -38,7 +49,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
|
|||
);
|
||||
|
||||
/** Right sidebar container in the UI. */
|
||||
const Aside: React.FC = ({ children }) => (
|
||||
const Aside: React.FC<IAside> = ({ children }) => (
|
||||
<aside className='hidden xl:block xl:col-span-3'>
|
||||
<StickyBox offsetTop={80} className='space-y-6 pb-12'>
|
||||
{children}
|
||||
|
|
|
@ -40,6 +40,8 @@ interface IModal {
|
|||
confirmationText?: React.ReactNode,
|
||||
/** Confirmation button theme. */
|
||||
confirmationTheme?: ButtonThemes,
|
||||
/** Whether to use full width style for confirmation button. */
|
||||
confirmationFullWidth?: boolean,
|
||||
/** Callback when the modal is closed. */
|
||||
onClose?: () => void,
|
||||
/** Callback when the secondary action is chosen. */
|
||||
|
@ -52,6 +54,7 @@ interface IModal {
|
|||
/** Title text for the modal. */
|
||||
title?: React.ReactNode,
|
||||
width?: keyof typeof widths,
|
||||
children?: React.ReactNode,
|
||||
}
|
||||
|
||||
/** Displays a modal dialog box. */
|
||||
|
@ -65,6 +68,7 @@ const Modal: React.FC<IModal> = ({
|
|||
confirmationDisabled,
|
||||
confirmationText,
|
||||
confirmationTheme,
|
||||
confirmationFullWidth,
|
||||
onClose,
|
||||
secondaryAction,
|
||||
secondaryDisabled = false,
|
||||
|
@ -117,7 +121,7 @@ const Modal: React.FC<IModal> = ({
|
|||
|
||||
{confirmationAction && (
|
||||
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
|
||||
<div className='flex-grow'>
|
||||
<div className={classNames({ 'flex-grow': !confirmationFullWidth })}>
|
||||
{cancelAction && (
|
||||
<Button
|
||||
theme='tertiary'
|
||||
|
@ -128,7 +132,7 @@ const Modal: React.FC<IModal> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<HStack space={2}>
|
||||
<HStack space={2} className={classNames({ 'flex-grow': confirmationFullWidth })}>
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
|
@ -144,6 +148,7 @@ const Modal: React.FC<IModal> = ({
|
|||
onClick={confirmationAction}
|
||||
disabled={confirmationDisabled}
|
||||
ref={buttonRef}
|
||||
block={confirmationFullWidth}
|
||||
>
|
||||
{confirmationText}
|
||||
</Button>
|
||||
|
|
|
@ -21,6 +21,7 @@ interface IAnimatedInterface {
|
|||
onChange(index: number): void,
|
||||
/** Default tab index. */
|
||||
defaultIndex: number
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Tabs with a sliding active state. */
|
||||
|
|
|
@ -7,6 +7,8 @@ import './tooltip.css';
|
|||
interface ITooltip {
|
||||
/** Text to display in the tooltip. */
|
||||
text: string,
|
||||
/** Element to display the tooltip around. */
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
const centered = (triggerRect: any, tooltipRect: any) => {
|
||||
|
|
|
@ -12,8 +12,12 @@ const WidgetTitle = ({ title }: IWidgetTitle): JSX.Element => (
|
|||
<Text size='xl' weight='bold' tag='h1'>{title}</Text>
|
||||
);
|
||||
|
||||
interface IWidgetBody {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Body of a widget. */
|
||||
const WidgetBody: React.FC = ({ children }): JSX.Element => (
|
||||
const WidgetBody: React.FC<IWidgetBody> = ({ children }): JSX.Element => (
|
||||
<Stack space={3}>{children}</Stack>
|
||||
);
|
||||
|
||||
|
@ -27,6 +31,7 @@ interface IWidget {
|
|||
/** Text for the action. */
|
||||
actionTitle?: string,
|
||||
action?: JSX.Element,
|
||||
children?: React.ReactNode,
|
||||
}
|
||||
|
||||
/** Sidebar widget. */
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
|
||||
interface IGroupContainer {
|
||||
id: string
|
||||
}
|
||||
|
||||
const GroupContainer: React.FC<IGroupContainer> = (props) => {
|
||||
const { id, ...rest } = props;
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
|
||||
if (group) {
|
||||
return <GroupCard group={group} {...rest} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default GroupContainer;
|
|
@ -7,6 +7,7 @@ import { Toaster } from 'react-hot-toast';
|
|||
import { IntlProvider } from 'react-intl';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
// @ts-ignore: it doesn't have types
|
||||
import { ScrollContext } from 'react-router-scroll-4';
|
||||
|
||||
|
@ -40,6 +41,7 @@ import {
|
|||
useTheme,
|
||||
useLocale,
|
||||
useInstance,
|
||||
useRegistrationStatus,
|
||||
} from 'soapbox/hooks';
|
||||
import MESSAGES from 'soapbox/locales/messages';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
|
@ -92,13 +94,12 @@ const SoapboxMount = () => {
|
|||
const account = useOwnAccount();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const features = useFeatures();
|
||||
const { pepeEnabled } = useRegistrationStatus();
|
||||
|
||||
const waitlisted = account && !account.source.get('approved', true);
|
||||
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
|
||||
const showOnboarding = account && !waitlisted && needsOnboarding;
|
||||
const singleUserMode = soapboxConfig.singleUserMode && soapboxConfig.singleUserModeProfile;
|
||||
|
||||
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
|
||||
const { redirectRootNoLogin } = soapboxConfig;
|
||||
|
||||
// @ts-ignore: I don't actually know what these should be, lol
|
||||
const shouldUpdateScroll = (prevRouterProps, { location }) => {
|
||||
|
@ -134,8 +135,8 @@ const SoapboxMount = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{!me && (singleUserMode
|
||||
? <Redirect exact from='/' to={`/${singleUserMode}`} />
|
||||
{!me && (redirectRootNoLogin
|
||||
? <Redirect exact from='/' to={redirectRootNoLogin} />
|
||||
: <Route exact path='/' component={PublicLayout} />)}
|
||||
|
||||
{!me && (
|
||||
|
@ -173,26 +174,28 @@ const SoapboxMount = () => {
|
|||
return (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
|
||||
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
|
||||
<Switch>
|
||||
<Route
|
||||
path='/embed/:statusId'
|
||||
render={(props) => <EmbeddedStatus params={props.match.params} />}
|
||||
/>
|
||||
<Redirect from='/@:username/:statusId/embed' to='/embed/:statusId' />
|
||||
<CompatRouter>
|
||||
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
|
||||
<Switch>
|
||||
<Route
|
||||
path='/embed/:statusId'
|
||||
render={(props) => <EmbeddedStatus params={props.match.params} />}
|
||||
/>
|
||||
<Redirect from='/@:username/:statusId/embed' to='/embed/:statusId' />
|
||||
|
||||
<Route>
|
||||
{renderBody()}
|
||||
<Route>
|
||||
{renderBody()}
|
||||
|
||||
<BundleContainer fetchComponent={ModalContainer}>
|
||||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
<BundleContainer fetchComponent={ModalContainer}>
|
||||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
|
||||
<GdprBanner />
|
||||
<Toaster position='top-right' containerClassName='top-10' containerStyle={{ top: 75 }} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</ScrollContext>
|
||||
<GdprBanner />
|
||||
<Toaster position='top-right' containerClassName='top-10' containerStyle={{ top: 75 }} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</ScrollContext>
|
||||
</CompatRouter>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
|
|
@ -19,7 +19,11 @@ enum ChatWidgetScreens {
|
|||
CHAT_SETTINGS = 'CHAT_SETTINGS'
|
||||
}
|
||||
|
||||
const ChatProvider: React.FC = ({ children }) => {
|
||||
interface IChatProvider {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ChatProvider: React.FC<IChatProvider> = ({ children }) => {
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const settings = useSettings();
|
||||
|
|
|
@ -9,7 +9,11 @@ const StatContext = createContext<any>({
|
|||
unreadChatsCount: 0,
|
||||
});
|
||||
|
||||
const StatProvider: React.FC = ({ children }) => {
|
||||
interface IStatProvider {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const StatProvider: React.FC<IStatProvider> = ({ children }) => {
|
||||
const [unreadChatsCount, setUnreadChatsCount] = useState<number>(0);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
|
|
|
@ -11,7 +11,7 @@ import { compareId } from 'soapbox/utils/comparators';
|
|||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' },
|
||||
expand: { id: 'admin.latest_accounts_panel.more', defaultMessage: 'Click to see {count} {count, plural, one {account} other {accounts}}' },
|
||||
expand: { id: 'admin.latest_accounts_panel.more', defaultMessage: 'Click to see {count, plural, one {# account} other {# accounts}}' },
|
||||
});
|
||||
|
||||
interface ILatestAccountsPanel {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -24,9 +25,9 @@ const Ad: React.FC<IAd> = ({ ad }) => {
|
|||
|
||||
// Fetch the impression URL (if any) upon displaying the ad.
|
||||
// Don't fetch it more than once.
|
||||
useQuery(['ads', 'impression', ad.impression], () => {
|
||||
useQuery(['ads', 'impression', ad.impression], async () => {
|
||||
if (ad.impression) {
|
||||
return fetch(ad.impression);
|
||||
return await axios.get(ad.impression);
|
||||
}
|
||||
}, { cacheTime: Infinity, staleTime: Infinity });
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import axios from 'axios';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { normalizeAd, normalizeCard } from 'soapbox/normalizers';
|
||||
|
@ -28,14 +30,13 @@ const RumbleAdProvider: AdProvider = {
|
|||
const endpoint = soapboxConfig.extensions.getIn(['ads', 'endpoint']) as string | undefined;
|
||||
|
||||
if (endpoint) {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Accept-Language': settings.get('locale', '*') as string,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { data } = await axios.get<RumbleApiResponse>(endpoint, {
|
||||
headers: {
|
||||
'Accept-Language': settings.get('locale', '*') as string,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as RumbleApiResponse;
|
||||
return data.ads.map(item => normalizeAd({
|
||||
impression: item.impression,
|
||||
card: normalizeCard({
|
||||
|
@ -45,6 +46,8 @@ const RumbleAdProvider: AdProvider = {
|
|||
}),
|
||||
expires_at: new Date(item.expires * 1000),
|
||||
}));
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import axios from 'axios';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { normalizeCard } from 'soapbox/normalizers';
|
||||
|
||||
|
@ -18,18 +20,19 @@ const TruthAdProvider: AdProvider = {
|
|||
const state = getState();
|
||||
const settings = getSettings(state);
|
||||
|
||||
const response = await fetch('/api/v2/truth/ads?device=desktop', {
|
||||
headers: {
|
||||
'Accept-Language': settings.get('locale', '*') as string,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { data } = await axios.get<TruthAd[]>('/api/v2/truth/ads?device=desktop', {
|
||||
headers: {
|
||||
'Accept-Language': settings.get('locale', '*') as string,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as TruthAd[];
|
||||
return data.map(item => ({
|
||||
...item,
|
||||
card: normalizeCard(item.card),
|
||||
}));
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return [];
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Link, Redirect, Route, Switch, useHistory, useLocation } from 'react-ro
|
|||
|
||||
import LandingGradient from 'soapbox/components/landing-gradient';
|
||||
import SiteLogo from 'soapbox/components/site-logo';
|
||||
import { useAppSelector, useFeatures, useSoapboxConfig, useOwnAccount, useInstance } from 'soapbox/hooks';
|
||||
import { useOwnAccount, useInstance, useRegistrationStatus } from 'soapbox/hooks';
|
||||
|
||||
import { Button, Card, CardBody } from '../../components/ui';
|
||||
import LoginPage from '../auth-login/components/login-page';
|
||||
|
@ -28,14 +28,8 @@ const AuthLayout = () => {
|
|||
|
||||
const account = useOwnAccount();
|
||||
const instance = useInstance();
|
||||
const features = useFeatures();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
|
||||
const isOpen = features.accountCreation && instance.registrations;
|
||||
const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);
|
||||
const { isOpen } = useRegistrationStatus();
|
||||
const isLoginPage = history.location.pathname === '/login';
|
||||
const shouldShowRegisterLink = (isLoginPage && (isOpen || (pepeEnabled && pepeOpen)));
|
||||
|
||||
return (
|
||||
<div className='h-full'>
|
||||
|
@ -50,7 +44,7 @@ const AuthLayout = () => {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
{shouldShowRegisterLink && (
|
||||
{(isLoginPage && isOpen) && (
|
||||
<div className='relative z-10 ml-auto flex items-center'>
|
||||
<Button
|
||||
theme='tertiary'
|
||||
|
|
|
@ -32,7 +32,7 @@ const PasswordResetConfirm = () => {
|
|||
|
||||
const isLoading = status === Statuses.LOADING;
|
||||
|
||||
const handleSubmit = React.useCallback((event) => {
|
||||
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
|
||||
setStatus(Statuses.LOADING);
|
||||
|
@ -41,7 +41,7 @@ const PasswordResetConfirm = () => {
|
|||
.catch(() => setStatus(Statuses.FAIL));
|
||||
}, [password]);
|
||||
|
||||
const onChange = React.useCallback((event) => {
|
||||
const onChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((event) => {
|
||||
setPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -224,124 +224,127 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
|
|||
return (
|
||||
<Form onSubmit={onSubmit} data-testid='registrations-open'>
|
||||
<fieldset disabled={isLoading} className='space-y-3'>
|
||||
<FormGroup
|
||||
hintText={intl.formatMessage(messages.username_hint)}
|
||||
errors={usernameUnavailable ? [intl.formatMessage(messages.usernameUnavailable)] : undefined}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
name='username'
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
pattern='^[a-zA-Z\d_-]+'
|
||||
onChange={onUsernameChange}
|
||||
value={params.get('username', '')}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Input
|
||||
type='email'
|
||||
name='email'
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onInputChange}
|
||||
value={params.get('email', '')}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type='password'
|
||||
name='password'
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onPasswordChange}
|
||||
value={params.get('password', '')}
|
||||
required
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
errors={passwordMismatch ? [intl.formatMessage(messages.passwordMismatch)] : undefined}
|
||||
>
|
||||
<Input
|
||||
type='password'
|
||||
name='password_confirmation'
|
||||
placeholder={intl.formatMessage(messages.confirm)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onPasswordConfirmChange}
|
||||
onBlur={onPasswordConfirmBlur}
|
||||
value={passwordConfirmation}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{birthdayRequired && (
|
||||
<BirthdayInput
|
||||
value={params.get('birthday')}
|
||||
onChange={onBirthdayChange}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{needsApproval && (
|
||||
<>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='registration.reason' defaultMessage='Why do you want to join?' />}
|
||||
hintText={<FormattedMessage id='registration.reason_hint' defaultMessage='This will help us review your application' />}
|
||||
hintText={intl.formatMessage(messages.username_hint)}
|
||||
errors={usernameUnavailable ? [intl.formatMessage(messages.usernameUnavailable)] : undefined}
|
||||
>
|
||||
<Textarea
|
||||
name='reason'
|
||||
maxLength={500}
|
||||
onChange={onInputChange}
|
||||
value={params.get('reason', '')}
|
||||
<Input
|
||||
type='text'
|
||||
name='username'
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
pattern='^[a-zA-Z\d_-]+'
|
||||
icon={require('@tabler/icons/at.svg')}
|
||||
onChange={onUsernameChange}
|
||||
value={params.get('username', '')}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
<CaptchaField
|
||||
onFetch={onFetchCaptcha}
|
||||
onFetchFail={onFetchCaptchaFail}
|
||||
onChange={onInputChange}
|
||||
onClick={onCaptchaClick}
|
||||
idempotencyKey={captchaIdempotencyKey}
|
||||
name='captcha_solution'
|
||||
value={params.get('captcha_solution', '')}
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.agreement, { tos: <Link to='/about/tos' target='_blank' key={0}>{intl.formatMessage(messages.tos)}</Link> })}
|
||||
>
|
||||
<Checkbox
|
||||
name='agreement'
|
||||
onChange={onCheckboxChange}
|
||||
checked={params.get('agreement', false)}
|
||||
<Input
|
||||
type='email'
|
||||
name='email'
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onInputChange}
|
||||
value={params.get('email', '')}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{supportsEmailList && (
|
||||
<FormGroup labelText={intl.formatMessage(messages.newsletter)}>
|
||||
<Checkbox
|
||||
name='accepts_email_list'
|
||||
onChange={onCheckboxChange}
|
||||
checked={params.get('accepts_email_list', false)}
|
||||
<Input
|
||||
type='password'
|
||||
name='password'
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onPasswordChange}
|
||||
value={params.get('password', '')}
|
||||
required
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
errors={passwordMismatch ? [intl.formatMessage(messages.passwordMismatch)] : undefined}
|
||||
>
|
||||
<Input
|
||||
type='password'
|
||||
name='password_confirmation'
|
||||
placeholder={intl.formatMessage(messages.confirm)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onPasswordConfirmChange}
|
||||
onBlur={onPasswordConfirmBlur}
|
||||
value={passwordConfirmation}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
<FormActions>
|
||||
<Button type='submit'>
|
||||
<FormattedMessage id='registration.sign_up' defaultMessage='Sign up' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
{birthdayRequired && (
|
||||
<BirthdayInput
|
||||
value={params.get('birthday')}
|
||||
onChange={onBirthdayChange}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{needsApproval && (
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='registration.reason' defaultMessage='Why do you want to join?' />}
|
||||
hintText={<FormattedMessage id='registration.reason_hint' defaultMessage='This will help us review your application' />}
|
||||
>
|
||||
<Textarea
|
||||
name='reason'
|
||||
maxLength={500}
|
||||
onChange={onInputChange}
|
||||
value={params.get('reason', '')}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
<CaptchaField
|
||||
onFetch={onFetchCaptcha}
|
||||
onFetchFail={onFetchCaptchaFail}
|
||||
onChange={onInputChange}
|
||||
onClick={onCaptchaClick}
|
||||
idempotencyKey={captchaIdempotencyKey}
|
||||
name='captcha_solution'
|
||||
value={params.get('captcha_solution', '')}
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.agreement, { tos: <Link to='/about/tos' target='_blank' key={0}>{intl.formatMessage(messages.tos)}</Link> })}
|
||||
>
|
||||
<Checkbox
|
||||
name='agreement'
|
||||
onChange={onCheckboxChange}
|
||||
checked={params.get('agreement', false)}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{supportsEmailList && (
|
||||
<FormGroup labelText={intl.formatMessage(messages.newsletter)}>
|
||||
<Checkbox
|
||||
name='accepts_email_list'
|
||||
onChange={onCheckboxChange}
|
||||
checked={params.get('accepts_email_list', false)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
<FormActions>
|
||||
<Button type='submit'>
|
||||
<FormattedMessage id='registration.sign_up' defaultMessage='Sign up' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</>
|
||||
</fieldset>
|
||||
</Form>
|
||||
);
|
||||
|
|
|
@ -45,17 +45,19 @@ const AuthToken: React.FC<IAuthToken> = ({ token, isCurrent }) => {
|
|||
<Stack space={2}>
|
||||
<Stack>
|
||||
<Text size='md' weight='medium'>{token.app_name}</Text>
|
||||
<Text size='sm' theme='muted'>
|
||||
<FormattedDate
|
||||
value={new Date(token.valid_until)}
|
||||
hour12
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='numeric'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</Text>
|
||||
{token.valid_until && (
|
||||
<Text size='sm' theme='muted'>
|
||||
<FormattedDate
|
||||
value={token.valid_until}
|
||||
hour12
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='numeric'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchBackups, createBackup } from 'soapbox/actions/backups';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { Button, Column, FormActions, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -23,22 +22,14 @@ const Backups = () => {
|
|||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const handleCreateBackup: React.MouseEventHandler<HTMLAnchorElement> = e => {
|
||||
const handleCreateBackup: React.MouseEventHandler = e => {
|
||||
dispatch(createBackup());
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const makeColumnMenu = () => {
|
||||
return [{
|
||||
text: intl.formatMessage(messages.create),
|
||||
action: handleCreateBackup,
|
||||
icon: require('@tabler/icons/plus.svg'),
|
||||
}];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchBackups()).then(() => {
|
||||
setIsLoading(true);
|
||||
setIsLoading(false);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
|
@ -46,16 +37,14 @@ const Backups = () => {
|
|||
|
||||
const emptyMessageAction = (
|
||||
<a href='#' onClick={handleCreateBackup}>
|
||||
{intl.formatMessage(messages.emptyMessageAction)}
|
||||
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
|
||||
{intl.formatMessage(messages.emptyMessageAction)}
|
||||
</Text>
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
// @ts-ignore FIXME: make this menu available.
|
||||
menu={makeColumnMenu()}
|
||||
>
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
|
@ -64,16 +53,22 @@ const Backups = () => {
|
|||
>
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
className={classNames('backup', { 'backup--pending': !backup.processed })}
|
||||
className='p-4'
|
||||
key={backup.id}
|
||||
>
|
||||
{backup.processed
|
||||
? <a href={backup.url} target='_blank'>{backup.inserted_at}</a>
|
||||
: <div>{intl.formatMessage(messages.pending)}: {backup.inserted_at}</div>
|
||||
: <Text theme='subtle'>{intl.formatMessage(messages.pending)}: {backup.inserted_at}</Text>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<FormActions>
|
||||
<Button theme='primary' disabled={isLoading} onClick={handleCreateBackup}>
|
||||
{intl.formatMessage(messages.create)}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,7 +19,7 @@ const messages = defineMessages({
|
|||
accept: { id: 'chat_message_list_intro.actions.accept', defaultMessage: 'Accept' },
|
||||
leaveChat: { id: 'chat_message_list_intro.actions.leave_chat', defaultMessage: 'Leave chat' },
|
||||
report: { id: 'chat_message_list_intro.actions.report', defaultMessage: 'Report' },
|
||||
messageLifespan: { id: 'chat_message_list_intro.actions.message_lifespan', defaultMessage: 'Messages older than {day} days are deleted.' },
|
||||
messageLifespan: { id: 'chat_message_list_intro.actions.message_lifespan', defaultMessage: 'Messages older than {day, plural, one {# day} other {# days}} are deleted.' },
|
||||
});
|
||||
|
||||
const ChatMessageListIntro = () => {
|
||||
|
|
|
@ -246,7 +246,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
|
||||
const menu: Menu = [];
|
||||
|
||||
if (navigator.clipboard) {
|
||||
if (navigator.clipboard && chatMessage.content) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: () => handleCopyText(chatMessage),
|
||||
|
@ -312,39 +312,47 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<HStack
|
||||
alignItems='bottom'
|
||||
<Stack
|
||||
space={0.5}
|
||||
className={classNames({
|
||||
'max-w-[85%]': true,
|
||||
'flex-1': chatMessage.attachment,
|
||||
'order-2': isMyMessage,
|
||||
'order-1': !isMyMessage,
|
||||
})}
|
||||
justifyContent={isMyMessage ? 'end' : 'start'}
|
||||
alignItems={isMyMessage ? 'end' : 'start'}
|
||||
>
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className={
|
||||
classNames({
|
||||
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
|
||||
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
|
||||
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
|
||||
'bg-primary-500 text-white': isMyMessage,
|
||||
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
|
||||
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
|
||||
})
|
||||
}
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
<Text
|
||||
size='sm'
|
||||
theme='inherit'
|
||||
className='break-word-nested'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
|
||||
{content && (
|
||||
<HStack alignItems='bottom' className='max-w-full'>
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className={
|
||||
classNames({
|
||||
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
|
||||
'rounded-tr-sm': chatMessage.attachment && isMyMessage,
|
||||
'rounded-tl-sm': chatMessage.attachment && !isMyMessage,
|
||||
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
|
||||
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
|
||||
'bg-primary-500 text-white': isMyMessage,
|
||||
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
|
||||
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
|
||||
})
|
||||
}
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
size='sm'
|
||||
theme='inherit'
|
||||
className='break-word-nested'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
|
|
|
@ -38,8 +38,8 @@ const messages = defineMessages({
|
|||
autoDelete14Days: { id: 'chat_settings.auto_delete.14days', defaultMessage: '14 days' },
|
||||
autoDelete30Days: { id: 'chat_settings.auto_delete.30days', defaultMessage: '30 days' },
|
||||
autoDelete90Days: { id: 'chat_settings.auto_delete.90days', defaultMessage: '90 days' },
|
||||
autoDeleteMessage: { id: 'chat_window.auto_delete_label', defaultMessage: 'Auto-delete after {day} days' },
|
||||
autoDeleteMessageTooltip: { id: 'chat_window.auto_delete_tooltip', defaultMessage: 'Chat messages are set to auto-delete after {day} days upon sending.' },
|
||||
autoDeleteMessage: { id: 'chat_window.auto_delete_label', defaultMessage: 'Auto-delete after {day, plural, one {# day} other {# days}}' },
|
||||
autoDeleteMessageTooltip: { id: 'chat_window.auto_delete_tooltip', defaultMessage: 'Chat messages are set to auto-delete after {day, plural, one {# day} other {# days}} upon sending.' },
|
||||
});
|
||||
|
||||
const ChatPageMain = () => {
|
||||
|
|
|
@ -37,7 +37,7 @@ const Welcome = () => {
|
|||
|
||||
return (
|
||||
<Stack className='py-20 px-4 sm:px-0 h-full overflow-y-auto' data-testid='chats-welcome'>
|
||||
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-10'>
|
||||
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-2.5'>
|
||||
<Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'>
|
||||
{intl.formatMessage(messages.title, { br: <br /> })}
|
||||
</Text>
|
||||
|
@ -77,4 +77,4 @@ const Welcome = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
export default Welcome;
|
||||
|
|
|
@ -6,6 +6,8 @@ import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
|
|||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import useAccountSearch from 'soapbox/queries/search';
|
||||
|
||||
import type { Account } from 'soapbox/types/entities';
|
||||
|
||||
interface IResults {
|
||||
accountSearchResult: ReturnType<typeof useAccountSearch>
|
||||
onSelect(id: string): void
|
||||
|
@ -23,7 +25,7 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => {
|
|||
}
|
||||
};
|
||||
|
||||
const renderAccount = useCallback((_index, account) => (
|
||||
const renderAccount = useCallback((_index: number, account: Account) => (
|
||||
<button
|
||||
key={account.id}
|
||||
type='button'
|
||||
|
|
|
@ -27,7 +27,7 @@ const messages = defineMessages({
|
|||
unblockUser: { id: 'chat_settings.options.unblock_user', defaultMessage: 'Unblock @{acct}' },
|
||||
leaveChat: { id: 'chat_settings.options.leave_chat', defaultMessage: 'Leave Chat' },
|
||||
autoDeleteLabel: { id: 'chat_settings.auto_delete.label', defaultMessage: 'Auto-delete messages' },
|
||||
autoDeleteDays: { id: 'chat_settings.auto_delete.days', defaultMessage: '{day} days' },
|
||||
autoDeleteDays: { id: 'chat_settings.auto_delete.days', defaultMessage: '{day, plural, one {# day} other {# days}}' },
|
||||
});
|
||||
|
||||
const ChatSettings = () => {
|
||||
|
|
|
@ -13,8 +13,8 @@ import ChatPaneHeader from './chat-pane-header';
|
|||
import ChatSettings from './chat-settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
autoDeleteMessage: { id: 'chat_window.auto_delete_label', defaultMessage: 'Auto-delete after {day} days' },
|
||||
autoDeleteMessageTooltip: { id: 'chat_window.auto_delete_tooltip', defaultMessage: 'Chat messages are set to auto-delete after {day} days upon sending.' },
|
||||
autoDeleteMessage: { id: 'chat_window.auto_delete_label', defaultMessage: 'Auto-delete after {day, plural, one {# day} other {# days}}' },
|
||||
autoDeleteMessageTooltip: { id: 'chat_window.auto_delete_tooltip', defaultMessage: 'Chat messages are set to auto-delete after {day, plural, one {# day} other {# days}} upon sending.' },
|
||||
});
|
||||
|
||||
const LinkWrapper = ({ enabled, to, children }: { enabled: boolean, to: string, children: React.ReactNode }): JSX.Element => {
|
||||
|
|
|
@ -15,7 +15,6 @@ import {
|
|||
} from 'soapbox/actions/compose';
|
||||
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useFeatures, useInstance, usePrevious } from 'soapbox/hooks';
|
||||
import { isMobile } from 'soapbox/is-mobile';
|
||||
|
@ -63,9 +62,10 @@ interface IComposeForm<ID extends string> {
|
|||
autoFocus?: boolean,
|
||||
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
||||
event?: string,
|
||||
group?: string,
|
||||
}
|
||||
|
||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event }: IComposeForm<ID>) => {
|
||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event, group }: IComposeForm<ID>) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
@ -78,7 +78,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
|
||||
const features = useFeatures();
|
||||
|
||||
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose;
|
||||
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt, group_id: groupId } = compose;
|
||||
const prevSpoiler = usePrevious(spoiler);
|
||||
|
||||
const hasPoll = !!compose.poll;
|
||||
|
@ -228,7 +228,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
{features.media && <UploadButtonContainer composeId={id} />}
|
||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
|
||||
{features.polls && <PollButton composeId={id} />}
|
||||
{features.privacyScopes && <PrivacyDropdown composeId={id} />}
|
||||
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
|
||||
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
||||
{features.spoilers && <SpoilerButton composeId={id} />}
|
||||
{features.richText && <MarkdownButton composeId={id} />}
|
||||
|
@ -241,25 +241,18 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
|
||||
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
|
||||
|
||||
let publishText: string | JSX.Element = '';
|
||||
let publishText: string = '';
|
||||
let publishIcon: string | undefined;
|
||||
let textareaPlaceholder: MessageDescriptor;
|
||||
|
||||
if (isEditing) {
|
||||
publishText = intl.formatMessage(messages.saveChanges);
|
||||
} else if (privacy === 'direct') {
|
||||
publishText = (
|
||||
<>
|
||||
<Icon src={require('@tabler/icons/mail.svg')} />
|
||||
{intl.formatMessage(messages.message)}
|
||||
</>
|
||||
);
|
||||
publishText = intl.formatMessage(messages.message);
|
||||
publishIcon = require('@tabler/icons/mail.svg');
|
||||
} else if (privacy === 'private') {
|
||||
publishText = (
|
||||
<>
|
||||
<Icon src={require('@tabler/icons/lock.svg')} />
|
||||
{intl.formatMessage(messages.publish)}
|
||||
</>
|
||||
);
|
||||
publishText = intl.formatMessage(messages.publish);
|
||||
publishIcon = require('@tabler/icons/lock.svg');
|
||||
} else {
|
||||
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
||||
}
|
||||
|
@ -278,7 +271,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
return (
|
||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||
{scheduledStatusCount > 0 && !event && (
|
||||
{scheduledStatusCount > 0 && !event && !group && (
|
||||
<Warning
|
||||
message={(
|
||||
<FormattedMessage
|
||||
|
@ -299,9 +292,9 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
<WarningContainer composeId={id} />
|
||||
|
||||
{!shouldCondense && !event && <ReplyIndicatorContainer composeId={id} />}
|
||||
{!shouldCondense && !event && !group && <ReplyIndicatorContainer composeId={id} />}
|
||||
|
||||
{!shouldCondense && !event && <ReplyMentions composeId={id} />}
|
||||
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
|
||||
|
||||
<AutosuggestTextarea
|
||||
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
||||
|
@ -355,10 +348,8 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
</HStack>
|
||||
)}
|
||||
|
||||
<Button type='submit' theme='primary' text={publishText} disabled={disabledButton} />
|
||||
<Button type='submit' theme='primary' text={publishText} icon={publishIcon} disabled={disabledButton} />
|
||||
</HStack>
|
||||
{/* <HStack alignItems='center' space={4}>
|
||||
</HStack> */}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -72,8 +72,8 @@ const EmojiPickerMenu: React.FC<IEmojiPickerMenu> = ({
|
|||
|
||||
categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(customEmojis) as Set<string>).sort());
|
||||
|
||||
const handleDocumentClick = useCallback(e => {
|
||||
if (node.current && !node.current.contains(e.target)) {
|
||||
const handleDocumentClick = useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (node.current && !node.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}, []);
|
||||
|
|
|
@ -19,8 +19,8 @@ const ModifierPickerMenu: React.FC<IModifierPickerMenu> = ({ active, onSelect, o
|
|||
onSelect(+e.currentTarget.getAttribute('data-index')! * 1);
|
||||
};
|
||||
|
||||
const handleDocumentClick = useCallback((e => {
|
||||
if (node.current && !node.current.contains(e.target)) {
|
||||
const handleDocumentClick = useCallback(((e: MouseEvent | TouchEvent) => {
|
||||
if (node.current && !node.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}), []);
|
||||
|
|
|
@ -40,6 +40,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCanc
|
|||
timestamp={status.created_at}
|
||||
showProfileHoverCard={false}
|
||||
withLinkToProfile={false}
|
||||
hideActions={hideActions}
|
||||
/>
|
||||
|
||||
<Text
|
||||
|
|
|
@ -9,11 +9,13 @@ import IconButton from 'soapbox/components/icon-button';
|
|||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { HStack, Tabs, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import GroupContainer from 'soapbox/containers/group-container';
|
||||
import StatusContainer from 'soapbox/containers/status-container';
|
||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
||||
import PlaceholderGroupCard from 'soapbox/features/placeholder/components/placeholder-group-card';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
@ -22,6 +24,7 @@ import type { SearchFilter } from 'soapbox/reducers/search';
|
|||
const messages = defineMessages({
|
||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||
groups: { id: 'search_results.groups', defaultMessage: 'Groups' },
|
||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||
});
|
||||
|
||||
|
@ -30,6 +33,7 @@ const SearchResults = () => {
|
|||
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const value = useAppSelector((state) => state.search.submittedValue);
|
||||
const results = useAppSelector((state) => state.search.results);
|
||||
|
@ -48,7 +52,8 @@ const SearchResults = () => {
|
|||
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
||||
|
||||
const renderFilterBar = () => {
|
||||
const items = [
|
||||
const items = [];
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.accounts),
|
||||
action: () => selectFilter('accounts'),
|
||||
|
@ -59,12 +64,23 @@ const SearchResults = () => {
|
|||
action: () => selectFilter('statuses'),
|
||||
name: 'statuses',
|
||||
},
|
||||
);
|
||||
|
||||
if (features.groups) items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.groups),
|
||||
action: () => selectFilter('groups'),
|
||||
name: 'groups',
|
||||
},
|
||||
);
|
||||
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.hashtags),
|
||||
action: () => selectFilter('hashtags'),
|
||||
name: 'hashtags',
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||
};
|
||||
|
@ -170,6 +186,31 @@ const SearchResults = () => {
|
|||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'groups') {
|
||||
hasMore = results.groupsHasMore;
|
||||
loaded = results.groupsLoaded;
|
||||
placeholderComponent = PlaceholderGroupCard;
|
||||
|
||||
if (results.groups && results.groups.size > 0) {
|
||||
searchResults = results.groups.map((groupId: string) => (
|
||||
<GroupContainer id={groupId} />
|
||||
));
|
||||
resultsIds = results.groups;
|
||||
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
|
||||
searchResults = null;
|
||||
} else if (loaded) {
|
||||
noResultsMessage = (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage
|
||||
id='empty_column.search.groups'
|
||||
defaultMessage='There are no groups results for "{term}"'
|
||||
values={{ term: value }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'hashtags') {
|
||||
hasMore = results.hashtagsHasMore;
|
||||
loaded = results.hashtagsLoaded;
|
||||
|
|
|
@ -22,6 +22,7 @@ export interface IUploadButton {
|
|||
resetFileKey: number | null,
|
||||
className?: string,
|
||||
iconClassName?: string,
|
||||
icon?: string,
|
||||
}
|
||||
|
||||
const UploadButton: React.FC<IUploadButton> = ({
|
||||
|
@ -31,6 +32,7 @@ const UploadButton: React.FC<IUploadButton> = ({
|
|||
resetFileKey,
|
||||
className = 'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500',
|
||||
iconClassName,
|
||||
icon,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { configuration } = useInstance();
|
||||
|
@ -52,9 +54,11 @@ const UploadButton: React.FC<IUploadButton> = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
const src = onlyImages(attachmentTypes)
|
||||
? require('@tabler/icons/photo.svg')
|
||||
: require('@tabler/icons/paperclip.svg');
|
||||
const src = icon || (
|
||||
onlyImages(attachmentTypes)
|
||||
? require('@tabler/icons/photo.svg')
|
||||
: require('@tabler/icons/paperclip.svg')
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
|
@ -5,7 +5,7 @@ import { length } from 'stringz';
|
|||
import ProgressCircle from 'soapbox/components/progress-circle';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'compose.character_counter.title', defaultMessage: 'Used {chars} out of {maxChars} characters' },
|
||||
title: { id: 'compose.character_counter.title', defaultMessage: 'Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}' },
|
||||
});
|
||||
|
||||
interface IVisualCharacterCounter {
|
||||
|
|
|
@ -8,7 +8,7 @@ import { useInstance, useSoapboxConfig } from 'soapbox/hooks';
|
|||
import SiteWallet from './site-wallet';
|
||||
|
||||
const messages = defineMessages({
|
||||
actionTitle: { id: 'crypto_donate_panel.actions.view', defaultMessage: 'Click to see {count} {count, plural, one {wallet} other {wallets}}' },
|
||||
actionTitle: { id: 'crypto_donate_panel.actions.view', defaultMessage: 'Click to see {count, plural, one {# wallet} other {# wallets}}' },
|
||||
});
|
||||
|
||||
interface ICryptoDonatePanel {
|
||||
|
|
|
@ -36,7 +36,7 @@ const DetailedCryptoAddress: React.FC<IDetailedCryptoAddress> = ({ address, tick
|
|||
</div>
|
||||
{note && <div className='crypto-address__note'>{note}</div>}
|
||||
<div className='crypto-address__qrcode'>
|
||||
<QRCode value={address} />
|
||||
<QRCode className='rounded-lg' value={address} includeMargin />
|
||||
</div>
|
||||
|
||||
<CopyableInput value={address} />
|
||||
|
|
|
@ -3,10 +3,10 @@ import React from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import Account from 'soapbox/components/account';
|
||||
import Badge from 'soapbox/components/badge';
|
||||
import RelativeTimestamp from 'soapbox/components/relative-timestamp';
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import ActionButton from 'soapbox/features/ui/components/action-button';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
@ -51,8 +51,8 @@ const AccountCard: React.FC<IAccountCard> = ({ id }) => {
|
|||
</div>
|
||||
|
||||
<Stack space={4} className='p-3'>
|
||||
<AccountContainer
|
||||
id={account.id}
|
||||
<Account
|
||||
account={account}
|
||||
withRelationship={false}
|
||||
/>
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ const EditEmail = () => {
|
|||
|
||||
const { email, password } = state;
|
||||
|
||||
const handleInputChange = React.useCallback((event) => {
|
||||
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((event) => {
|
||||
event.persist();
|
||||
|
||||
setState((prevState) => ({ ...prevState, [event.target.name]: event.target.value }));
|
||||
|
|
|
@ -34,7 +34,7 @@ const EditPassword = () => {
|
|||
|
||||
const resetState = () => setState(initialState);
|
||||
|
||||
const handleInputChange = React.useCallback((event) => {
|
||||
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((event) => {
|
||||
event.persist();
|
||||
|
||||
setState((prevState) => ({ ...prevState, [event.target.name]: event.target.value }));
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -125,7 +126,7 @@ const accountToCredentials = (account: Account): AccountCredentials => {
|
|||
display_name: account.display_name,
|
||||
note: account.source.get('note'),
|
||||
locked: account.locked,
|
||||
fields_attributes: [...account.source.get<Iterable<AccountCredentialsField>>('fields', []).toJS()],
|
||||
fields_attributes: [...account.source.get<Iterable<AccountCredentialsField>>('fields', ImmutableList()).toJS()],
|
||||
stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true,
|
||||
accepts_email_list: account.getIn(['pleroma', 'accepts_email_list']) === true,
|
||||
hide_followers: hideNetwork,
|
||||
|
|
|
@ -7,7 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
|||
import { launchChat } from 'soapbox/actions/chats';
|
||||
import { directCompose, mentionCompose, quoteCompose } from 'soapbox/actions/compose';
|
||||
import { editEvent, fetchEventIcs } from 'soapbox/actions/events';
|
||||
import { toggleBookmark, togglePin } from 'soapbox/actions/interactions';
|
||||
import { toggleBookmark, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||
|
@ -18,7 +18,7 @@ import StillImage from 'soapbox/components/still-image';
|
|||
import { Button, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList, Stack, Text } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||
import { isRemote } from 'soapbox/utils/accounts';
|
||||
import copy from 'soapbox/utils/copy';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
|
@ -38,11 +38,11 @@ const messages = defineMessages({
|
|||
external: { id: 'event.external', defaultMessage: 'View event on {domain}' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
|
||||
quotePost: { id: 'status.quote', defaultMessage: 'Quote post' },
|
||||
quotePost: { id: 'event.quote', defaultMessage: 'Quote event' },
|
||||
reblog: { id: 'event.reblog', defaultMessage: 'Repost event' },
|
||||
unreblog: { id: 'event.unreblog', defaultMessage: 'Un-repost event' },
|
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' },
|
||||
|
@ -72,6 +72,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
|||
const history = useHistory();
|
||||
|
||||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const ownAccount = useOwnAccount();
|
||||
const isStaff = ownAccount ? ownAccount.staff : false;
|
||||
const isAdmin = ownAccount ? ownAccount.admin : false;
|
||||
|
@ -121,6 +122,16 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
|||
dispatch(toggleBookmark(status));
|
||||
};
|
||||
|
||||
const handleReblogClick = () => {
|
||||
const modalReblog = () => dispatch(toggleReblog(status));
|
||||
const boostModal = settings.get('boostModal');
|
||||
if (!boostModal) {
|
||||
modalReblog();
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: modalReblog }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuoteClick = () => {
|
||||
dispatch(quoteCompose(status));
|
||||
};
|
||||
|
@ -224,12 +235,20 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (features.quotePosts) {
|
||||
if (['public', 'unlisted'].includes(status.visibility)) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.quotePost),
|
||||
action: handleQuoteClick,
|
||||
icon: require('@tabler/icons/quote.svg'),
|
||||
text: intl.formatMessage(status.reblogged ? messages.unreblog : messages.reblog),
|
||||
action: handleReblogClick,
|
||||
icon: require('@tabler/icons/repeat.svg'),
|
||||
});
|
||||
|
||||
if (features.quotePosts) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.quotePost),
|
||||
action: handleQuoteClick,
|
||||
icon: require('@tabler/icons/quote.svg'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
|
|
|
@ -11,6 +11,7 @@ interface IInputContainer {
|
|||
type?: string,
|
||||
extraClass?: string,
|
||||
error?: boolean,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
export const InputContainer: React.FC<IInputContainer> = (props) => {
|
||||
|
@ -32,6 +33,7 @@ export const InputContainer: React.FC<IInputContainer> = (props) => {
|
|||
interface ILabelInputContainer {
|
||||
label?: React.ReactNode,
|
||||
hint?: React.ReactNode,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
export const LabelInputContainer: React.FC<ILabelInputContainer> = ({ label, hint, children }) => {
|
||||
|
@ -128,6 +130,7 @@ interface ISimpleForm {
|
|||
onSubmit?: React.FormEventHandler,
|
||||
acceptCharset?: string,
|
||||
style?: React.CSSProperties,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
export const SimpleForm: React.FC<ISimpleForm> = (props) => {
|
||||
|
@ -157,7 +160,11 @@ export const SimpleForm: React.FC<ISimpleForm> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const FieldsGroup: React.FC = ({ children }) => (
|
||||
interface IFieldsGroup {
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
export const FieldsGroup: React.FC<IFieldsGroup> = ({ children }) => (
|
||||
<div className='fields-group'>{children}</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
import { isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
|
||||
import type { Group } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
|
||||
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
|
||||
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
|
||||
});
|
||||
|
||||
interface IGroupHeader {
|
||||
group?: Group | false | null,
|
||||
}
|
||||
|
||||
const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className='-mt-4 -mx-4'>
|
||||
<div>
|
||||
<div className='relative h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50' />
|
||||
</div>
|
||||
|
||||
<div className='px-4 sm:px-6'>
|
||||
<HStack alignItems='bottom' space={5} className='-mt-12'>
|
||||
<div className='flex relative'>
|
||||
<div
|
||||
className='h-24 w-24 bg-gray-400 rounded-full ring-4 ring-white dark:ring-gray-800'
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onJoinGroup = () => dispatch(joinGroup(group.id));
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
message: intl.formatMessage(messages.confirmationMessage),
|
||||
confirm: intl.formatMessage(messages.confirmationConfirm),
|
||||
onConfirm: () => dispatch(leaveGroup(group.id)),
|
||||
}));
|
||||
|
||||
const onAvatarClick = () => {
|
||||
const avatar = normalizeAttachment({
|
||||
type: 'image',
|
||||
url: group.avatar,
|
||||
});
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
|
||||
};
|
||||
|
||||
const handleAvatarClick: React.MouseEventHandler = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onAvatarClick();
|
||||
}
|
||||
};
|
||||
|
||||
const onHeaderClick = () => {
|
||||
const header = normalizeAttachment({
|
||||
type: 'image',
|
||||
url: group.header,
|
||||
});
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
|
||||
};
|
||||
|
||||
const handleHeaderClick: React.MouseEventHandler = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onHeaderClick();
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
let header: React.ReactNode;
|
||||
|
||||
if (group.header) {
|
||||
header = (
|
||||
<StillImage
|
||||
src={group.header}
|
||||
alt={intl.formatMessage(messages.header)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isDefaultHeader(group.header)) {
|
||||
header = (
|
||||
<a href={group.header} onClick={handleHeaderClick} target='_blank'>
|
||||
{header}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return header;
|
||||
};
|
||||
|
||||
const makeActionButton = () => {
|
||||
if (!group.relationship || !group.relationship.member) {
|
||||
return (
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={onJoinGroup}
|
||||
>
|
||||
{group.locked ? <FormattedMessage id='group.request_join' defaultMessage='Request to join group' /> : <FormattedMessage id='group.join' defaultMessage='Join group' />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.relationship.requested) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel request' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.relationship?.role === 'admin') {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
to={`/groups/${group.id}/manage`}
|
||||
>
|
||||
<FormattedMessage id='group.manage' defaultMessage='Manage group' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.leave' defaultMessage='Leave group' />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const actionButton = makeActionButton();
|
||||
|
||||
return (
|
||||
<div className='-mt-4 -mx-4'>
|
||||
<div className='relative'>
|
||||
<div className='relative flex flex-col justify-center h-32 w-full lg:h-[200px] md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
|
||||
{renderHeader()}
|
||||
</div>
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack className='p-3 pt-12' alignItems='center' space={2}>
|
||||
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
{actionButton}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupHeader;
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchGroup, fetchGroupBlocks, groupUnblock } from 'soapbox/actions/groups';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.group_blocked_members', defaultMessage: 'Blocked members' },
|
||||
unblock: { id: 'group.group_mod_unblock', defaultMessage: 'Unblock' },
|
||||
unblocked: { id: 'group.group_mod_unblock.success', defaultMessage: 'Unblocked @{name} from group' },
|
||||
});
|
||||
|
||||
interface IBlockedMember {
|
||||
accountId: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const BlockedMember: React.FC<IBlockedMember> = ({ accountId, groupId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleUnblock = () =>
|
||||
dispatch(groupUnblock(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.unblocked, { name: account.acct }));
|
||||
});
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.unblock)}
|
||||
onClick={handleUnblock}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupBlockedMembers {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
dispatch(fetchGroupBlocks(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship || !accountIds) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.group_blocks' defaultMessage="The group hasn't blocked any users yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
||||
<ScrollableList
|
||||
scrollKey='group_blocks'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{accountIds.map((accountId) =>
|
||||
<BlockedMember key={accountId} accountId={accountId} groupId={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupBlockedMembers;
|
|
@ -0,0 +1,285 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { expandGroupMemberships, fetchGroup, fetchGroupMemberships, groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { CardHeader, CardTitle, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||
|
||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||
import type { GroupRole, List } from 'soapbox/reducers/group-memberships';
|
||||
import type { GroupRelationship } from 'soapbox/types/entities';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
adminSubheading: { id: 'group.admin_subheading', defaultMessage: 'Group administrators' },
|
||||
moderatorSubheading: { id: 'group.moderator_subheading', defaultMessage: 'Group moderators' },
|
||||
userSubheading: { id: 'group.user_subheading', defaultMessage: 'Users' },
|
||||
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
||||
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
||||
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
||||
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
||||
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
|
||||
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
||||
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
||||
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||
});
|
||||
|
||||
interface IGroupMember {
|
||||
accountId: string
|
||||
accountRole: GroupRole
|
||||
groupId: string
|
||||
relationship?: GroupRelationship
|
||||
}
|
||||
|
||||
const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId, relationship }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleKickFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickConfirm),
|
||||
onConfirm: () => dispatch(groupKick(groupId, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.kicked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(groupBlock(groupId, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
||||
if (warning) {
|
||||
return dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||
onConfirm: () => dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
return dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteToGroupAdmin = () => {
|
||||
onPromote('admin', true);
|
||||
};
|
||||
|
||||
const handlePromoteToGroupMod = () => {
|
||||
onPromote('moderator', relationship!.role === 'moderator');
|
||||
};
|
||||
|
||||
const handleDemote = () => {
|
||||
dispatch(groupDemoteAccount(groupId, account.id, 'user')).then(() =>
|
||||
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
||||
).catch(() => {});
|
||||
};
|
||||
|
||||
const makeMenu = () => {
|
||||
const menu: MenuType = [];
|
||||
|
||||
if (!relationship || !relationship.role) return menu;
|
||||
|
||||
if (['admin', 'moderator'].includes(relationship.role) && ['moderator', 'user'].includes(accountRole) && accountRole !== relationship.role) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
action: handleKickFromGroup,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
action: handleBlockFromGroup,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship.role === 'admin' && accountRole !== 'admin' && account.acct === account.username) {
|
||||
menu.push(null);
|
||||
switch (accountRole) {
|
||||
case 'moderator':
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupAdmin,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
||||
action: handleDemote,
|
||||
});
|
||||
break;
|
||||
case 'user':
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupMod,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
};
|
||||
|
||||
const menu = makeMenu();
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
{menu.length > 0 && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
theme='outlined'
|
||||
className='px-2'
|
||||
iconClassName='w-4 h-4'
|
||||
children={null}
|
||||
/>
|
||||
|
||||
<MenuList className='w-56'>
|
||||
{menu.map((menuItem, idx) => {
|
||||
if (typeof menuItem?.text === 'undefined') {
|
||||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
<HStack space={3} alignItems='center'>
|
||||
{menuItem.icon && (
|
||||
<SvgIcon src={menuItem.icon} className='h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
|
||||
)}
|
||||
|
||||
<div className='truncate'>{menuItem.text}</div>
|
||||
</HStack>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupMembers {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||
const admins = useAppSelector((state) => state.group_memberships.admin.get(groupId));
|
||||
const moderators = useAppSelector((state) => state.group_memberships.moderator.get(groupId));
|
||||
const users = useAppSelector((state) => state.group_memberships.user.get(groupId));
|
||||
|
||||
const handleLoadMore = (role: 'admin' | 'moderator' | 'user') => {
|
||||
dispatch(expandGroupMemberships(groupId, role));
|
||||
};
|
||||
|
||||
const handleLoadMoreAdmins = useCallback(debounce(() => {
|
||||
handleLoadMore('admin');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleLoadMoreModerators = useCallback(debounce(() => {
|
||||
handleLoadMore('moderator');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleLoadMoreUsers = useCallback(debounce(() => {
|
||||
handleLoadMore('user');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const renderMemberships = (memberships: List | undefined, role: GroupRole, handler: () => void) => {
|
||||
if (!memberships?.isLoading && !memberships?.items.count()) return;
|
||||
|
||||
return (
|
||||
<React.Fragment key={role}>
|
||||
<CardHeader className='mt-4'>
|
||||
<CardTitle title={intl.formatMessage(messages[`${role}Subheading`])} />
|
||||
</CardHeader>
|
||||
<ScrollableList
|
||||
scrollKey={`group_${role}s-${groupId}`}
|
||||
hasMore={!!memberships?.next}
|
||||
onLoadMore={handler}
|
||||
isLoading={memberships?.isLoading}
|
||||
showLoading={memberships?.isLoading && !memberships?.items?.count()}
|
||||
placeholderComponent={PlaceholderAccount}
|
||||
placeholderCount={3}
|
||||
itemClassName='pb-4 last:pb-0'
|
||||
>
|
||||
{memberships?.items?.map(accountId => (
|
||||
<GroupMember
|
||||
key={accountId}
|
||||
accountId={accountId}
|
||||
accountRole={role}
|
||||
groupId={groupId}
|
||||
relationship={relationship}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
|
||||
dispatch(fetchGroupMemberships(groupId, 'admin'));
|
||||
dispatch(fetchGroupMemberships(groupId, 'moderator'));
|
||||
dispatch(fetchGroupMemberships(groupId, 'user'));
|
||||
}, [groupId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderMemberships(admins, 'admin', handleLoadMoreAdmins)}
|
||||
{renderMemberships(moderators, 'moderator', handleLoadMoreModerators)}
|
||||
{renderMemberships(users, 'user', handleLoadMoreUsers)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMembers;
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { authorizeGroupMembershipRequest, fetchGroup, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
|
||||
authorize: { id: 'group.group_mod_authorize', defaultMessage: 'Accept' },
|
||||
authorized: { id: 'group.group_mod_authorize.success', defaultMessage: 'Accepted @{name} to group' },
|
||||
reject: { id: 'group.group_mod_reject', defaultMessage: 'Reject' },
|
||||
rejected: { id: 'group.group_mod_reject.success', defaultMessage: 'Rejected @{name} from group' },
|
||||
});
|
||||
|
||||
interface IMembershipRequest {
|
||||
accountId: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const MembershipRequest: React.FC<IMembershipRequest> = ({ accountId, groupId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleAuthorize = () =>
|
||||
dispatch(authorizeGroupMembershipRequest(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.authorized, { name: account.acct }));
|
||||
});
|
||||
|
||||
const handleReject = () =>
|
||||
dispatch(rejectGroupMembershipRequest(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.rejected, { name: account.acct }));
|
||||
});
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
<HStack space={2}>
|
||||
<Button
|
||||
theme='secondary'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.authorize)}
|
||||
onClick={handleAuthorize}
|
||||
/>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.reject)}
|
||||
onClick={handleReject}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupMembershipRequests {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
dispatch(fetchGroupMembershipRequests(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship || !accountIds) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
||||
<ScrollableList
|
||||
scrollKey='group_membership_requests'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{accountIds.map((accountId) =>
|
||||
<MembershipRequest key={accountId} accountId={accountId} groupId={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMembershipRequests;
|
|
@ -0,0 +1,76 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { groupCompose } from 'soapbox/actions/compose';
|
||||
import { fetchGroup } from 'soapbox/actions/groups';
|
||||
import { connectGroupStream } from 'soapbox/actions/streaming';
|
||||
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
||||
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
|
||||
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
||||
import { useAppDispatch, useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import Timeline from '../ui/components/timeline';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
interface IGroupTimeline {
|
||||
params: RouteParams,
|
||||
}
|
||||
|
||||
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||
const account = useOwnAccount();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||
|
||||
const handleLoadMore = (maxId: string) => {
|
||||
dispatch(expandGroupTimeline(groupId, { maxId }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
dispatch(expandGroupTimeline(groupId));
|
||||
|
||||
dispatch(groupCompose(`group:${groupId}`, groupId));
|
||||
|
||||
const disconnect = dispatch(connectGroupStream(groupId));
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
{!!account && relationship?.member && (
|
||||
<div className='px-2 py-4 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||
<HStack alignItems='start' space={4}>
|
||||
<Link to={`/@${account.acct}`}>
|
||||
<Avatar src={account.avatar} size={46} />
|
||||
</Link>
|
||||
|
||||
<ComposeForm
|
||||
id={`group:${groupId}`}
|
||||
shouldCondense
|
||||
autoFocus={false}
|
||||
group={groupId}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
<Timeline
|
||||
scrollKey='group_timeline'
|
||||
timelineId={`group:${groupId}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
|
||||
divideType='border'
|
||||
showGroup={false}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTimeline;
|
|
@ -0,0 +1,96 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { deleteGroup, editGroup, fetchGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { CardBody, Column, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
|
||||
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' },
|
||||
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' },
|
||||
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Blocked members' },
|
||||
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' },
|
||||
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' },
|
||||
deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' },
|
||||
});
|
||||
|
||||
interface IManageGroup {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const onEditGroup = () =>
|
||||
dispatch(editGroup(group));
|
||||
|
||||
const onDeleteGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteGroup(id)),
|
||||
}));
|
||||
|
||||
const navigateToPending = () => history.push(`/groups/${id}/manage/requests`);
|
||||
const navigateToBlocks = () => history.push(`/groups/${id}/manage/blocks`);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}>
|
||||
<CardBody className='space-y-4'>
|
||||
{group.relationship.role === 'admin' && (
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}>
|
||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} />
|
||||
<ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} />
|
||||
</List>
|
||||
{group.relationship.role === 'admin' && (
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.deleteGroup)} onClick={onDeleteGroup} />
|
||||
</List>
|
||||
)}
|
||||
</CardBody>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageGroup;
|
|
@ -0,0 +1,106 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { fetchGroups } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||
|
||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const getOrderedGroups = createSelector([
|
||||
(state: RootState) => state.groups.items,
|
||||
(state: RootState) => state.groups.isLoading,
|
||||
(state: RootState) => state.group_relationships,
|
||||
], (groups, isLoading, group_relationships) => ({
|
||||
groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||
.map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||
.filter((item) => item.relationship?.member)
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||
isLoading,
|
||||
}));
|
||||
|
||||
const Groups: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { groups, isLoading } = useAppSelector((state) => getOrderedGroups(state));
|
||||
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroups());
|
||||
}, []);
|
||||
|
||||
const createGroup = () => {
|
||||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
if (!groups) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = (
|
||||
<Stack space={6} alignItems='center' justifyContent='center' className='p-6 h-full'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className='gap-4'>
|
||||
{canCreateGroup && (
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end xl:hidden'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
</Button>
|
||||
)}
|
||||
<ScrollableList
|
||||
scrollKey='groups'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !groups.count()}
|
||||
placeholderComponent={PlaceholderGroupCard}
|
||||
placeholderCount={3}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Link key={group.id} to={`/groups/${group.id}`}>
|
||||
<GroupCard group={group as GroupEntity} />
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Groups;
|
|
@ -6,17 +6,15 @@ import Markup from 'soapbox/components/markup';
|
|||
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
|
||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import RegistrationForm from 'soapbox/features/auth-login/components/registration-form';
|
||||
import { useAppDispatch, useAppSelector, useFeatures, useInstance, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useFeatures, useInstance, useRegistrationStatus, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { capitalize } from 'soapbox/utils/strings';
|
||||
|
||||
const LandingPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
|
||||
|
||||
const { pepeEnabled, pepeOpen } = useRegistrationStatus();
|
||||
const instance = useInstance();
|
||||
const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);
|
||||
|
||||
/** Registrations are closed */
|
||||
const renderClosed = () => {
|
||||
|
|
|
@ -138,7 +138,7 @@ const buildMessage = (
|
|||
others: totalCount && totalCount > 0 ? (
|
||||
<FormattedMessage
|
||||
id='notification.others'
|
||||
defaultMessage=' + {count} {count, plural, one {other} other {others}}'
|
||||
defaultMessage=' + {count, plural, one {# other} other {# others}}'
|
||||
values={{ count: totalCount - 1 }}
|
||||
/>
|
||||
) : '',
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderGroupCard = () => {
|
||||
const groupNameLength = randomIntFromInterval(5, 25);
|
||||
const roleLength = randomIntFromInterval(5, 15);
|
||||
const privacyLength = randomIntFromInterval(5, 15);
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden animate-pulse'>
|
||||
<Stack className='bg-white dark:bg-primary-900 border border-solid border-gray-300 dark:border-primary-800 rounded-lg sm:rounded-xl'>
|
||||
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative -m-[1px] mb-0 rounded-t-lg sm:rounded-t-xl'>
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<div className='h-16 w-16 rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900' />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold'>{generateText(groupNameLength)}</Text>
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
<span>{generateText(roleLength)}</span>
|
||||
<span>{generateText(privacyLength)}</span>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderGroupCard;
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
|
||||
import { storeOpen, storePepeOpen } from 'soapbox/jest/mock-stores';
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import Header from '../header';
|
||||
|
||||
describe('<Header />', () => {
|
||||
it('successfully renders', () => {
|
||||
render(<Header />);
|
||||
expect(screen.getByTestId('public-layout-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('doesn\'t display the signup button by default', () => {
|
||||
render(<Header />);
|
||||
expect(screen.queryByText('Register')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('with registrations enabled', () => {
|
||||
it('displays the signup button', () => {
|
||||
render(<Header />, undefined, storeOpen);
|
||||
expect(screen.getByText('Register')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with registrations closed, Pepe enabled', () => {
|
||||
it('displays the signup button', () => {
|
||||
render(<Header />, undefined, storePepeOpen);
|
||||
expect(screen.getByText('Register')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -7,7 +7,7 @@ import { fetchInstance } from 'soapbox/actions/instance';
|
|||
import { openModal } from 'soapbox/actions/modals';
|
||||
import SiteLogo from 'soapbox/components/site-logo';
|
||||
import { Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useFeatures, useSoapboxConfig, useOwnAccount, useInstance, useAppDispatch } from 'soapbox/hooks';
|
||||
import { useSoapboxConfig, useOwnAccount, useAppDispatch, useRegistrationStatus } from 'soapbox/hooks';
|
||||
|
||||
import Sonar from './sonar';
|
||||
|
||||
|
@ -29,14 +29,9 @@ const Header = () => {
|
|||
|
||||
const account = useOwnAccount();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
|
||||
const { isOpen } = useRegistrationStatus();
|
||||
const { links } = soapboxConfig;
|
||||
|
||||
const features = useFeatures();
|
||||
const instance = useInstance();
|
||||
const isOpen = features.accountCreation && instance.registrations;
|
||||
const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true);
|
||||
|
||||
const [isLoading, setLoading] = React.useState(false);
|
||||
const [username, setUsername] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
|
@ -70,7 +65,7 @@ const Header = () => {
|
|||
if (mfaToken) return <Redirect to={`/login?token=${encodeURIComponent(mfaToken)}`} />;
|
||||
|
||||
return (
|
||||
<header>
|
||||
<header data-testid='public-layout-header'>
|
||||
<nav className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' aria-label='Header'>
|
||||
<div className='w-full py-6 flex items-center justify-between border-b border-indigo-500 lg:border-none'>
|
||||
<div className='flex items-center sm:justify-center relative w-36'>
|
||||
|
@ -111,7 +106,7 @@ const Header = () => {
|
|||
{intl.formatMessage(messages.login)}
|
||||
</Button>
|
||||
|
||||
{(isOpen || pepeEnabled && pepeOpen) && (
|
||||
{isOpen && (
|
||||
<Button
|
||||
to='/signup'
|
||||
theme='primary'
|
||||
|
|
|
@ -42,7 +42,7 @@ const OtpConfirmForm: React.FC = () => {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((event) => {
|
||||
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = useCallback((event) => {
|
||||
event.persist();
|
||||
|
||||
setState((prevState) => ({ ...prevState, [event.target.name]: event.target.value }));
|
||||
|
@ -75,7 +75,7 @@ const OtpConfirmForm: React.FC = () => {
|
|||
</Text>
|
||||
</Stack>
|
||||
|
||||
<QRCode value={state.qrCodeURI} />
|
||||
<QRCode className='rounded-lg' value={state.qrCodeURI} includeMargin />
|
||||
{state.confirmKey}
|
||||
|
||||
<Text weight='semibold' size='lg'>
|
||||
|
|
|
@ -27,6 +27,9 @@ const messages = defineMessages({
|
|||
other: { id: 'settings.other', defaultMessage: 'Other options' },
|
||||
mfaEnabled: { id: 'mfa.enabled', defaultMessage: 'Enabled' },
|
||||
mfaDisabled: { id: 'mfa.disabled', defaultMessage: 'Disabled' },
|
||||
backups: { id: 'column.backups', defaultMessage: 'Backups' },
|
||||
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
|
||||
exportData: { id: 'column.export_data', defaultMessage: 'Export data' },
|
||||
});
|
||||
|
||||
/** User settings page. */
|
||||
|
@ -47,6 +50,9 @@ const Settings = () => {
|
|||
const navigateToDeleteAccount = () => history.push('/settings/account');
|
||||
const navigateToMoveAccount = () => history.push('/settings/migration');
|
||||
const navigateToAliases = () => history.push('/settings/aliases');
|
||||
const navigateToBackups = () => history.push('/settings/backups');
|
||||
const navigateToImportData = () => history.push('/settings/import');
|
||||
const navigateToExportData = () => history.push('/settings/export');
|
||||
|
||||
const isMfaEnabled = mfa.getIn(['settings', 'totp']);
|
||||
|
||||
|
@ -130,6 +136,18 @@ const Settings = () => {
|
|||
|
||||
<CardBody>
|
||||
<List>
|
||||
{features.importData && (
|
||||
<ListItem label={intl.formatMessage(messages.importData)} onClick={navigateToImportData} />
|
||||
)}
|
||||
|
||||
{features.exportData && (
|
||||
<ListItem label={intl.formatMessage(messages.exportData)} onClick={navigateToExportData} />
|
||||
)}
|
||||
|
||||
{features.backups && (
|
||||
<ListItem label={intl.formatMessage(messages.backups)} onClick={navigateToBackups} />
|
||||
)}
|
||||
|
||||
{features.federating && (features.accountMoving ? (
|
||||
<ListItem label={intl.formatMessage(messages.accountMigration)} onClick={navigateToMoveAccount} />
|
||||
) : features.accountAliases && (
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue