Merge remote-tracking branch 'origin/develop' into accounts-scss

environments/review-accounts-s-gnzsor/deployments/2477
Alex Gleason 2023-01-30 12:14:51 -06:00
commit d64b8d9b16
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
291 zmienionych plików z 9889 dodań i 17068 usunięć

Wyświetl plik

@ -5,4 +5,4 @@
/tmp/**
/coverage/**
/custom/**
!.eslintrc.js
!.eslintrc.cjs

Wyświetl plik

@ -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
],
},

Wyświetl plik

@ -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

Wyświetl plik

@ -1,5 +1,8 @@
{
"*.js": "eslint --cache",
"*.cjs": "eslint --cache",
"*.mjs": "eslint --cache",
"*.ts": "eslint --cache",
"*.tsx": "eslint --cache",
"app/styles/**/*.scss": "stylelint"
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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 %>

Wyświetl plik

@ -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);

Wyświetl plik

@ -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,

Wyświetl plik

@ -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;

Wyświetl plik

@ -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))

Plik diff jest za duży Load Diff

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -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[]) => {

Wyświetl plik

@ -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 => {

Wyświetl plik

@ -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 });

Wyświetl plik

@ -156,6 +156,8 @@ const defaultSettings = ImmutableMap({
}),
}),
groups: ImmutableMap({}),
trends: ImmutableMap({
show: true,
}),

Wyświetl plik

@ -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,

Wyświetl plik

@ -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'>&middot;</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'>&middot;</Text>

Wyświetl plik

@ -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);
}
}

Wyświetl plik

@ -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 }) => {

Wyświetl plik

@ -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);

Wyświetl plik

@ -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'>

Wyświetl plik

@ -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;

Wyświetl plik

@ -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);

Wyświetl plik

@ -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. */

Wyświetl plik

@ -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. */

Wyświetl plik

@ -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 }) => {

Wyświetl plik

@ -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>

Wyświetl plik

@ -7,6 +7,7 @@ interface IPullToRefresh {
onRefresh?: () => Promise<any>;
refreshingContent?: JSX.Element | string;
pullingContent?: JSX.Element | string;
children: React.ReactNode;
}
/**

Wyświetl plik

@ -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

Wyświetl plik

@ -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')}

Wyświetl plik

@ -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) ? (

Wyświetl plik

@ -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,

Wyświetl plik

@ -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();

Wyświetl plik

@ -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}
/>
));
};

Wyświetl plik

@ -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 (

Wyświetl plik

@ -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}
/>

Wyświetl plik

@ -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'>
&ldquo;<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />&rdquo;
</Text>
</div>
)}
</div>
{status.spoiler_text && (
<div className='py-4 italic'>
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
&ldquo;<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />&rdquo;
</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>

Wyświetl plik

@ -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>
);

Wyświetl plik

@ -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);
}

Wyświetl plik

@ -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. */

Wyświetl plik

@ -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. */

Wyświetl plik

@ -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>

Wyświetl plik

@ -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. */

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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}

Wyświetl plik

@ -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>

Wyświetl plik

@ -21,6 +21,7 @@ interface IAnimatedInterface {
onChange(index: number): void,
/** Default tab index. */
defaultIndex: number
children: React.ReactNode
}
/** Tabs with a sliding active state. */

Wyświetl plik

@ -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) => {

Wyświetl plik

@ -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. */

Wyświetl plik

@ -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;

Wyświetl plik

@ -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>
);

Wyświetl plik

@ -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();

Wyświetl plik

@ -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(() => ({

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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 });

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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 [];

Wyświetl plik

@ -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'

Wyświetl plik

@ -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);
}, []);

Wyświetl plik

@ -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>
);

Wyświetl plik

@ -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'>

Wyświetl plik

@ -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>
);
};

Wyświetl plik

@ -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 = () => {

Wyświetl plik

@ -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

Wyświetl plik

@ -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 = () => {

Wyświetl plik

@ -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;

Wyświetl plik

@ -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'

Wyświetl plik

@ -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 = () => {

Wyświetl plik

@ -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 => {

Wyświetl plik

@ -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>
);

Wyświetl plik

@ -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();
}
}, []);

Wyświetl plik

@ -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();
}
}), []);

Wyświetl plik

@ -40,6 +40,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCanc
timestamp={status.created_at}
showProfileHoverCard={false}
withLinkToProfile={false}
hideActions={hideActions}
/>
<Text

Wyświetl plik

@ -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;

Wyświetl plik

@ -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>

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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} />

Wyświetl plik

@ -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}
/>

Wyświetl plik

@ -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 }));

Wyświetl plik

@ -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 }));

Wyświetl plik

@ -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,

Wyświetl plik

@ -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);

Wyświetl plik

@ -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>
);

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;

Wyświetl plik

@ -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 = () => {

Wyświetl plik

@ -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 }}
/>
) : '',

Wyświetl plik

@ -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;

Wyświetl plik

@ -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();
});
});
});

Wyświetl plik

@ -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'

Wyświetl plik

@ -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'>

Wyświetl plik

@ -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