Merge remote-tracking branch 'origin/develop' into chat-composer
This commit is contained in:
commit
382bc6732d
|
@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- 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.
|
||||
- Posts: increased font size of focused status in threads.
|
||||
|
||||
### Fixed
|
||||
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.
|
||||
|
|
|
@ -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';
|
||||
|
@ -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,
|
||||
|
|
|
@ -15,7 +15,7 @@ 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 { getScopes } from 'soapbox/utils/scopes';
|
||||
import { getInstanceScopes } from 'soapbox/utils/scopes';
|
||||
|
||||
import { baseClient } from '../api';
|
||||
|
||||
|
@ -38,7 +38,7 @@ const fetchExternalInstance = (baseURL?: string) => {
|
|||
};
|
||||
|
||||
const createExternalApp = (instance: Instance, baseURL?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
// Mitra: skip creating the auth app
|
||||
if (getQuirks(instance).noApps) return new Promise(f => f({}));
|
||||
|
||||
|
@ -46,15 +46,15 @@ const createExternalApp = (instance: Instance, baseURL?: string) =>
|
|||
client_name: sourceCode.displayName,
|
||||
redirect_uris: `${window.location.origin}/login/external`,
|
||||
website: sourceCode.homepage,
|
||||
scopes: getScopes(getState()),
|
||||
scopes: getInstanceScopes(instance),
|
||||
};
|
||||
|
||||
return dispatch(createApp(params, baseURL));
|
||||
};
|
||||
|
||||
const externalAuthorize = (instance: Instance, baseURL: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const scopes = getScopes(getState());
|
||||
(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>;
|
||||
|
@ -88,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: getScopes(getState()),
|
||||
scope: getInstanceScopes(instance),
|
||||
};
|
||||
|
||||
return dispatch(obtainOAuthToken(params, baseURL))
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -5,42 +5,44 @@ import type { APIEntity } from 'soapbox/types/entities';
|
|||
|
||||
const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
|
||||
const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
|
||||
const GROUP_IMPORT = 'GROUP_IMPORT';
|
||||
const GROUPS_IMPORT = 'GROUPS_IMPORT';
|
||||
const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||
const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP';
|
||||
|
||||
export function importAccount(account: APIEntity) {
|
||||
return { type: ACCOUNT_IMPORT, account };
|
||||
}
|
||||
const importAccount = (account: APIEntity) =>
|
||||
({ type: ACCOUNT_IMPORT, account });
|
||||
|
||||
export function importAccounts(accounts: APIEntity[]) {
|
||||
return { type: ACCOUNTS_IMPORT, accounts };
|
||||
}
|
||||
const importAccounts = (accounts: APIEntity[]) =>
|
||||
({ type: ACCOUNTS_IMPORT, accounts });
|
||||
|
||||
export function importStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importGroup = (group: APIEntity) =>
|
||||
({ type: GROUP_IMPORT, group });
|
||||
|
||||
const importGroups = (groups: APIEntity[]) =>
|
||||
({ type: GROUPS_IMPORT, groups });
|
||||
|
||||
const importStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
|
||||
};
|
||||
}
|
||||
|
||||
export function importStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
|
||||
};
|
||||
}
|
||||
|
||||
export function importPolls(polls: APIEntity[]) {
|
||||
return { type: POLLS_IMPORT, polls };
|
||||
}
|
||||
const importPolls = (polls: APIEntity[]) =>
|
||||
({ type: POLLS_IMPORT, polls });
|
||||
|
||||
export function importFetchedAccount(account: APIEntity) {
|
||||
return importFetchedAccounts([account]);
|
||||
}
|
||||
const importFetchedAccount = (account: APIEntity) =>
|
||||
importFetchedAccounts([account]);
|
||||
|
||||
export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) {
|
||||
const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => {
|
||||
const { should_refetch } = args;
|
||||
const normalAccounts: APIEntity[] = [];
|
||||
|
||||
|
@ -61,10 +63,27 @@ export function importFetchedAccounts(accounts: APIEntity[], args = { should_ref
|
|||
accounts.forEach(processAccount);
|
||||
|
||||
return importAccounts(normalAccounts);
|
||||
}
|
||||
};
|
||||
|
||||
export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const importFetchedGroup = (group: APIEntity) =>
|
||||
importFetchedGroups([group]);
|
||||
|
||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||
const normalGroups: APIEntity[] = [];
|
||||
|
||||
const processGroup = (group: APIEntity) => {
|
||||
if (!group.id) return;
|
||||
|
||||
normalGroups.push(group);
|
||||
};
|
||||
|
||||
groups.forEach(processGroup);
|
||||
|
||||
return importGroups(normalGroups);
|
||||
};
|
||||
|
||||
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
// Skip broken statuses
|
||||
if (isBroken(status)) return;
|
||||
|
||||
|
@ -96,10 +115,13 @@ export function importFetchedStatus(status: APIEntity, idempotencyKey?: string)
|
|||
dispatch(importFetchedPoll(status.poll));
|
||||
}
|
||||
|
||||
if (status.group?.id) {
|
||||
dispatch(importFetchedGroup(status.group));
|
||||
}
|
||||
|
||||
dispatch(importFetchedAccount(status.account));
|
||||
dispatch(importStatus(status, idempotencyKey));
|
||||
};
|
||||
}
|
||||
|
||||
// Sometimes Pleroma can return an empty account,
|
||||
// or a repost can appear of a deleted account. Skip these statuses.
|
||||
|
@ -117,8 +139,8 @@ const isBroken = (status: APIEntity) => {
|
|||
}
|
||||
};
|
||||
|
||||
export function importFetchedStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importFetchedStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const accounts: APIEntity[] = [];
|
||||
const normalStatuses: APIEntity[] = [];
|
||||
const polls: APIEntity[] = [];
|
||||
|
@ -146,6 +168,10 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
|||
if (status.poll?.id) {
|
||||
polls.push(status.poll);
|
||||
}
|
||||
|
||||
if (status.group?.id) {
|
||||
dispatch(importFetchedGroup(status.group));
|
||||
}
|
||||
}
|
||||
|
||||
statuses.forEach(processStatus);
|
||||
|
@ -154,23 +180,37 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
|||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
};
|
||||
}
|
||||
|
||||
export function importFetchedPoll(poll: APIEntity) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const importFetchedPoll = (poll: APIEntity) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
dispatch(importPolls([poll]));
|
||||
};
|
||||
}
|
||||
|
||||
export function importErrorWhileFetchingAccountByUsername(username: string) {
|
||||
return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username };
|
||||
}
|
||||
const importErrorWhileFetchingAccountByUsername = (username: string) =>
|
||||
({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username });
|
||||
|
||||
export {
|
||||
ACCOUNT_IMPORT,
|
||||
ACCOUNTS_IMPORT,
|
||||
GROUP_IMPORT,
|
||||
GROUPS_IMPORT,
|
||||
STATUS_IMPORT,
|
||||
STATUSES_IMPORT,
|
||||
POLLS_IMPORT,
|
||||
ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
|
||||
importAccount,
|
||||
importAccounts,
|
||||
importGroup,
|
||||
importGroups,
|
||||
importStatus,
|
||||
importStatuses,
|
||||
importPolls,
|
||||
importFetchedAccount,
|
||||
importFetchedAccounts,
|
||||
importFetchedGroup,
|
||||
importFetchedGroups,
|
||||
importFetchedStatus,
|
||||
importFetchedStatuses,
|
||||
importFetchedPoll,
|
||||
importErrorWhileFetchingAccountByUsername,
|
||||
};
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -156,6 +156,8 @@ const defaultSettings = ImmutableMap({
|
|||
}),
|
||||
}),
|
||||
|
||||
groups: ImmutableMap({}),
|
||||
|
||||
trends: ImmutableMap({
|
||||
show: true,
|
||||
}),
|
||||
|
|
|
@ -219,6 +219,9 @@ const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, don
|
|||
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
||||
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
|
||||
|
||||
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
|
||||
expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
|
||||
|
||||
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
|
||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||
max_id: maxId,
|
||||
|
@ -309,6 +312,7 @@ export {
|
|||
expandAccountMediaTimeline,
|
||||
expandListTimeline,
|
||||
expandGroupTimeline,
|
||||
expandGroupMediaTimeline,
|
||||
expandHashtagTimeline,
|
||||
expandTimelineRequest,
|
||||
expandTimelineSuccess,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||
|
@ -13,6 +13,7 @@ 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 {
|
||||
|
@ -87,6 +88,7 @@ export interface IAccount {
|
|||
withLinkToProfile?: boolean,
|
||||
withRelationship?: boolean,
|
||||
showEdit?: boolean,
|
||||
approvalStatus?: StatusApprovalStatus,
|
||||
emoji?: string,
|
||||
note?: string,
|
||||
}
|
||||
|
@ -111,6 +113,7 @@ const Account = ({
|
|||
withLinkToProfile = true,
|
||||
withRelationship = true,
|
||||
showEdit = false,
|
||||
approvalStatus,
|
||||
emoji,
|
||||
note,
|
||||
}: IAccount) => {
|
||||
|
@ -259,6 +262,18 @@ const Account = ({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
||||
<Text tag='span' theme='muted' size='sm'>
|
||||
{approvalStatus === 'pending'
|
||||
? <FormattedMessage id='status.approval.pending' defaultMessage='Pending approval' />
|
||||
: <FormattedMessage id='status.approval.rejected' defaultMessage='Rejected' />}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEdit ? (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
|
|
@ -5,8 +5,6 @@ import { useSoapboxConfig } from 'soapbox/hooks';
|
|||
|
||||
import { getAcct } from '../utils/accounts';
|
||||
|
||||
import Icon from './icon';
|
||||
import RelativeTimestamp from './relative-timestamp';
|
||||
import { HStack, Text } from './ui';
|
||||
import VerificationBadge from './verification-badge';
|
||||
|
||||
|
@ -15,20 +13,12 @@ import type { Account } from 'soapbox/types/entities';
|
|||
interface IDisplayName {
|
||||
account: Account
|
||||
withSuffix?: boolean
|
||||
withDate?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true, withDate = false }) => {
|
||||
const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true }) => {
|
||||
const { displayFqn = false } = useSoapboxConfig();
|
||||
const { created_at: createdAt, verified } = account;
|
||||
|
||||
const joinedAt = createdAt ? (
|
||||
<div className='account__joined-at'>
|
||||
<Icon src={require('@tabler/icons/clock.svg')} />
|
||||
<RelativeTimestamp timestamp={createdAt} />
|
||||
</div>
|
||||
) : null;
|
||||
const { verified } = account;
|
||||
|
||||
const displayName = (
|
||||
<HStack space={1} alignItems='center' grow>
|
||||
|
@ -40,7 +30,6 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
|
|||
/>
|
||||
|
||||
{verified && <VerificationBadge />}
|
||||
{withDate && joinedAt}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge';
|
|||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
|
||||
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
|
||||
});
|
||||
|
@ -56,7 +56,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
|
|||
{floatingAction && action}
|
||||
</div>
|
||||
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
|
||||
</div>
|
||||
<Stack className='p-2.5' space={2}>
|
||||
<HStack space={2} alignItems='center' justifyContent='between'>
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Avatar, HStack, Icon, Stack, Text } from './ui';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
});
|
||||
|
||||
interface IGroupCard {
|
||||
group: GroupEntity
|
||||
}
|
||||
|
||||
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<Stack className='bg-white dark:bg-primary-900 border border-solid border-gray-300 dark:border-primary-800 rounded-lg sm:rounded-xl'>
|
||||
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative -m-[1px] mb-0 rounded-t-lg sm:rounded-t-xl'>
|
||||
{group.header && <img className='h-full w-full object-cover rounded-t-lg sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupCard;
|
|
@ -33,6 +33,7 @@ const messages = defineMessages({
|
|||
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' },
|
||||
|
@ -207,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'
|
||||
|
|
|
@ -135,6 +135,14 @@ const SidebarNavigation = () => {
|
|||
|
||||
{renderMessagesLink()}
|
||||
|
||||
{features.groups && (
|
||||
<SidebarNavigationLink
|
||||
to='/groups'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarNavigationLink
|
||||
to={`/@${account.acct}`}
|
||||
icon={require('@tabler/icons/user.svg')}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
|||
import { launchChat } from 'soapbox/actions/chats';
|
||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { editEvent } from 'soapbox/actions/events';
|
||||
import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups';
|
||||
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
|
@ -24,7 +25,7 @@ import copy from 'soapbox/utils/copy';
|
|||
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||
import type { Account, Status } from 'soapbox/types/entities';
|
||||
import type { Account, Group, Status } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -81,6 +82,18 @@ const messages = defineMessages({
|
|||
redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||
replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' },
|
||||
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
|
||||
groupModKick: { id: 'status.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModBlock: { id: 'status.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||
deleteFromGroupHeading: { id: 'confirmations.delete_from_group.heading', defaultMessage: 'Delete from group' },
|
||||
deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' },
|
||||
kickFromGroupHeading: { id: 'confirmations.kick_from_group.heading', defaultMessage: 'Kick group member' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kickFromGroupConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Block group member' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||
blockFromGroupConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
});
|
||||
|
||||
interface IStatusActionBar {
|
||||
|
@ -103,6 +116,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const dispatch = useAppDispatch();
|
||||
|
||||
const me = useAppSelector(state => state.me);
|
||||
const groupRelationship = useAppSelector(state => status.group ? state.group_relationships.get((status.group as Group).id) : null);
|
||||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
@ -285,6 +299,39 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
|
||||
};
|
||||
|
||||
const handleDeleteFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKickFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.kickFromGroupHeading),
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickFromGroupConfirm),
|
||||
onConfirm: () => dispatch(groupKick((status.group as Group).id, account.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.blockFromGroupHeading),
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockFromGroupConfirm),
|
||||
onConfirm: () => dispatch(groupBlock((status.group as Group).id, account.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const _makeMenu = (publicStatus: boolean) => {
|
||||
const mutingConversation = status.muted;
|
||||
const ownAccount = status.getIn(['account', 'id']) === me;
|
||||
|
@ -425,6 +472,26 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
});
|
||||
}
|
||||
|
||||
if (status.group && groupRelationship?.role && ['admin', 'moderator'].includes(groupRelationship.role)) {
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModDelete),
|
||||
action: handleDeleteFromGroup,
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
});
|
||||
// TODO: figure out when an account is not in the group anymore
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.get('username') }),
|
||||
action: handleKickFromGroup,
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.get('username') }),
|
||||
action: handleBlockFromGroup,
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isStaff) {
|
||||
menu.push(null);
|
||||
|
||||
|
@ -491,6 +558,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const menu = _makeMenu(publicStatus);
|
||||
let reblogIcon = require('@tabler/icons/repeat.svg');
|
||||
let replyTitle;
|
||||
let replyDisabled = false;
|
||||
|
||||
if (status.visibility === 'direct') {
|
||||
reblogIcon = require('@tabler/icons/mail.svg');
|
||||
|
@ -498,6 +566,11 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
reblogIcon = require('@tabler/icons/lock.svg');
|
||||
}
|
||||
|
||||
if ((status.group as Group)?.membership_required && !groupRelationship?.member) {
|
||||
replyDisabled = true;
|
||||
replyTitle = intl.formatMessage(messages.replies_disabled_group);
|
||||
}
|
||||
|
||||
const reblogMenu = [{
|
||||
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog),
|
||||
action: handleReblogClick,
|
||||
|
@ -543,6 +616,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
onClick={handleReplyClick}
|
||||
count={replyCount}
|
||||
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
||||
disabled={replyDisabled}
|
||||
/>
|
||||
|
||||
{(features.quotePosts && me) ? (
|
||||
|
|
|
@ -85,6 +85,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
|||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',
|
||||
{
|
||||
'text-black dark:text-white': active && emoji,
|
||||
'hover:text-gray-600 dark:hover:text-white': !filteredProps.disabled,
|
||||
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && !emoji && color === COLORS.accent,
|
||||
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && !emoji && color === COLORS.success,
|
||||
'space-x-1': !text,
|
||||
|
|
|
@ -13,6 +13,7 @@ import { isRtl } from '../rtl';
|
|||
import Markup from './markup';
|
||||
import Poll from './polls/poll';
|
||||
|
||||
import type { Sizes } from 'soapbox/components/ui/text/text';
|
||||
import type { Status, Mention } from 'soapbox/types/entities';
|
||||
|
||||
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
|
||||
|
@ -35,10 +36,17 @@ interface IStatusContent {
|
|||
onClick?: () => void,
|
||||
collapsable?: boolean,
|
||||
translatable?: boolean,
|
||||
textSize?: Sizes,
|
||||
}
|
||||
|
||||
/** Renders the text content of a status */
|
||||
const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable = false, translatable }) => {
|
||||
const StatusContent: React.FC<IStatusContent> = ({
|
||||
status,
|
||||
onClick,
|
||||
collapsable = false,
|
||||
translatable,
|
||||
textSize = 'md',
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
@ -162,6 +170,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
|
|||
direction={direction}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.language || undefined}
|
||||
size={textSize}
|
||||
/>,
|
||||
];
|
||||
|
||||
|
@ -187,6 +196,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
|
|||
direction={direction}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.language || undefined}
|
||||
size={textSize}
|
||||
/>,
|
||||
];
|
||||
|
||||
|
|
|
@ -46,6 +46,8 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
|||
divideType?: 'space' | 'border',
|
||||
/** Whether to display ads. */
|
||||
showAds?: boolean,
|
||||
/** Whether to show group information. */
|
||||
showGroup?: boolean,
|
||||
}
|
||||
|
||||
/** Feed of statuses, built atop ScrollableList. */
|
||||
|
@ -59,6 +61,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
isLoading,
|
||||
isPartial,
|
||||
showAds = false,
|
||||
showGroup = true,
|
||||
...other
|
||||
}) => {
|
||||
const { data: ads } = useAds();
|
||||
|
@ -135,6 +138,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showGroup={showGroup}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -167,6 +171,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showGroup={showGroup}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
|
|
@ -50,7 +50,14 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
|
|||
// The typical case with a reply-to and a list of mentions.
|
||||
const accounts = to.slice(0, 2).map(account => {
|
||||
const link = (
|
||||
<Link to={`/@${account.acct}`} className='reply-mentions__account' onClick={(e) => e.stopPropagation()}>@{account.username}</Link>
|
||||
<Link
|
||||
key={account.id}
|
||||
to={`/@${account.acct}`}
|
||||
className='reply-mentions__account'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{account.username}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (hoverable) {
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -348,6 +371,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
showEdit={!!actualStatus.edited_at}
|
||||
showProfileHoverCard={hoverable}
|
||||
withLinkToProfile={hoverable}
|
||||
approvalStatus={actualStatus.approval_status}
|
||||
avatarSize={avatarSize}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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 { Stack, Button, Text } from './ui';
|
||||
|
||||
import type { Account, Status } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -50,20 +50,30 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
|
|||
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 }} />
|
||||
|
||||
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue hover:underline' onClick={handleTranslate}>
|
||||
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
|
||||
</button>
|
||||
<Stack space={3} alignItems='start'>
|
||||
<Button
|
||||
theme='muted'
|
||||
text={<FormattedMessage id='status.show_original' defaultMessage='Show original' />}
|
||||
icon={require('@tabler/icons/language.svg')}
|
||||
onClick={handleTranslate}
|
||||
/>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
<FormattedMessage id='status.translate' defaultMessage='Translate' />
|
||||
</button>
|
||||
<div>
|
||||
<Button
|
||||
theme='muted'
|
||||
text={<FormattedMessage id='status.translate' defaultMessage='Translate' />}
|
||||
icon={require('@tabler/icons/language.svg')}
|
||||
onClick={handleTranslate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ const themes = {
|
|||
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600',
|
||||
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
|
||||
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
|
||||
muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
|
|
|
@ -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. */
|
||||
|
@ -66,6 +68,7 @@ const Modal: React.FC<IModal> = ({
|
|||
confirmationDisabled,
|
||||
confirmationText,
|
||||
confirmationTheme,
|
||||
confirmationFullWidth,
|
||||
onClose,
|
||||
secondaryAction,
|
||||
secondaryDisabled = false,
|
||||
|
@ -118,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'
|
||||
|
@ -129,7 +132,7 @@ const Modal: React.FC<IModal> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<HStack space={2}>
|
||||
<HStack space={2} className={classNames({ 'flex-grow': confirmationFullWidth })}>
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
|
@ -145,6 +148,7 @@ const Modal: React.FC<IModal> = ({
|
|||
onClick={confirmationAction}
|
||||
disabled={confirmationDisabled}
|
||||
ref={buttonRef}
|
||||
block={confirmationFullWidth}
|
||||
>
|
||||
{confirmationText}
|
||||
</Button>
|
||||
|
|
|
@ -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;
|
|
@ -11,11 +11,10 @@ import type { Attachment } from 'soapbox/types/entities';
|
|||
|
||||
interface IMediaItem {
|
||||
attachment: Attachment,
|
||||
displayWidth: number,
|
||||
onOpenMedia: (attachment: Attachment) => void,
|
||||
}
|
||||
|
||||
const MediaItem: React.FC<IMediaItem> = ({ attachment, displayWidth, onOpenMedia }) => {
|
||||
const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia }) => {
|
||||
const settings = useSettings();
|
||||
const autoPlayGif = settings.get('autoPlayGif');
|
||||
const displayMedia = settings.get('displayMedia');
|
||||
|
@ -53,8 +52,6 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, displayWidth, onOpenMedia
|
|||
}
|
||||
};
|
||||
|
||||
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
|
||||
const height = width;
|
||||
const status = attachment.get('status');
|
||||
const title = status.get('spoiler_text') || attachment.get('description');
|
||||
|
||||
|
@ -117,15 +114,15 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, displayWidth, onOpenMedia
|
|||
|
||||
if (!visible) {
|
||||
icon = (
|
||||
<span className='account-gallery__item__icons'>
|
||||
<span className='media-gallery__item__icons'>
|
||||
<Icon src={require('@tabler/icons/eye-off.svg')} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-gallery__item' style={{ width, height }}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={handleClick} title={title}>
|
||||
<div className='col-span-1'>
|
||||
<a className='media-gallery__item-thumbnail aspect-square' href={status.get('url')} target='_blank' onClick={handleClick} title={title}>
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
className={classNames('media-gallery__preview', {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
|
@ -65,7 +65,6 @@ const AccountGallery = () => {
|
|||
const isLoading = useAppSelector((state) => state.timelines.get(`account:${accountId}:media`)?.isLoading);
|
||||
const hasMore = useAppSelector((state) => state.timelines.get(`account:${accountId}:media`)?.hasMore);
|
||||
|
||||
const [width, setWidth] = useState(323);
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
|
@ -96,12 +95,6 @@ const AccountGallery = () => {
|
|||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (node.current) {
|
||||
setWidth(node.current.offsetWidth);
|
||||
}
|
||||
}, [node.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && accountId !== -1) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
|
@ -143,14 +136,13 @@ const AccountGallery = () => {
|
|||
|
||||
return (
|
||||
<Column label={`@${accountUsername}`} transparent withHeader={false}>
|
||||
<div role='feed' className='account-gallery__container' ref={node}>
|
||||
<div role='feed' className='grid grid-cols-2 gap-2 sm:grid-cols-3' ref={node}>
|
||||
{attachments.map((attachment, index) => attachment === null ? (
|
||||
<LoadMoreMedia key={'more:' + attachments.get(index + 1)?.id} maxId={index > 0 ? (attachments.get(index - 1)?.id || null) : null} onLoadMore={handleLoadMore} />
|
||||
) : (
|
||||
<MediaItem
|
||||
key={`${attachment.status.id}+${attachment.id}`}
|
||||
attachment={attachment}
|
||||
displayWidth={width}
|
||||
onOpenMedia={handleOpenMedia}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -13,7 +13,7 @@ interface IMovedNote {
|
|||
}
|
||||
|
||||
const MovedNote: React.FC<IMovedNote> = ({ from, to }) => (
|
||||
<div className='account__moved-note'>
|
||||
<div className='p-4'>
|
||||
<HStack className='mb-2' alignItems='center' space={1.5}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/briefcase.svg')}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -62,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();
|
||||
|
@ -77,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;
|
||||
|
@ -227,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} />}
|
||||
|
@ -270,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
|
||||
|
@ -291,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}
|
||||
|
@ -349,8 +350,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
<Button type='submit' theme='primary' text={publishText} icon={publishIcon} disabled={disabledButton} />
|
||||
</HStack>
|
||||
{/* <HStack alignItems='center' space={4}>
|
||||
</HStack> */}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
import { isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
|
||||
import type { Group } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
|
||||
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
|
||||
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
|
||||
});
|
||||
|
||||
interface IGroupHeader {
|
||||
group?: Group | false | null,
|
||||
}
|
||||
|
||||
const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className='-mt-4 -mx-4'>
|
||||
<div>
|
||||
<div className='relative h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50' />
|
||||
</div>
|
||||
|
||||
<div className='px-4 sm:px-6'>
|
||||
<HStack alignItems='bottom' space={5} className='-mt-12'>
|
||||
<div className='flex relative'>
|
||||
<div
|
||||
className='h-24 w-24 bg-gray-400 rounded-full ring-4 ring-white dark:ring-gray-800'
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onJoinGroup = () => dispatch(joinGroup(group.id));
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
message: intl.formatMessage(messages.confirmationMessage),
|
||||
confirm: intl.formatMessage(messages.confirmationConfirm),
|
||||
onConfirm: () => dispatch(leaveGroup(group.id)),
|
||||
}));
|
||||
|
||||
const onAvatarClick = () => {
|
||||
const avatar = normalizeAttachment({
|
||||
type: 'image',
|
||||
url: group.avatar,
|
||||
});
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
|
||||
};
|
||||
|
||||
const handleAvatarClick: React.MouseEventHandler = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onAvatarClick();
|
||||
}
|
||||
};
|
||||
|
||||
const onHeaderClick = () => {
|
||||
const header = normalizeAttachment({
|
||||
type: 'image',
|
||||
url: group.header,
|
||||
});
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
|
||||
};
|
||||
|
||||
const handleHeaderClick: React.MouseEventHandler = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onHeaderClick();
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
let header: React.ReactNode;
|
||||
|
||||
if (group.header) {
|
||||
header = (
|
||||
<StillImage
|
||||
src={group.header}
|
||||
alt={intl.formatMessage(messages.header)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isDefaultHeader(group.header)) {
|
||||
header = (
|
||||
<a href={group.header} onClick={handleHeaderClick} target='_blank'>
|
||||
{header}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return header;
|
||||
};
|
||||
|
||||
const makeActionButton = () => {
|
||||
if (!group.relationship || !group.relationship.member) {
|
||||
return (
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={onJoinGroup}
|
||||
>
|
||||
{group.locked ? <FormattedMessage id='group.request_join' defaultMessage='Request to join group' /> : <FormattedMessage id='group.join' defaultMessage='Join group' />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.relationship.requested) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel request' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.relationship?.role === 'admin') {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
to={`/groups/${group.id}/manage`}
|
||||
>
|
||||
<FormattedMessage id='group.manage' defaultMessage='Manage group' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.leave' defaultMessage='Leave group' />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const actionButton = makeActionButton();
|
||||
|
||||
return (
|
||||
<div className='-mt-4 -mx-4'>
|
||||
<div className='relative'>
|
||||
<div className='relative flex flex-col justify-center h-32 w-full lg:h-[200px] md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
|
||||
{renderHeader()}
|
||||
</div>
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack className='p-3 pt-12' alignItems='center' space={2}>
|
||||
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
{actionButton}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupHeader;
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchGroup, fetchGroupBlocks, groupUnblock } from 'soapbox/actions/groups';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.group_blocked_members', defaultMessage: 'Blocked members' },
|
||||
unblock: { id: 'group.group_mod_unblock', defaultMessage: 'Unblock' },
|
||||
unblocked: { id: 'group.group_mod_unblock.success', defaultMessage: 'Unblocked @{name} from group' },
|
||||
});
|
||||
|
||||
interface IBlockedMember {
|
||||
accountId: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const BlockedMember: React.FC<IBlockedMember> = ({ accountId, groupId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleUnblock = () =>
|
||||
dispatch(groupUnblock(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.unblocked, { name: account.acct }));
|
||||
});
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.unblock)}
|
||||
onClick={handleUnblock}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupBlockedMembers {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
dispatch(fetchGroupBlocks(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship || !accountIds) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.group_blocks' defaultMessage="The group hasn't blocked any users yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
||||
<ScrollableList
|
||||
scrollKey='group_blocks'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{accountIds.map((accountId) =>
|
||||
<BlockedMember key={accountId} accountId={accountId} groupId={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupBlockedMembers;
|
|
@ -0,0 +1,285 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { expandGroupMemberships, fetchGroup, fetchGroupMemberships, groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { CardHeader, CardTitle, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||
|
||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||
import type { GroupRole, List } from 'soapbox/reducers/group-memberships';
|
||||
import type { GroupRelationship } from 'soapbox/types/entities';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
adminSubheading: { id: 'group.admin_subheading', defaultMessage: 'Group administrators' },
|
||||
moderatorSubheading: { id: 'group.moderator_subheading', defaultMessage: 'Group moderators' },
|
||||
userSubheading: { id: 'group.user_subheading', defaultMessage: 'Users' },
|
||||
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
||||
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
||||
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
||||
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
||||
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
|
||||
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
||||
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
||||
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||
});
|
||||
|
||||
interface IGroupMember {
|
||||
accountId: string
|
||||
accountRole: GroupRole
|
||||
groupId: string
|
||||
relationship?: GroupRelationship
|
||||
}
|
||||
|
||||
const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId, relationship }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleKickFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickConfirm),
|
||||
onConfirm: () => dispatch(groupKick(groupId, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.kicked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(groupBlock(groupId, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
||||
if (warning) {
|
||||
return dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||
onConfirm: () => dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
return dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteToGroupAdmin = () => {
|
||||
onPromote('admin', true);
|
||||
};
|
||||
|
||||
const handlePromoteToGroupMod = () => {
|
||||
onPromote('moderator', relationship!.role === 'moderator');
|
||||
};
|
||||
|
||||
const handleDemote = () => {
|
||||
dispatch(groupDemoteAccount(groupId, account.id, 'user')).then(() =>
|
||||
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
||||
).catch(() => {});
|
||||
};
|
||||
|
||||
const makeMenu = () => {
|
||||
const menu: MenuType = [];
|
||||
|
||||
if (!relationship || !relationship.role) return menu;
|
||||
|
||||
if (['admin', 'moderator'].includes(relationship.role) && ['moderator', 'user'].includes(accountRole) && accountRole !== relationship.role) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
action: handleKickFromGroup,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
action: handleBlockFromGroup,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship.role === 'admin' && accountRole !== 'admin' && account.acct === account.username) {
|
||||
menu.push(null);
|
||||
switch (accountRole) {
|
||||
case 'moderator':
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupAdmin,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
||||
action: handleDemote,
|
||||
});
|
||||
break;
|
||||
case 'user':
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupMod,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
};
|
||||
|
||||
const menu = makeMenu();
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
{menu.length > 0 && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
theme='outlined'
|
||||
className='px-2'
|
||||
iconClassName='w-4 h-4'
|
||||
children={null}
|
||||
/>
|
||||
|
||||
<MenuList className='w-56'>
|
||||
{menu.map((menuItem, idx) => {
|
||||
if (typeof menuItem?.text === 'undefined') {
|
||||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
<HStack space={3} alignItems='center'>
|
||||
{menuItem.icon && (
|
||||
<SvgIcon src={menuItem.icon} className='h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
|
||||
)}
|
||||
|
||||
<div className='truncate'>{menuItem.text}</div>
|
||||
</HStack>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupMembers {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||
const admins = useAppSelector((state) => state.group_memberships.admin.get(groupId));
|
||||
const moderators = useAppSelector((state) => state.group_memberships.moderator.get(groupId));
|
||||
const users = useAppSelector((state) => state.group_memberships.user.get(groupId));
|
||||
|
||||
const handleLoadMore = (role: 'admin' | 'moderator' | 'user') => {
|
||||
dispatch(expandGroupMemberships(groupId, role));
|
||||
};
|
||||
|
||||
const handleLoadMoreAdmins = useCallback(debounce(() => {
|
||||
handleLoadMore('admin');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleLoadMoreModerators = useCallback(debounce(() => {
|
||||
handleLoadMore('moderator');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleLoadMoreUsers = useCallback(debounce(() => {
|
||||
handleLoadMore('user');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const renderMemberships = (memberships: List | undefined, role: GroupRole, handler: () => void) => {
|
||||
if (!memberships?.isLoading && !memberships?.items.count()) return;
|
||||
|
||||
return (
|
||||
<React.Fragment key={role}>
|
||||
<CardHeader className='mt-4'>
|
||||
<CardTitle title={intl.formatMessage(messages[`${role}Subheading`])} />
|
||||
</CardHeader>
|
||||
<ScrollableList
|
||||
scrollKey={`group_${role}s-${groupId}`}
|
||||
hasMore={!!memberships?.next}
|
||||
onLoadMore={handler}
|
||||
isLoading={memberships?.isLoading}
|
||||
showLoading={memberships?.isLoading && !memberships?.items?.count()}
|
||||
placeholderComponent={PlaceholderAccount}
|
||||
placeholderCount={3}
|
||||
itemClassName='pb-4 last:pb-0'
|
||||
>
|
||||
{memberships?.items?.map(accountId => (
|
||||
<GroupMember
|
||||
key={accountId}
|
||||
accountId={accountId}
|
||||
accountRole={role}
|
||||
groupId={groupId}
|
||||
relationship={relationship}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
|
||||
dispatch(fetchGroupMemberships(groupId, 'admin'));
|
||||
dispatch(fetchGroupMemberships(groupId, 'moderator'));
|
||||
dispatch(fetchGroupMemberships(groupId, 'user'));
|
||||
}, [groupId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderMemberships(admins, 'admin', handleLoadMoreAdmins)}
|
||||
{renderMemberships(moderators, 'moderator', handleLoadMoreModerators)}
|
||||
{renderMemberships(users, 'user', handleLoadMoreUsers)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMembers;
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { authorizeGroupMembershipRequest, fetchGroup, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
|
||||
authorize: { id: 'group.group_mod_authorize', defaultMessage: 'Accept' },
|
||||
authorized: { id: 'group.group_mod_authorize.success', defaultMessage: 'Accepted @{name} to group' },
|
||||
reject: { id: 'group.group_mod_reject', defaultMessage: 'Reject' },
|
||||
rejected: { id: 'group.group_mod_reject.success', defaultMessage: 'Rejected @{name} from group' },
|
||||
});
|
||||
|
||||
interface IMembershipRequest {
|
||||
accountId: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const MembershipRequest: React.FC<IMembershipRequest> = ({ accountId, groupId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleAuthorize = () =>
|
||||
dispatch(authorizeGroupMembershipRequest(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.authorized, { name: account.acct }));
|
||||
});
|
||||
|
||||
const handleReject = () =>
|
||||
dispatch(rejectGroupMembershipRequest(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.rejected, { name: account.acct }));
|
||||
});
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
<HStack space={2}>
|
||||
<Button
|
||||
theme='secondary'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.authorize)}
|
||||
onClick={handleAuthorize}
|
||||
/>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.reject)}
|
||||
onClick={handleReject}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupMembershipRequests {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
dispatch(fetchGroupMembershipRequests(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship || !accountIds) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
||||
<ScrollableList
|
||||
scrollKey='group_membership_requests'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{accountIds.map((accountId) =>
|
||||
<MembershipRequest key={accountId} accountId={accountId} groupId={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMembershipRequests;
|
|
@ -0,0 +1,76 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { groupCompose } from 'soapbox/actions/compose';
|
||||
import { fetchGroup } from 'soapbox/actions/groups';
|
||||
import { connectGroupStream } from 'soapbox/actions/streaming';
|
||||
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
||||
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
|
||||
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
||||
import { useAppDispatch, useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import Timeline from '../ui/components/timeline';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
interface IGroupTimeline {
|
||||
params: RouteParams,
|
||||
}
|
||||
|
||||
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||
const account = useOwnAccount();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||
|
||||
const handleLoadMore = (maxId: string) => {
|
||||
dispatch(expandGroupTimeline(groupId, { maxId }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
dispatch(expandGroupTimeline(groupId));
|
||||
|
||||
dispatch(groupCompose(`group:${groupId}`, groupId));
|
||||
|
||||
const disconnect = dispatch(connectGroupStream(groupId));
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
{!!account && relationship?.member && (
|
||||
<div className='px-2 py-4 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||
<HStack alignItems='start' space={4}>
|
||||
<Link to={`/@${account.acct}`}>
|
||||
<Avatar src={account.avatar} size={46} />
|
||||
</Link>
|
||||
|
||||
<ComposeForm
|
||||
id={`group:${groupId}`}
|
||||
shouldCondense
|
||||
autoFocus={false}
|
||||
group={groupId}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
<Timeline
|
||||
scrollKey='group_timeline'
|
||||
timelineId={`group:${groupId}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
|
||||
divideType='border'
|
||||
showGroup={false}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTimeline;
|
|
@ -0,0 +1,96 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { deleteGroup, editGroup, fetchGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { CardBody, Column, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
|
||||
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' },
|
||||
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' },
|
||||
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Blocked members' },
|
||||
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' },
|
||||
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' },
|
||||
deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' },
|
||||
});
|
||||
|
||||
interface IManageGroup {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const onEditGroup = () =>
|
||||
dispatch(editGroup(group));
|
||||
|
||||
const onDeleteGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteGroup(id)),
|
||||
}));
|
||||
|
||||
const navigateToPending = () => history.push(`/groups/${id}/manage/requests`);
|
||||
const navigateToBlocks = () => history.push(`/groups/${id}/manage/blocks`);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}>
|
||||
<CardBody className='space-y-4'>
|
||||
{group.relationship.role === 'admin' && (
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}>
|
||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} />
|
||||
<ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} />
|
||||
</List>
|
||||
{group.relationship.role === 'admin' && (
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.deleteGroup)} onClick={onDeleteGroup} />
|
||||
</List>
|
||||
)}
|
||||
</CardBody>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageGroup;
|
|
@ -0,0 +1,106 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { fetchGroups } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||
|
||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const getOrderedGroups = createSelector([
|
||||
(state: RootState) => state.groups.items,
|
||||
(state: RootState) => state.groups.isLoading,
|
||||
(state: RootState) => state.group_relationships,
|
||||
], (groups, isLoading, group_relationships) => ({
|
||||
groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||
.map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||
.filter((item) => item.relationship?.member)
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||
isLoading,
|
||||
}));
|
||||
|
||||
const Groups: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { groups, isLoading } = useAppSelector((state) => getOrderedGroups(state));
|
||||
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroups());
|
||||
}, []);
|
||||
|
||||
const createGroup = () => {
|
||||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
if (!groups) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = (
|
||||
<Stack space={6} alignItems='center' justifyContent='center' className='p-6 h-full'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className='gap-4'>
|
||||
{canCreateGroup && (
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end xl:hidden'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
</Button>
|
||||
)}
|
||||
<ScrollableList
|
||||
scrollKey='groups'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !groups.count()}
|
||||
placeholderComponent={PlaceholderGroupCard}
|
||||
placeholderCount={3}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Link key={group.id} to={`/groups/${group.id}`}>
|
||||
<GroupCard group={group as GroupEntity} />
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Groups;
|
|
@ -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;
|
|
@ -90,6 +90,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
timestamp={actualStatus.created_at}
|
||||
avatarSize={42}
|
||||
hideActions
|
||||
approvalStatus={actualStatus.approval_status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -109,7 +110,11 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
)}
|
||||
|
||||
<Stack space={4}>
|
||||
<StatusContent status={actualStatus} translatable />
|
||||
<StatusContent
|
||||
status={actualStatus}
|
||||
textSize='lg'
|
||||
translatable
|
||||
/>
|
||||
|
||||
<TranslateButton status={actualStatus} />
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const ComposeButton = () => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const onOpenCompose = () => dispatch(openModal('COMPOSE'));
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { expandGroupMediaTimeline } from 'soapbox/actions/timelines';
|
||||
import { Spinner, Text, Widget } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { getGroupGallery } from 'soapbox/selectors';
|
||||
|
||||
import MediaItem from '../../account-gallery/components/media-item';
|
||||
|
||||
import type { Attachment, Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupMediaPanel {
|
||||
group?: Group,
|
||||
}
|
||||
|
||||
const GroupMediaPanel: React.FC<IGroupMediaPanel> = ({ group }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const attachments: ImmutableList<Attachment> = useAppSelector((state) => group ? getGroupGallery(state, group?.id) : ImmutableList());
|
||||
|
||||
const handleOpenMedia = (attachment: Attachment): void => {
|
||||
if (attachment.type === 'video') {
|
||||
dispatch(openModal('VIDEO', { media: attachment, status: attachment.status }));
|
||||
} else {
|
||||
const media = attachment.getIn(['status', 'media_attachments']) as ImmutableList<Attachment>;
|
||||
const index = media.findIndex(x => x.id === attachment.id);
|
||||
|
||||
dispatch(openModal('MEDIA', { media, index, status: attachment.status, account: attachment.account }));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
if (group) {
|
||||
dispatch(expandGroupMediaTimeline(group.id))
|
||||
// @ts-ignore
|
||||
.then(() => setLoading(false))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [group?.id]);
|
||||
|
||||
const renderAttachments = () => {
|
||||
const nineAttachments = attachments.slice(0, 9);
|
||||
|
||||
if (!nineAttachments.isEmpty()) {
|
||||
return (
|
||||
<div className='grid grid-cols-3 gap-1'>
|
||||
{nineAttachments.map((attachment, _index) => (
|
||||
<MediaItem
|
||||
key={`${attachment.getIn(['status', 'id'])}+${attachment.id}`}
|
||||
attachment={attachment}
|
||||
onOpenMedia={handleOpenMedia}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Text size='sm' theme='muted'>
|
||||
<FormattedMessage id='media_panel.empty_message' defaultMessage='No media found.' />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget title={<FormattedMessage id='media_panel.title' defaultMessage='Media' />}>
|
||||
{group && (
|
||||
<div className='w-full'>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
renderAttachments()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMediaPanel;
|
|
@ -36,6 +36,7 @@ import {
|
|||
EventMapModal,
|
||||
EventParticipantsModal,
|
||||
PolicyModal,
|
||||
ManageGroupModal,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
|
||||
import BundleContainer from '../containers/bundle-container';
|
||||
|
@ -79,6 +80,7 @@ const MODAL_COMPONENTS = {
|
|||
'EVENT_MAP': EventMapModal,
|
||||
'EVENT_PARTICIPANTS': EventParticipantsModal,
|
||||
'POLICY': PolicyModal,
|
||||
'MANAGE_GROUP': ManageGroupModal,
|
||||
};
|
||||
|
||||
export type ModalType = keyof typeof MODAL_COMPONENTS | null;
|
||||
|
|
|
@ -214,7 +214,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
|||
<FormGroup
|
||||
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />}
|
||||
>
|
||||
<div className='flex items-center justify-center bg-gray-200 dark:bg-gray-900/50 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
|
||||
<div className='flex items-center justify-center bg-primary-100 dark:bg-gray-800 rounded-lg text-primary-500 dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
|
||||
{banner ? (
|
||||
<>
|
||||
<img className='h-full w-full object-cover' src={banner.url} alt='' />
|
||||
|
@ -223,7 +223,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
|||
) : (
|
||||
<UploadButton disabled={isUploading} onSelectFile={handleFiles} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
|
|
|
@ -1,23 +1,18 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { HStack, Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
upload: { id: 'compose_event.upload_banner', defaultMessage: 'Upload event banner' },
|
||||
});
|
||||
|
||||
interface IUploadButton {
|
||||
disabled?: boolean,
|
||||
onSelectFile: (files: FileList) => void,
|
||||
}
|
||||
|
||||
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const fileElement = useRef<HTMLInputElement>(null);
|
||||
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/'));
|
||||
|
||||
|
@ -32,27 +27,25 @@ const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton
|
||||
<HStack className='h-full w-full text-primary-500 dark:text-accent-blue cursor-pointer' space={3} alignItems='center' justifyContent='center' element='label'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/photo-plus.svg')}
|
||||
className='h-8 w-8 text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
|
||||
title={intl.formatMessage(messages.upload)}
|
||||
disabled={disabled}
|
||||
className='h-7 w-7'
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
<label>
|
||||
<span className='sr-only'>{intl.formatMessage(messages.upload)}</span>
|
||||
<input
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||
</Text>
|
||||
<input
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { submitGroupEditor } from 'soapbox/actions/groups';
|
||||
import { Modal, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import DetailsStep from './steps/details-step';
|
||||
import PrivacyStep from './steps/privacy-step';
|
||||
|
||||
const messages = defineMessages({
|
||||
next: { id: 'manage_group.next', defaultMessage: 'Next' },
|
||||
create: { id: 'manage_group.create', defaultMessage: 'Create' },
|
||||
update: { id: 'manage_group.update', defaultMessage: 'Update' },
|
||||
});
|
||||
|
||||
enum Steps {
|
||||
ONE = 'ONE',
|
||||
TWO = 'TWO',
|
||||
}
|
||||
|
||||
const manageGroupSteps = {
|
||||
ONE: PrivacyStep,
|
||||
TWO: DetailsStep,
|
||||
};
|
||||
|
||||
interface IManageGroupModal {
|
||||
onClose: (type?: string) => void,
|
||||
}
|
||||
|
||||
const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = useAppSelector((state) => state.group_editor.groupId);
|
||||
|
||||
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
|
||||
|
||||
const onClickClose = () => {
|
||||
onClose('manage_group');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitGroupEditor(true));
|
||||
};
|
||||
|
||||
const confirmationText = useMemo(() => {
|
||||
switch (currentStep) {
|
||||
case Steps.TWO:
|
||||
return intl.formatMessage(id ? messages.update : messages.create);
|
||||
default:
|
||||
return intl.formatMessage(messages.next);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const handleNextStep = () => {
|
||||
switch (currentStep) {
|
||||
case Steps.ONE:
|
||||
setCurrentStep(Steps.TWO);
|
||||
break;
|
||||
case Steps.TWO:
|
||||
handleSubmit();
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const StepToRender = manageGroupSteps[currentStep];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={id
|
||||
? <FormattedMessage id='navigation_bar.edit_group' defaultMessage='Edit Group' />
|
||||
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
|
||||
confirmationAction={handleNextStep}
|
||||
confirmationText={confirmationText}
|
||||
confirmationDisabled={isSubmitting}
|
||||
confirmationFullWidth
|
||||
onClose={onClickClose}
|
||||
>
|
||||
<Stack space={2}>
|
||||
<StepToRender />
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageGroupModal;
|
|
@ -0,0 +1,180 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import {
|
||||
changeGroupEditorTitle,
|
||||
changeGroupEditorDescription,
|
||||
changeGroupEditorMedia,
|
||||
} from 'soapbox/actions/groups';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Avatar, Form, FormGroup, HStack, Input, Text, Textarea } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
import resizeImage from 'soapbox/utils/resize-image';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
interface IMediaInput {
|
||||
src: string | null,
|
||||
accept: string,
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
|
||||
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
|
||||
});
|
||||
|
||||
const HeaderPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
|
||||
return (
|
||||
<label
|
||||
className='h-24 sm:h-36 w-full text-primary-500 dark:text-accent-blue bg-primary-100 dark:bg-gray-800 cursor-pointer relative rounded-lg sm:shadow dark:sm:shadow-inset overflow-hidden'
|
||||
>
|
||||
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
|
||||
<HStack
|
||||
className={classNames('h-full w-full top-0 absolute transition-opacity', {
|
||||
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
|
||||
})}
|
||||
space={3}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/photo-plus.svg')}
|
||||
className='h-7 w-7'
|
||||
/>
|
||||
|
||||
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||
</Text>
|
||||
|
||||
<input
|
||||
name='header'
|
||||
type='file'
|
||||
accept={accept}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</HStack>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
|
||||
return (
|
||||
<label className='h-[72px] w-[72px] bg-primary-500 cursor-pointer rounded-full absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 ring-2 ring-white dark:ring-primary-900'>
|
||||
{src && <Avatar src={src} size={72} />}
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
|
||||
className={classNames('h-full w-full left-0 top-0 rounded-full absolute transition-opacity', {
|
||||
'opacity-0 hover:opacity-90 bg-primary-500': src,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/camera-plus.svg')}
|
||||
className='h-7 w-7 text-white'
|
||||
/>
|
||||
</HStack>
|
||||
<span className='sr-only'>Upload avatar</span>
|
||||
<input
|
||||
name='avatar'
|
||||
type='file'
|
||||
accept={accept}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailsStep = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const groupId = useAppSelector((state) => state.group_editor.groupId);
|
||||
const isUploading = useAppSelector((state) => state.group_editor.isUploading);
|
||||
const name = useAppSelector((state) => state.group_editor.displayName);
|
||||
const description = useAppSelector((state) => state.group_editor.note);
|
||||
|
||||
const [avatarSrc, setAvatarSrc] = useState<string | null>(null);
|
||||
const [headerSrc, setHeaderSrc] = useState<string | null>(null);
|
||||
|
||||
const attachmentTypes = useAppSelector(
|
||||
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
|
||||
)?.filter(type => type.startsWith('image/')).toArray().join(',');
|
||||
|
||||
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
dispatch(changeGroupEditorTitle(target.value));
|
||||
};
|
||||
|
||||
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
|
||||
dispatch(changeGroupEditorDescription(target.value));
|
||||
};
|
||||
|
||||
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
const rawFile = e.target.files?.item(0);
|
||||
|
||||
if (!rawFile) return;
|
||||
|
||||
if (e.target.name === 'avatar') {
|
||||
resizeImage(rawFile, 400 * 400).then(file => {
|
||||
dispatch(changeGroupEditorMedia('avatar', file));
|
||||
setAvatarSrc(URL.createObjectURL(file));
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
resizeImage(rawFile, 1920 * 1080).then(file => {
|
||||
dispatch(changeGroupEditorMedia('header', file));
|
||||
setHeaderSrc(URL.createObjectURL(file));
|
||||
}).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
|
||||
dispatch((_, getState) => {
|
||||
const group = getState().groups.items.get(groupId);
|
||||
if (!group) return;
|
||||
if (group.avatar && !isDefaultAvatar(group.avatar)) setAvatarSrc(group.avatar);
|
||||
if (group.header && !isDefaultHeader(group.header)) setHeaderSrc(group.header);
|
||||
});
|
||||
}, [groupId]);
|
||||
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div className='flex mb-12 relative'>
|
||||
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
|
||||
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
|
||||
</div>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name (required)' />}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
|
||||
value={name}
|
||||
onChange={onChangeName}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='manage_group.fields.description_label' defaultMessage='Description' />}
|
||||
>
|
||||
<Textarea
|
||||
autoComplete='off'
|
||||
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
|
||||
value={description}
|
||||
onChange={onChangeDescription}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailsStep;
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { changeGroupEditorPrivacy } from 'soapbox/actions/groups';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Form, FormGroup, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const PrivacyStep = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const locked = useAppSelector((state) => state.group_editor.locked);
|
||||
|
||||
const onChangePrivacy = (value: boolean) => {
|
||||
dispatch(changeGroupEditorPrivacy(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack className='max-w-sm mx-auto' space={2}>
|
||||
<Text size='3xl' weight='bold' align='center'>
|
||||
<FormattedMessage id='manage_group.get_started' defaultMessage="Let's get started!" />
|
||||
</Text>
|
||||
<Text size='lg' theme='muted' align='center'>
|
||||
<FormattedMessage id='manage_group.tagline' defaultMessage='Groups connect you with others based on shared interests.' />
|
||||
</Text>
|
||||
</Stack>
|
||||
<Form>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='manage_group.privacy.label' defaultMessage='Privacy settings' />}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='manage_group.privacy.public.label' defaultMessage='Public' />}
|
||||
hint={<FormattedMessage id='manage_group.privacy.public.hint' defaultMessage='Discoverable. Anyone can join.' />}
|
||||
onSelect={() => onChangePrivacy(false)}
|
||||
isSelected={!locked}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
label={<FormattedMessage id='manage_group.privacy.private.label' defaultMessage='Private (Owner approval required)' />}
|
||||
hint={<FormattedMessage id='manage_group.privacy.private.hint' defaultMessage='Discoverable. Users can join after their request is approved.' />}
|
||||
onSelect={() => onChangePrivacy(true)}
|
||||
isSelected={locked}
|
||||
/>
|
||||
</List>
|
||||
</FormGroup>
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage id='manage_group.privacy.hint' defaultMessage='These settings cannot be changed later.' />
|
||||
</Text>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyStep;
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||
|
||||
const NewGroupPanel = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||
|
||||
const createGroup = () => {
|
||||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
if (!canCreateGroup) return null;
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
<Stack>
|
||||
<Text size='lg' weight='bold'>
|
||||
<FormattedMessage id='new_group_panel.title' defaultMessage='Create New Group' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage id='new_group_panel.subtitle' defaultMessage="Can't find what you're looking for? Start your own private or public group." />
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewGroupPanel;
|
|
@ -51,12 +51,11 @@ const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
|
|||
|
||||
if (!nineAttachments.isEmpty()) {
|
||||
return (
|
||||
<div className='flex flex-wrap'>
|
||||
<div className='grid grid-cols-3 gap-1'>
|
||||
{nineAttachments.map((attachment, _index) => (
|
||||
<MediaItem
|
||||
key={`${attachment.getIn(['status', 'id'])}+${attachment.id}`}
|
||||
attachment={attachment}
|
||||
displayWidth={255}
|
||||
onOpenMedia={handleOpenMedia}
|
||||
/>
|
||||
))}
|
||||
|
@ -74,7 +73,7 @@ const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
|
|||
return (
|
||||
<Widget title={<FormattedMessage id='media_panel.title' defaultMessage='Media' />}>
|
||||
{account && (
|
||||
<div className='w-full py-2'>
|
||||
<div className='w-full'>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
|
|
|
@ -39,7 +39,7 @@ const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
|
|||
action={
|
||||
<Link className='text-right' to='/suggestions'>
|
||||
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
|
||||
<FormattedMessage id='feed_suggestions.view_all' defaultMessage='View all' />
|
||||
<FormattedMessage id='feed_suggestions.view_all' defaultMessage='View all' />
|
||||
</Text>
|
||||
</Link>
|
||||
}
|
||||
|
|
|
@ -29,6 +29,8 @@ import AdminPage from 'soapbox/pages/admin-page';
|
|||
import ChatsPage from 'soapbox/pages/chats-page';
|
||||
import DefaultPage from 'soapbox/pages/default-page';
|
||||
import EventPage from 'soapbox/pages/event-page';
|
||||
import GroupPage from 'soapbox/pages/group-page';
|
||||
import GroupsPage from 'soapbox/pages/groups-page';
|
||||
import HomePage from 'soapbox/pages/home-page';
|
||||
import ProfilePage from 'soapbox/pages/profile-page';
|
||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||
|
@ -112,6 +114,12 @@ import {
|
|||
EventInformation,
|
||||
EventDiscussion,
|
||||
Events,
|
||||
Groups,
|
||||
GroupMembers,
|
||||
GroupTimeline,
|
||||
ManageGroup,
|
||||
GroupBlockedMembers,
|
||||
GroupMembershipRequests,
|
||||
} from './util/async-components';
|
||||
import { WrappedRoute } from './util/react-router-helpers';
|
||||
|
||||
|
@ -272,6 +280,13 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
{features.events && <WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />}
|
||||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||
|
||||
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage/blocks' exact page={DefaultPage} component={GroupBlockedMembers} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={DefaultPage} component={GroupMembershipRequests} content={children} />}
|
||||
|
||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
||||
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
||||
|
|
|
@ -541,3 +541,39 @@ export function EventParticipantsModal() {
|
|||
export function Events() {
|
||||
return import(/* webpackChunkName: "features/events" */'../../events');
|
||||
}
|
||||
|
||||
export function Groups() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups');
|
||||
}
|
||||
|
||||
export function GroupMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||
}
|
||||
|
||||
export function GroupTimeline() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
||||
}
|
||||
|
||||
export function ManageGroup() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/manage-group');
|
||||
}
|
||||
|
||||
export function GroupBlockedMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-blocked-members');
|
||||
}
|
||||
|
||||
export function GroupMembershipRequests() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-membership-requests');
|
||||
}
|
||||
|
||||
export function ManageGroupModal() {
|
||||
return import(/* webpackChunkName: "features/manage_group_modal" */'../components/modals/manage-group-modal/manage-group-modal');
|
||||
}
|
||||
|
||||
export function NewGroupPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
|
||||
}
|
||||
|
||||
export function GroupMediaPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
|
||||
}
|
||||
|
|
|
@ -7,11 +7,11 @@
|
|||
"account.birthday": "Geburtsdatum {date}",
|
||||
"account.birthday_today": "Hat heute Geburtstag!",
|
||||
"account.block": "Blockieren",
|
||||
"account.block_domain": "Alles von {domain} verstecken",
|
||||
"account.block_domain": "Domäne verstecken",
|
||||
"account.blocked": "Blockiert",
|
||||
"account.chat": "Mit @{name} chatten",
|
||||
"account.deactivated": "Deaktiviert",
|
||||
"account.direct": "Direktnachricht an @{name}",
|
||||
"account.direct": "Direktnachricht",
|
||||
"account.domain_blocked": "Domain versteckt",
|
||||
"account.edit_profile": "Profil bearbeiten",
|
||||
"account.endorse": "Auf Profil hervorheben",
|
||||
|
@ -26,21 +26,21 @@
|
|||
"account.follows.empty": "Dieses Profil folgt noch niemandem.",
|
||||
"account.follows_you": "Folgt dir",
|
||||
"account.header.alt": "Profilkopf",
|
||||
"account.hide_reblogs": "Geteilte Beiträge von @{name} verbergen",
|
||||
"account.hide_reblogs": "Verberge geteilte Beiträge",
|
||||
"account.last_status": "Zuletzt aktiv",
|
||||
"account.link_verified_on": "Besitz dieses Links wurde geprüft am {date}",
|
||||
"account.locked_info": "Der Privatsphärenstatus dieses Kontos wurde auf privat gesetzt. Die Person bestimmt manuell, wer ihr folgen darf.",
|
||||
"account.login": "Anmelden",
|
||||
"account.media": "Medien",
|
||||
"account.member_since": "Mitglied seit {date}",
|
||||
"account.mention": "erwähnen",
|
||||
"account.mention": "Erwähnen",
|
||||
"account.mute": "Stummschalten",
|
||||
"account.muted": "Stummgeschaltet",
|
||||
"account.never_active": "Niemals",
|
||||
"account.posts": "Beiträge",
|
||||
"account.posts_with_replies": "Beiträge und Antworten",
|
||||
"account.profile": "Profil",
|
||||
"account.profile_external": "Auf {domain} aufrufen",
|
||||
"account.profile_external": "Auf Heimdomäne",
|
||||
"account.register": "Registrieren",
|
||||
"account.remote_follow": "Von anderer Instanz folgen",
|
||||
"account.remove_from_followers": "Follower entfernen",
|
||||
|
@ -50,7 +50,7 @@
|
|||
"account.search": "Suche von @{name}",
|
||||
"account.search_self": "Durchsuchen deine Beiträge",
|
||||
"account.share": "Profil von @{name} teilen",
|
||||
"account.show_reblogs": "Von @{name} geteilte Beiträge anzeigen",
|
||||
"account.show_reblogs": "Geteilte Beiträge von @{name} anzeigen",
|
||||
"account.subscribe": "Benachrichtigungen von @{name} abonnieren",
|
||||
"account.subscribe.failure": "Beim Versuch dieses Konto zu abonnieren ist ein Fehler aufgetreten.",
|
||||
"account.subscribe.success": "Sie haben diesen Account abonniert.",
|
||||
|
@ -319,10 +319,14 @@
|
|||
"column.follow_requests": "Follower-Anfragen",
|
||||
"column.followers": "Follower",
|
||||
"column.following": "Gefolgte",
|
||||
"column.group_blocked_members": "Blockierte Mitglieder",
|
||||
"column.group_pending_requests": "Offene Anfragen",
|
||||
"column.groups": "Gruppen",
|
||||
"column.home": "Startseite",
|
||||
"column.import_data": "Daten importieren",
|
||||
"column.info": "Serverinformation",
|
||||
"column.lists": "Listen",
|
||||
"column.manage_group": "Gruppe verwalten",
|
||||
"column.mentions": "Erwähnungen",
|
||||
"column.mfa": "Multi-Faktor-Authentifizierung",
|
||||
"column.mfa_cancel": "Abbrechen",
|
||||
|
@ -431,6 +435,8 @@
|
|||
"confirmations.block.confirm": "Blockieren",
|
||||
"confirmations.block.heading": "Blockiere @{name}",
|
||||
"confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
|
||||
"confirmations.block_from_group.confirm": "Blockieren",
|
||||
"confirmations.block_from_group.heading": "Gruppenmitglied blockieren",
|
||||
"confirmations.cancel.confirm": "Wegwerfen",
|
||||
"confirmations.cancel.heading": "Beitrag verwerfen",
|
||||
"confirmations.cancel.message": "Bist du sicher, dass du die Erstellung dieses Beitrags abbrechen willst?",
|
||||
|
@ -439,7 +445,7 @@
|
|||
"confirmations.cancel_editing.message": "Bist du sicher, dass du die Bearbeitung dieses Beitrags abbrechen willst? Alle Änderungen gehen dann verloren.",
|
||||
"confirmations.cancel_event_editing.heading": "Veranstaltungsbearbeitung abbrechen",
|
||||
"confirmations.cancel_event_editing.message": "Bist du sicher, dass du die Bearbeitung dieser Veranstaltung abbrechen willst? Alle Änderungen gehen dann verloren.",
|
||||
"confirmations.delete.confirm": "Entwurf löschen",
|
||||
"confirmations.delete.confirm": "Löschen",
|
||||
"confirmations.delete.heading": "Beitrag löschen",
|
||||
"confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?",
|
||||
"confirmations.delete_event.confirm": "Löschen",
|
||||
|
@ -639,7 +645,10 @@
|
|||
"event.manage": "Verwalten",
|
||||
"event.organized_by": "Organisiert von {name}",
|
||||
"event.participants": "{count} {rawCount, plural, eine {person} andere {people}} gehen",
|
||||
"event.quote": "Veranstaltung zitieren",
|
||||
"event.reblog": "Veranstaltung teilen",
|
||||
"event.show_on_map": "Auf Karte anzeigen",
|
||||
"event.unreblog": "Veranstaltung unteilen",
|
||||
"event.website": "Externe links",
|
||||
"event_map.navigate": "Navigieren",
|
||||
"events.create_event": "Veranstaltung erstellen",
|
||||
|
@ -856,7 +865,7 @@
|
|||
"navigation_bar.account_migration": "Konto umziehen",
|
||||
"navigation_bar.blocks": "Blockierte Profile",
|
||||
"navigation_bar.compose": "Neuen Beitrag verfassen",
|
||||
"navigation_bar.compose_direct": "Direktnachrichten",
|
||||
"navigation_bar.compose_direct": "Direktnachricht",
|
||||
"navigation_bar.compose_edit": "Beitrag bearbeiten",
|
||||
"navigation_bar.compose_event": "Veranstaltung verwalten",
|
||||
"navigation_bar.compose_quote": "Beitrag zitieren",
|
||||
|
@ -972,7 +981,7 @@
|
|||
"preferences.fields.privacy_label": "Beitragsprivatsphäre",
|
||||
"preferences.fields.reduce_motion_label": "Bewegung in Animationen reduzieren",
|
||||
"preferences.fields.system_font_label": "Standardschriftart des Systems verwenden",
|
||||
"preferences.fields.theme": "Theme",
|
||||
"preferences.fields.theme": "Thema",
|
||||
"preferences.fields.underline_links_label": "Links in Beiträgen immer unterstreichen",
|
||||
"preferences.fields.unfollow_modal_label": "Bestätigungsdialog anzeigen, bevor jemandem entfolgt wird",
|
||||
"preferences.hints.demetricator": "Verringere die Angst vor sozialen Medien, indem du alle Nummern auf der Seite ausblendest.",
|
||||
|
@ -994,7 +1003,7 @@
|
|||
"profile_dropdown.add_account": "Bestehendes Konto hinzufügen",
|
||||
"profile_dropdown.logout": "Aus @{acct} abmelden",
|
||||
"profile_dropdown.switch_account": "Konten wechseln",
|
||||
"profile_dropdown.theme": "Theme",
|
||||
"profile_dropdown.theme": "Thema",
|
||||
"profile_fields_panel.title": "Profilfelder",
|
||||
"reactions.all": "Alle",
|
||||
"regeneration_indicator.label": "Laden…",
|
||||
|
@ -1179,14 +1188,14 @@
|
|||
"soapbox_config.fields.home_footer_fields_label": "Fußzeilen der Startseite",
|
||||
"soapbox_config.fields.logo_label": "Logo",
|
||||
"soapbox_config.fields.promo_panel_fields_label": "Werbepanelartikel",
|
||||
"soapbox_config.fields.theme_label": "Standard-Theme",
|
||||
"soapbox_config.fields.theme_label": "Standardthema",
|
||||
"soapbox_config.greentext_label": "Greentext-Unterstützung aktivieren",
|
||||
"soapbox_config.headings.advanced": "Fortgeschritten",
|
||||
"soapbox_config.headings.cryptocurrency": "Kryptowährung",
|
||||
"soapbox_config.headings.events": "Veranstaltungen",
|
||||
"soapbox_config.headings.navigation": "Navigation",
|
||||
"soapbox_config.headings.options": "Options",
|
||||
"soapbox_config.headings.theme": "Theme",
|
||||
"soapbox_config.headings.theme": "Thema",
|
||||
"soapbox_config.hints.crypto_addresses": "Füge Kryptowährungsadressen hinzu, damit die Nutzer deiner Seite an dich spenden können. Die Reihenfolge ist wichtig, und du musst die Tickerwerte in Kleinbuchstaben schreiben.",
|
||||
"soapbox_config.hints.home_footer_fields": "Du kannst benutzerdefinierte Links in der Fußzeile deiner statischen Seiten anzeigen lassen",
|
||||
"soapbox_config.hints.logo": "SVG. Maximal 2 MB. Die Darstellung erfolgt mit 50px Höhe, das Seitenverhältnis wird beibehalten",
|
||||
|
@ -1222,10 +1231,10 @@
|
|||
"status.copy": "Kopiere Link zum Beitrag",
|
||||
"status.delete": "Löschen",
|
||||
"status.detailed_status": "Detaillierte Ansicht der Unterhaltung",
|
||||
"status.direct": "Direktnachricht an @{name}",
|
||||
"status.direct": "Direktnachricht",
|
||||
"status.edit": "Bearbeiten",
|
||||
"status.embed": "Einbetten",
|
||||
"status.external": "View post on {domain}",
|
||||
"status.external": "Öffne auf Heimatdomäne",
|
||||
"status.favourite": "Favorisieren",
|
||||
"status.filtered": "Gefiltert",
|
||||
"status.interactions.favourites": "{count, plural, one {Favorit} other {Favoriten}}",
|
||||
|
|
|
@ -319,10 +319,14 @@
|
|||
"column.follow_requests": "Follow requests",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
"column.group_blocked_members": "Blocked members",
|
||||
"column.group_pending_requests": "Pending requests",
|
||||
"column.groups": "Groups",
|
||||
"column.home": "Home",
|
||||
"column.import_data": "Import data",
|
||||
"column.info": "Server information",
|
||||
"column.lists": "Lists",
|
||||
"column.manage_group": "Manage group",
|
||||
"column.mentions": "Mentions",
|
||||
"column.mfa": "Multi-Factor Authentication",
|
||||
"column.mfa_cancel": "Cancel",
|
||||
|
@ -431,6 +435,9 @@
|
|||
"confirmations.block.confirm": "Block",
|
||||
"confirmations.block.heading": "Block @{name}",
|
||||
"confirmations.block.message": "Are you sure you want to block {name}?",
|
||||
"confirmations.block_from_group.confirm": "Block",
|
||||
"confirmations.block_from_group.heading": "Block group member",
|
||||
"confirmations.block_from_group.message": "Are you sure you want to block @{name} from interacting with this group?",
|
||||
"confirmations.cancel.confirm": "Discard",
|
||||
"confirmations.cancel.heading": "Discard post",
|
||||
"confirmations.cancel.message": "Are you sure you want to cancel creating this post?",
|
||||
|
@ -445,17 +452,30 @@
|
|||
"confirmations.delete_event.confirm": "Delete",
|
||||
"confirmations.delete_event.heading": "Delete event",
|
||||
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
|
||||
"confirmations.delete_from_group.heading": "Delete from group",
|
||||
"confirmations.delete_from_group.message": "Are you sure you want to delete @{name}'s post?",
|
||||
"confirmations.delete_group.confirm": "Delete",
|
||||
"confirmations.delete_group.heading": "Delete group",
|
||||
"confirmations.delete_group.message": "Are you sure you want to delete this group? This is a permanent action that cannot be undone.",
|
||||
"confirmations.delete_list.confirm": "Delete",
|
||||
"confirmations.delete_list.heading": "Delete list",
|
||||
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.heading": "Block {domain}",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.",
|
||||
"confirmations.kick_from_group.confirm": "Kick",
|
||||
"confirmations.kick_from_group.heading": "Kick group member",
|
||||
"confirmations.kick_from_group.message": "Are you sure you want to kick @{name} from this group?",
|
||||
"confirmations.leave_event.confirm": "Leave event",
|
||||
"confirmations.leave_event.message": "If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?",
|
||||
"confirmations.leave_group.confirm": "Leave",
|
||||
"confirmations.leave_group.heading": "Leave group",
|
||||
"confirmations.leave_group.message": "You are about to leave the group. Do you want to continue?",
|
||||
"confirmations.mute.confirm": "Mute",
|
||||
"confirmations.mute.heading": "Mute @{name}",
|
||||
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
||||
"confirmations.promote_in_group.confirm": "Promote",
|
||||
"confirmations.promote_in_group.message": "Are you sure you want to promote @{name}? You will not be able to demote them.",
|
||||
"confirmations.redraft.confirm": "Delete & redraft",
|
||||
"confirmations.redraft.heading": "Delete & redraft",
|
||||
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.",
|
||||
|
@ -606,6 +626,9 @@
|
|||
"empty_column.filters": "You haven't created any muted words yet.",
|
||||
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
||||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There are no posts in this group yet.",
|
||||
"empty_column.group_blocks": "The group hasn't blocked any users yet.",
|
||||
"empty_column.group_membership_requests": "There are no pending membership requests for this group.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
|
@ -621,6 +644,7 @@
|
|||
"empty_column.remote": "There is nothing here! Manually follow users from {instance} to fill it up.",
|
||||
"empty_column.scheduled_statuses": "You don't have any scheduled statuses yet. When you add one, it will show up here.",
|
||||
"empty_column.search.accounts": "There are no people results for \"{term}\"",
|
||||
"empty_column.search.groups": "There are no groups results for \"{term}\"",
|
||||
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
|
||||
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
|
||||
"empty_column.test": "The test timeline is empty.",
|
||||
|
@ -639,7 +663,10 @@
|
|||
"event.manage": "Manage",
|
||||
"event.organized_by": "Organized by {name}",
|
||||
"event.participants": "{count} {rawCount, plural, one {person} other {people}} going",
|
||||
"event.quote": "Quote event",
|
||||
"event.reblog": "Repost event",
|
||||
"event.show_on_map": "Show on map",
|
||||
"event.unreblog": "Un-repost event",
|
||||
"event.website": "External links",
|
||||
"event_map.navigate": "Navigate",
|
||||
"events.create_event": "Create event",
|
||||
|
@ -693,6 +720,42 @@
|
|||
"gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.",
|
||||
"gdpr.title": "{siteTitle} uses cookies",
|
||||
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
|
||||
"group.admin_subheading": "Group administrators",
|
||||
"group.cancel_request": "Cancel request",
|
||||
"group.group_mod_authorize": "Accept",
|
||||
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
||||
"group.group_mod_block": "Block @{name} from group",
|
||||
"group.group_mod_block.success": "Blocked @{name} from group",
|
||||
"group.group_mod_demote": "Demote @{name}",
|
||||
"group.group_mod_demote.success": "Demoted @{name} to group user",
|
||||
"group.group_mod_kick": "Kick @{name} from group",
|
||||
"group.group_mod_kick.success": "Kicked @{name} from group",
|
||||
"group.group_mod_promote_admin": "Promote @{name} to group administrator",
|
||||
"group.group_mod_promote_admin.success": "Promoted @{name} to group administrator",
|
||||
"group.group_mod_promote_mod": "Promote @{name} to group moderator",
|
||||
"group.group_mod_promote_mod.success": "Promoted @{name} to group moderator",
|
||||
"group.group_mod_reject": "Reject",
|
||||
"group.group_mod_reject.success": "Rejected @{name} from group",
|
||||
"group.group_mod_unblock": "Unblock",
|
||||
"group.group_mod_unblock.success": "Unblocked @{name} from group",
|
||||
"group.header.alt": "Group header",
|
||||
"group.join": "Join group",
|
||||
"group.join.request_success": "Requested to join the group",
|
||||
"group.join.success": "Joined the group",
|
||||
"group.leave": "Leave group",
|
||||
"group.leave.success": "Left the group",
|
||||
"group.manage": "Manage group",
|
||||
"group.moderator_subheading": "Group moderators",
|
||||
"group.privacy.locked": "Private",
|
||||
"group.privacy.public": "Public",
|
||||
"group.request_join": "Request to join group",
|
||||
"group.role.admin": "Admin",
|
||||
"group.role.moderator": "Moderator",
|
||||
"group.tabs.all": "All",
|
||||
"group.tabs.members": "Members",
|
||||
"group.user_subheading": "Users",
|
||||
"groups.empty.subtitle": "Start discovering groups to join or create your own.",
|
||||
"groups.empty.title": "No Groups yet",
|
||||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "or {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "without {additional}",
|
||||
|
@ -796,6 +859,27 @@
|
|||
"login_external.errors.instance_fail": "The instance returned an error.",
|
||||
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
|
||||
"login_form.header": "Sign In",
|
||||
"manage_group.blocked_members": "Blocked members",
|
||||
"manage_group.create": "Create",
|
||||
"manage_group.delete_group": "Delete group",
|
||||
"manage_group.edit_group": "Edit group",
|
||||
"manage_group.edit_success": "The group was edited",
|
||||
"manage_group.fields.description_label": "Description",
|
||||
"manage_group.fields.description_placeholder": "Description",
|
||||
"manage_group.fields.name_label": "Group name (required)",
|
||||
"manage_group.fields.name_placeholder": "Group Name",
|
||||
"manage_group.get_started": "Let's get started!",
|
||||
"manage_group.next": "Next",
|
||||
"manage_group.pending_requests": "Pending requests",
|
||||
"manage_group.privacy.hint": "These settings cannot be changed later.",
|
||||
"manage_group.privacy.label": "Privacy settings",
|
||||
"manage_group.privacy.private.hint": "Discoverable. Users can join after their request is approved.",
|
||||
"manage_group.privacy.private.label": "Private (Owner approval required)",
|
||||
"manage_group.privacy.public.hint": "Discoverable. Anyone can join.",
|
||||
"manage_group.privacy.public.label": "Public",
|
||||
"manage_group.submit_success": "The group was created",
|
||||
"manage_group.tagline": "Groups connect you with others based on shared interests.",
|
||||
"manage_group.update": "Update",
|
||||
"media_panel.empty_message": "No media found.",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "MFA confirmed",
|
||||
|
@ -862,7 +946,9 @@
|
|||
"navigation_bar.compose_quote": "Quote post",
|
||||
"navigation_bar.compose_reply": "Reply to post",
|
||||
"navigation_bar.create_event": "Create new event",
|
||||
"navigation_bar.create_group": "Create Group",
|
||||
"navigation_bar.domain_blocks": "Domain blocks",
|
||||
"navigation_bar.edit_group": "Edit Group",
|
||||
"navigation_bar.favourites": "Likes",
|
||||
"navigation_bar.filters": "Filters",
|
||||
"navigation_bar.follow_requests": "Follow requests",
|
||||
|
@ -874,6 +960,9 @@
|
|||
"navigation_bar.preferences": "Preferences",
|
||||
"navigation_bar.profile_directory": "Profile directory",
|
||||
"navigation_bar.soapbox_config": "Soapbox config",
|
||||
"new_group_panel.action": "Create group",
|
||||
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
|
||||
"new_group_panel.title": "Create New Group",
|
||||
"notification.favourite": "{name} liked your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
|
@ -1104,6 +1193,7 @@
|
|||
"search.placeholder": "Search",
|
||||
"search_results.accounts": "People",
|
||||
"search_results.filter_message": "You are searching for posts from @{acct}.",
|
||||
"search_results.groups": "Groups",
|
||||
"search_results.hashtags": "Hashtags",
|
||||
"search_results.statuses": "Posts",
|
||||
"security.codes.fail": "Failed to fetch backup codes",
|
||||
|
@ -1214,6 +1304,8 @@
|
|||
"sponsored.subtitle": "Sponsored post",
|
||||
"status.admin_account": "Moderate @{name}",
|
||||
"status.admin_status": "Open this post in the moderation interface",
|
||||
"status.approval.pending": "Pending approval",
|
||||
"status.approval.rejected": "Rejected",
|
||||
"status.bookmark": "Bookmark",
|
||||
"status.bookmarked": "Bookmark added.",
|
||||
"status.cancel_reblog_private": "Un-repost",
|
||||
|
@ -1223,11 +1315,16 @@
|
|||
"status.delete": "Delete",
|
||||
"status.detailed_status": "Detailed conversation view",
|
||||
"status.direct": "Direct message @{name}",
|
||||
"status.disabled_replies.group_membership": "Only group members can reply",
|
||||
"status.edit": "Edit",
|
||||
"status.embed": "Embed post",
|
||||
"status.external": "View post on {domain}",
|
||||
"status.favourite": "Like",
|
||||
"status.filtered": "Filtered",
|
||||
"status.group": "Posted in {group}",
|
||||
"status.group_mod_block": "Block @{name} from group",
|
||||
"status.group_mod_delete": "Delete post from group",
|
||||
"status.group_mod_kick": "Kick @{name} from group",
|
||||
"status.interactions.favourites": "{count, plural, one {Like} other {Likes}}",
|
||||
"status.interactions.quotes": "{count, plural, one {Quote} other {Quotes}}",
|
||||
"status.interactions.reblogs": "{count, plural, one {Repost} other {Reposts}}",
|
||||
|
@ -1288,6 +1385,7 @@
|
|||
"tabs_bar.all": "All",
|
||||
"tabs_bar.dashboard": "Dashboard",
|
||||
"tabs_bar.fediverse": "Fediverse",
|
||||
"tabs_bar.groups": "Groups",
|
||||
"tabs_bar.home": "Home",
|
||||
"tabs_bar.local": "Local",
|
||||
"tabs_bar.more": "More",
|
||||
|
|
|
@ -639,7 +639,10 @@
|
|||
"event.manage": "Administrar",
|
||||
"event.organized_by": "Organizado por {name}",
|
||||
"event.participants": "{count} {rawCount, plural, one {persona} other {personas}} asistiendo",
|
||||
"event.quote": "Citar evento",
|
||||
"event.reblog": "Evento para reenvío",
|
||||
"event.show_on_map": "Mostrar en el mapa",
|
||||
"event.unreblog": "Dejar de publicar el evento",
|
||||
"event.website": "Vínculos externos",
|
||||
"event_map.navigate": "Navegar",
|
||||
"events.create_event": "Crear evento",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -18,7 +18,7 @@
|
|||
"account.endorse.success": "Stai promuovendo @{acct} dal tuo profilo",
|
||||
"account.familiar_followers": "Seguito da {accounts}",
|
||||
"account.familiar_followers.empty": "Nessun profilo conosciuto, segue {name}.",
|
||||
"account.familiar_followers.more": "{count} {count, plural, one {altro tuo} other {altri tuoi}} Follower",
|
||||
"account.familiar_followers.more": "{count, plural, one {# altro tuo} other {altri # tuoi}} profili Follower",
|
||||
"account.follow": "Segui",
|
||||
"account.followers": "Follower",
|
||||
"account.followers.empty": "Nessun follower, per ora.",
|
||||
|
@ -102,7 +102,7 @@
|
|||
"admin.dashcounters.user_count_label": "persone totali",
|
||||
"admin.dashwidgets.email_list_header": "Email list",
|
||||
"admin.dashwidgets.software_header": "Software",
|
||||
"admin.latest_accounts_panel.more": "Clicca per vedere {count} {count, plural, one {profilo} other {profili}}",
|
||||
"admin.latest_accounts_panel.more": "Clicca per vedere {count, plural, one {# profilo} other {# profili}}",
|
||||
"admin.latest_accounts_panel.title": "Ultimi profili",
|
||||
"admin.moderation_log.empty_message": "Non hai ancora moderato nessun profilo. In futuro, qui comparirà lo storico delle moderazioni.",
|
||||
"admin.reports.actions.close": "Chiudi",
|
||||
|
@ -139,7 +139,7 @@
|
|||
"admin_nav.awaiting_approval": "In attesa di approvazione",
|
||||
"admin_nav.dashboard": "Cruscotto",
|
||||
"admin_nav.reports": "Segnalazioni",
|
||||
"age_verification.body": "{siteTitle} richiede che le persone iscritte abbiano {ageMinimum} anni di età. Chiunque abbia l'età inferiore a {ageMinimum} anni, non può accedere.",
|
||||
"age_verification.body": "{siteTitle} richiede che le persone iscritte abbiano {ageMinimum, plural, one {# anno} other {# anni}} di età. Chiunque abbia l'età inferiore a {ageMinimum, plural, one {# anno} other {# anni}}, non può accedere.",
|
||||
"age_verification.fail": "Devi aver compiuto almeno {ageMinimum, plural, one {# anno} other {# anni}}.",
|
||||
"age_verification.header": "Inserisci la tua data di nascita",
|
||||
"alert.unexpected.body": "Spiacenti per l'interruzione, se il problema persiste, contatta gli amministratori. Oppure prova a {clearCookies} (avverrà l'uscita dal sito).",
|
||||
|
@ -215,7 +215,7 @@
|
|||
"chat_message_list.network_failure.title": "Ooops!",
|
||||
"chat_message_list_intro.actions.accept": "Acconsenti",
|
||||
"chat_message_list_intro.actions.leave_chat": "Abbandona la chat",
|
||||
"chat_message_list_intro.actions.message_lifespan": "Saranno eliminati i messaggi più vecchi di {day} giorni.",
|
||||
"chat_message_list_intro.actions.message_lifespan": "Saranno eliminati i messaggi più vecchi di {day, plural, one {# giorno} other {# giorni}}.",
|
||||
"chat_message_list_intro.actions.report": "Segnala",
|
||||
"chat_message_list_intro.intro": "vuole iniziare una chat con te",
|
||||
"chat_message_list_intro.leave_chat.confirm": "Abbandona la chat",
|
||||
|
@ -233,7 +233,7 @@
|
|||
"chat_settings.auto_delete.30days": "30 giorni",
|
||||
"chat_settings.auto_delete.7days": "7 giorni",
|
||||
"chat_settings.auto_delete.90days": "90 giorni",
|
||||
"chat_settings.auto_delete.days": "{day} giorni",
|
||||
"chat_settings.auto_delete.days": "{day, plural, one {# giorno} other {# giorni}}",
|
||||
"chat_settings.auto_delete.hint": "I messaggi inviati saranno eliminati automaticamente al termine del periodo selezionato",
|
||||
"chat_settings.auto_delete.label": "Eliminazione automatica dei messaggi",
|
||||
"chat_settings.block.confirm": "Blocca",
|
||||
|
@ -250,8 +250,8 @@
|
|||
"chat_settings.unblock.confirm": "Sblocca",
|
||||
"chat_settings.unblock.heading": "Sblocca @{acct}",
|
||||
"chat_settings.unblock.message": "Sbloccando permetterai a questo profile di inviarti messaggi e vedere le tue pubblicazioni.",
|
||||
"chat_window.auto_delete_label": "Eliminazione automatica dopo {day} giorni",
|
||||
"chat_window.auto_delete_tooltip": "Eliminazione automatica dei messaggi dopo {day} dalla relativa spedizione.",
|
||||
"chat_window.auto_delete_label": "Eliminazione automatica dopo {day, plural, one {# giorno} other {# giorni}}",
|
||||
"chat_window.auto_delete_tooltip": "Eliminazione automatica dei messaggi dopo {day, plural, one {# giorno} other {# giorni}} dalla relativa spedizione.",
|
||||
"chats.actions.copy": "Copia",
|
||||
"chats.actions.delete": "Elimina",
|
||||
"chats.actions.deleteForMe": "Elimina per me",
|
||||
|
@ -319,10 +319,14 @@
|
|||
"column.follow_requests": "Richieste dai Follower",
|
||||
"column.followers": "Follower",
|
||||
"column.following": "Following",
|
||||
"column.group_blocked_members": "Persone bloccate",
|
||||
"column.group_pending_requests": "Richieste in attesa",
|
||||
"column.groups": "Gruppi",
|
||||
"column.home": "Home",
|
||||
"column.import_data": "Importazione dati",
|
||||
"column.info": "Informazioni server",
|
||||
"column.lists": "Liste",
|
||||
"column.manage_group": "Gestione gruppi",
|
||||
"column.mentions": "Menzioni",
|
||||
"column.mfa": "Autenticazione a due fattori",
|
||||
"column.mfa_cancel": "Annulla",
|
||||
|
@ -348,7 +352,7 @@
|
|||
"common.cancel": "Annulla",
|
||||
"common.error": "Qualcosa è andato storto. Prova a ricaricare la pagina.",
|
||||
"compare_history_modal.header": "Storico delle modifiche",
|
||||
"compose.character_counter.title": "Stai usando {chars} di {maxChars} caratteri",
|
||||
"compose.character_counter.title": "Stai usando {chars} di {maxChars} {maxChars, plural, one {carattere} other {caratteri}}",
|
||||
"compose.edit_success": "Hai modificato la pubblicazione",
|
||||
"compose.invalid_schedule": "Devi pianificare le pubblicazioni almeno fra 5 minuti.",
|
||||
"compose.submit_success": "Pubblicazione avvenuta!",
|
||||
|
@ -431,6 +435,9 @@
|
|||
"confirmations.block.confirm": "Conferma il blocco",
|
||||
"confirmations.block.heading": "Blocca @{name}",
|
||||
"confirmations.block.message": "Vuoi davvero bloccare {name}?",
|
||||
"confirmations.block_from_group.confirm": "Blocca",
|
||||
"confirmations.block_from_group.heading": "Blocca partecipante al gruppo",
|
||||
"confirmations.block_from_group.message": "Vuoi davvero impedire a @{name} di interagire con questo gruppo?",
|
||||
"confirmations.cancel.confirm": "Abbandona",
|
||||
"confirmations.cancel.heading": "Abbandona la pubblicazione",
|
||||
"confirmations.cancel.message": "Vuoi davvero abbandonare la creazione di questa pubblicazione?",
|
||||
|
@ -445,17 +452,30 @@
|
|||
"confirmations.delete_event.confirm": "Elimina",
|
||||
"confirmations.delete_event.heading": "Elimina l'evento",
|
||||
"confirmations.delete_event.message": "Vuoi davvero eliminare questo evento?",
|
||||
"confirmations.delete_from_group.heading": "Elimina dal gruppo",
|
||||
"confirmations.delete_from_group.message": "Vuoi davvero eliminare la pubblicazione di @{name}?",
|
||||
"confirmations.delete_group.confirm": "Elimina",
|
||||
"confirmations.delete_group.heading": "Elimina gruppo",
|
||||
"confirmations.delete_group.message": "Vuoi davvero eliminare questo gruppo? Questa attività è irreversibile.",
|
||||
"confirmations.delete_list.confirm": "Elimina",
|
||||
"confirmations.delete_list.heading": "Elimina lista",
|
||||
"confirmations.delete_list.message": "Vuoi davvero eliminare questa lista?",
|
||||
"confirmations.domain_block.confirm": "Nascondi intero dominio",
|
||||
"confirmations.domain_block.heading": "Block {domain}",
|
||||
"confirmations.domain_block.message": "Vuoi davvero bloccare l'intero {domain}? Nella maggior parte dei casi, pochi blocchi o silenziamenti mirati sono sufficienti e preferibili. Non vedrai nessuna pubblicazione di quel dominio né nelle timeline pubbliche né nelle notifiche. I tuoi seguaci di quel dominio saranno eliminati.",
|
||||
"confirmations.kick_from_group.confirm": "Espelli",
|
||||
"confirmations.kick_from_group.heading": "Espelli persona dal gruppo",
|
||||
"confirmations.kick_from_group.message": "Vuoi davvero espellere @{name} da questo gruppo?",
|
||||
"confirmations.leave_event.confirm": "Abbandona",
|
||||
"confirmations.leave_event.message": "Se vorrai partecipare nuovamente, la tua richiesta dovrà essere riconfermata. Vuoi davvero procedere?",
|
||||
"confirmations.leave_group.confirm": "Abbandona",
|
||||
"confirmations.leave_group.heading": "Abbandona il gruppo",
|
||||
"confirmations.leave_group.message": "Stai per abbandonare questo gruppo. Vuoi davvero continuare?",
|
||||
"confirmations.mute.confirm": "Silenzia",
|
||||
"confirmations.mute.heading": "Silenzia @{name}",
|
||||
"confirmations.mute.message": "Vuoi davvero silenziare {name}?",
|
||||
"confirmations.promote_in_group.confirm": "Promuovi",
|
||||
"confirmations.promote_in_group.message": "Vuoi davvero promuovere @{nome}? Non sarai in grado di degradare questa persona.",
|
||||
"confirmations.redraft.confirm": "Cancella e riscrivi",
|
||||
"confirmations.redraft.heading": "Cancella e riscrivi",
|
||||
"confirmations.redraft.message": "Vuoi davvero cancellare questo stato e riscriverlo? Perderai tutte le risposte, condivisioni e segnalibri.",
|
||||
|
@ -606,6 +626,9 @@
|
|||
"empty_column.filters": "Non hai ancora filtrato alcuna parola.",
|
||||
"empty_column.follow_recommendations": "Sembra che non ci siano profili suggeriti. Prova a cercare quelli di persone che potresti conoscere, oppure esplora gli hashtag di tendenza.",
|
||||
"empty_column.follow_requests": "Non hai ancora ricevuto nessuna richiesta di seguirti. Quando ne arriveranno, saranno mostrate qui.",
|
||||
"empty_column.group": "In questo gruppo non è ancora stato pubblicato niente.",
|
||||
"empty_column.group_blocks": "Il gruppo non ha ancora bloccato alcun profilo.",
|
||||
"empty_column.group_membership_requests": "Non ci sono richieste in attesa per questo gruppo.",
|
||||
"empty_column.hashtag": "Non c'è ancora nessuna pubblicazione con questo hashtag.",
|
||||
"empty_column.home": "Non stai ancora seguendo nessuno. Visita {public} o usa la ricerca per incontrare nuove persone.",
|
||||
"empty_column.home.local_tab": "la «Timeline Locale» di {site_title}",
|
||||
|
@ -621,6 +644,7 @@
|
|||
"empty_column.remote": "Qui non c'è niente! Segui qualche profilo di {instance} per riempire quest'area.",
|
||||
"empty_column.scheduled_statuses": "Non hai ancora pianificato alcuna pubblicazione, quando succederà, saranno elencate qui",
|
||||
"empty_column.search.accounts": "Non risulta alcun profilo per \"{term}\"",
|
||||
"empty_column.search.groups": "Nessun risultato di gruppi con \"{term}\"",
|
||||
"empty_column.search.hashtags": "Non risulta alcun hashtag per \"{term}\"",
|
||||
"empty_column.search.statuses": "Non risulta alcuna pubblicazione per \"{term}\"",
|
||||
"empty_column.test": "La Timeline di prova è vuota.",
|
||||
|
@ -639,7 +663,10 @@
|
|||
"event.manage": "Gestione",
|
||||
"event.organized_by": "Organizzato da {name}",
|
||||
"event.participants": "Interessa a {count} {rawCount, plural one {una persona} other {altre persone}}",
|
||||
"event.quote": "Cita evento",
|
||||
"event.reblog": "Condividi evento",
|
||||
"event.show_on_map": "Mostra sulla mappa",
|
||||
"event.unreblog": "Annulla condivisione evento",
|
||||
"event.website": "Collegamenti esterni",
|
||||
"event_map.navigate": "Naviga",
|
||||
"events.create_event": "Crea evento",
|
||||
|
@ -693,6 +720,42 @@
|
|||
"gdpr.message": "{siteTitle} usa i cookie tecnici, quelli essenziali al funzionamento.",
|
||||
"gdpr.title": "{siteTitle} usa i cookie",
|
||||
"getting_started.open_source_notice": "{code_name} è un software open source. Puoi contribuire o segnalare errori su GitLab all'indirizzo {code_link} (v{code_version}).",
|
||||
"group.admin_subheading": "Amministrazione del gruppo",
|
||||
"group.cancel_request": "Cancella richiesta",
|
||||
"group.group_mod_authorize": "Accetta",
|
||||
"group.group_mod_authorize.success": "Hai accettato @{name} nel gruppo",
|
||||
"group.group_mod_block": "Blocca @{name} dal gruppo",
|
||||
"group.group_mod_block.success": "Hai bloccato @{name} dal gruppo",
|
||||
"group.group_mod_demote": "Degrada @{name}",
|
||||
"group.group_mod_demote.success": "Hai degradato @{name} a partecipante del gruppo",
|
||||
"group.group_mod_kick": "Espelli @{name} dal gruppo",
|
||||
"group.group_mod_kick.success": "Hai espulso @{name} dal gruppo",
|
||||
"group.group_mod_promote_admin": "Promuovi @{name} all'amministrazione del gruppo",
|
||||
"group.group_mod_promote_admin.success": "Hai promosso @{name} all'amministrazione del gruppo",
|
||||
"group.group_mod_promote_mod": "Promuovi @{name} alla moderazione del gruppo",
|
||||
"group.group_mod_promote_mod.success": "Hai promosso @{name} alla moderazione del gruppo",
|
||||
"group.group_mod_reject": "Rifiuta",
|
||||
"group.group_mod_reject.success": "Hai rifiutato la partecipazione di @{name} nel gruppo",
|
||||
"group.group_mod_unblock": "Sblocca",
|
||||
"group.group_mod_unblock.success": "Hai sbloccato @{name} dal gruppo",
|
||||
"group.header.alt": "Testata del gruppo",
|
||||
"group.join": "Entra nel gruppo",
|
||||
"group.join.request_success": "Richiesta di partecipazione",
|
||||
"group.join.success": "Partecipazione nel gruppo",
|
||||
"group.leave": "Abbandona il gruppo",
|
||||
"group.leave.success": "Hai abbandonato il gruppo",
|
||||
"group.manage": "Gestisci il gruppo",
|
||||
"group.moderator_subheading": "Moderazione del gruppo",
|
||||
"group.privacy.locked": "Privato",
|
||||
"group.privacy.public": "Pubblico",
|
||||
"group.request_join": "Richiesta di partecipazione",
|
||||
"group.role.admin": "Amministrazione",
|
||||
"group.role.moderator": "Moderazione",
|
||||
"group.tabs.all": "Tutto",
|
||||
"group.tabs.members": "Partecipanti",
|
||||
"group.user_subheading": "Persone",
|
||||
"groups.empty.subtitle": "Inizia scoprendo a che gruppi partecipare, o creandone uno tuo.",
|
||||
"groups.empty.title": "Ancora nessun gruppo",
|
||||
"hashtag.column_header.tag_mode.all": "e {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "o {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "senza {additional}",
|
||||
|
@ -796,6 +859,27 @@
|
|||
"login_external.errors.instance_fail": "L'istanza ha restituito un errore.",
|
||||
"login_external.errors.network_fail": "Connessione fallita. Verificare: ci sono estensioni del browser che la bloccano?",
|
||||
"login_form.header": "Accedi",
|
||||
"manage_group.blocked_members": "Persone bloccate",
|
||||
"manage_group.create": "Crea",
|
||||
"manage_group.delete_group": "Elimina gruppo",
|
||||
"manage_group.edit_group": "Modifica gruppo",
|
||||
"manage_group.edit_success": "Hai modificato il gruppo",
|
||||
"manage_group.fields.description_label": "Descrizione",
|
||||
"manage_group.fields.description_placeholder": "Descrizione",
|
||||
"manage_group.fields.name_label": "Nome del gruppo (obbligatorio)",
|
||||
"manage_group.fields.name_placeholder": "Nome del gruppo",
|
||||
"manage_group.get_started": "Iniziamo!",
|
||||
"manage_group.next": "Avanti",
|
||||
"manage_group.pending_requests": "Richieste in attesa",
|
||||
"manage_group.privacy.hint": "Queste impostazioni non potranno essere modificate.",
|
||||
"manage_group.privacy.label": "Impostazioni privacy",
|
||||
"manage_group.privacy.private.hint": "Esibito, le persone possono entrare quando la loro richiesta viene approvata.",
|
||||
"manage_group.privacy.private.label": "Privato (approvazione richiesta)",
|
||||
"manage_group.privacy.public.hint": "Esibito, può partecipare chiunque.",
|
||||
"manage_group.privacy.public.label": "Pubblico",
|
||||
"manage_group.submit_success": "Hai creato il gruppo",
|
||||
"manage_group.tagline": "I gruppi ti collegano ad altre persone con interessi in comune.",
|
||||
"manage_group.update": "Aggiorna",
|
||||
"media_panel.empty_message": "Non ha caricato niente",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "Autenticazione a due fattori, attivata!",
|
||||
|
@ -862,7 +946,9 @@
|
|||
"navigation_bar.compose_quote": "Citazione",
|
||||
"navigation_bar.compose_reply": "Rispondi",
|
||||
"navigation_bar.create_event": "Crea un nuovo evento",
|
||||
"navigation_bar.create_group": "Crea gruppo",
|
||||
"navigation_bar.domain_blocks": "Domini nascosti",
|
||||
"navigation_bar.edit_group": "Modifica gruppo",
|
||||
"navigation_bar.favourites": "Preferite",
|
||||
"navigation_bar.filters": "Filtri",
|
||||
"navigation_bar.follow_requests": "Richieste dai Follower",
|
||||
|
@ -874,6 +960,9 @@
|
|||
"navigation_bar.preferences": "Preferenze",
|
||||
"navigation_bar.profile_directory": "Esplora i profili",
|
||||
"navigation_bar.soapbox_config": "Configura Soapbox",
|
||||
"new_group_panel.action": "Crea gruppo",
|
||||
"new_group_panel.subtitle": "Non riesci a trovare qualcosa sul tema? Crea un gruppo privato o pubblico.",
|
||||
"new_group_panel.title": "Crea nuovo gruppo",
|
||||
"notification.favourite": "{name} ha preferito la pubblicazione",
|
||||
"notification.follow": "{name} adesso ti segue",
|
||||
"notification.follow_request": "{name} ha chiesto di seguirti",
|
||||
|
@ -881,7 +970,7 @@
|
|||
"notification.mentioned": "{name} ti ha menzionato",
|
||||
"notification.move": "{name} ha migrato su {targetName}",
|
||||
"notification.name": "{link}{others}",
|
||||
"notification.others": " + {count} {count, plural, one {altro} other {altri}}",
|
||||
"notification.others": " + {count, plural, one {altro} other {altri}}",
|
||||
"notification.pleroma:chat_mention": "{name} ti ha scritto in chat",
|
||||
"notification.pleroma:emoji_reaction": "{name} ha reagito alla pubblicazione",
|
||||
"notification.pleroma:event_reminder": "Un evento a cui partecipi, inizierà presto",
|
||||
|
@ -900,8 +989,8 @@
|
|||
"notifications.filter.mentions": "Menzioni",
|
||||
"notifications.filter.polls": "Risultati del sondaggio",
|
||||
"notifications.filter.statuses": "Aggiornamenti dalle persone che segui",
|
||||
"notifications.group": "{count} notifiche",
|
||||
"notifications.queue_label": "{count} {count, plural, one {notifica} other {notifiche}} da leggere",
|
||||
"notifications.group": "{count, plural, one {# notifica} other {# notifiche}}",
|
||||
"notifications.queue_label": "Hai {count, plural, one {una notifica} other {# notifiche}} da leggere",
|
||||
"oauth_consumer.tooltip": "Sign in with {provider}",
|
||||
"oauth_consumers.title": "Altri modi di accedere",
|
||||
"onboarding.avatar.subtitle": "Scegline una accattivante, o divertente!",
|
||||
|
@ -1104,6 +1193,7 @@
|
|||
"search.placeholder": "Cerca",
|
||||
"search_results.accounts": "Persone",
|
||||
"search_results.filter_message": "Stai cercando pubblicazioni di @{acct}.",
|
||||
"search_results.groups": "Gruppi",
|
||||
"search_results.hashtags": "Hashtag",
|
||||
"search_results.statuses": "Pubblicazioni",
|
||||
"security.codes.fail": "Impossibile ottenere i codici di backup.",
|
||||
|
@ -1214,6 +1304,8 @@
|
|||
"sponsored.subtitle": "Pubblicazione sponsorizzata",
|
||||
"status.admin_account": "Gestisci @{name}",
|
||||
"status.admin_status": "Apri su AdminFE",
|
||||
"status.approval.pending": "Richieste in attesa",
|
||||
"status.approval.rejected": "Rifiutata",
|
||||
"status.bookmark": "Aggiungi segnalibro",
|
||||
"status.bookmarked": "Segnalibro aggiunto!",
|
||||
"status.cancel_reblog_private": "Annulla condivisione",
|
||||
|
@ -1223,11 +1315,16 @@
|
|||
"status.delete": "Elimina",
|
||||
"status.detailed_status": "Vista conversazione dettagliata",
|
||||
"status.direct": "Messaggio privato @{name}",
|
||||
"status.disabled_replies.group_membership": "Può rispondere soltanto chi partecipa al gruppo",
|
||||
"status.edit": "Modifica",
|
||||
"status.embed": "Incorpora",
|
||||
"status.external": "View post on {domain}",
|
||||
"status.favourite": "Reazioni",
|
||||
"status.filtered": "Filtrato",
|
||||
"status.group": "Pubblicato in {group}",
|
||||
"status.group_mod_block": "Blocca @{name} dal gruppo",
|
||||
"status.group_mod_delete": "Elimina pubblicazione dal gruppo",
|
||||
"status.group_mod_kick": "Espelli @{name} dal gruppo",
|
||||
"status.interactions.favourites": "{count, plural, one {Like} other {Likes}}",
|
||||
"status.interactions.quotes": "{count, plural, one {Citazione} other {Citazioni}}",
|
||||
"status.interactions.reblogs": "{count, plural, one {Condivisione} other {Condivisioni}}",
|
||||
|
@ -1262,16 +1359,16 @@
|
|||
"status.share": "Condividi",
|
||||
"status.show_less_all": "Mostra meno per tutti",
|
||||
"status.show_more_all": "Mostra di più per tutti",
|
||||
"status.show_original": "Mostra l'originale",
|
||||
"status.show_original": "Tradotto",
|
||||
"status.title": "Pubblicazioni",
|
||||
"status.title_direct": "Messaggio diretto",
|
||||
"status.translate": "Traduci",
|
||||
"status.translate": "Traduzione",
|
||||
"status.translated_from_with": "Traduzione da {lang} tramite {provider}",
|
||||
"status.unbookmark": "Elimina preferito",
|
||||
"status.unbookmarked": "Preferito rimosso.",
|
||||
"status.unmute_conversation": "Annulla silenzia conversazione",
|
||||
"status.unpin": "Non fissare in cima al profilo",
|
||||
"status_list.queue_label": "{count} {count, plural, one {nuova pubblicazione} other {nuove pubblicazioni}} da leggere",
|
||||
"status_list.queue_label": "Hai {count, plural, one {una nuova pubblicazione} other {# nuove pubblicazioni}} da leggere",
|
||||
"statuses.quote_tombstone": "Pubblicazione non disponibile.",
|
||||
"statuses.tombstone": "Non è disponibile una o più pubblicazioni.",
|
||||
"streamfield.add": "Aggiungi",
|
||||
|
@ -1288,6 +1385,7 @@
|
|||
"tabs_bar.all": "Tutto",
|
||||
"tabs_bar.dashboard": "Cruscotto",
|
||||
"tabs_bar.fediverse": "Timeline Federata",
|
||||
"tabs_bar.groups": "Gruppi",
|
||||
"tabs_bar.home": "Home",
|
||||
"tabs_bar.local": "Timeline Locale",
|
||||
"tabs_bar.more": "Altro",
|
||||
|
@ -1325,7 +1423,7 @@
|
|||
"upload_error.image_size_limit": "L'immagine eccede il limite di dimensioni ({limit})",
|
||||
"upload_error.limit": "Hai superato il limite di quanti file puoi caricare",
|
||||
"upload_error.poll": "Caricamento file non consentito nei sondaggi",
|
||||
"upload_error.video_duration_limit": "Il video eccede la durata limite di {limit} secondi",
|
||||
"upload_error.video_duration_limit": "Il video eccede la durata limite (di {limit, plural, one {# secondo} other {# secondi}})",
|
||||
"upload_error.video_size_limit": "Il video eccede il limite di dimensioni ({limit})",
|
||||
"upload_form.description": "Descrizione a persone potratrici di disabilità visive",
|
||||
"upload_form.preview": "Anteprima",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -247,6 +247,7 @@
|
|||
"column.follow_requests": "Prośby o obserwację",
|
||||
"column.followers": "Obserwujący",
|
||||
"column.following": "Obserwowani",
|
||||
"column.groups": "Grupy",
|
||||
"column.home": "Strona główna",
|
||||
"column.import_data": "Importuj dane",
|
||||
"column.info": "Informacje o serwerze",
|
||||
|
@ -279,6 +280,31 @@
|
|||
"compose.edit_success": "Twój wpis został zedytowany",
|
||||
"compose.invalid_schedule": "Musisz zaplanować wpis przynajmniej 5 minut wcześniej.",
|
||||
"compose.submit_success": "Twój wpis został wysłany",
|
||||
"compose_event.create": "Utwórz",
|
||||
"compose_event.edit_success": "Wydarzenie zostało zedytowane",
|
||||
"compose_event.fields.approval_required": "Chcę ręcznie zatwierdzać prośby o dołączenie",
|
||||
"compose_event.fields.banner_label": "Baner wydarzenia",
|
||||
"compose_event.fields.description_hint": "Obsługiwana jest składnia Markdown",
|
||||
"compose_event.fields.description_label": "Opis wydarzenia",
|
||||
"compose_event.fields.description_placeholder": "Opis",
|
||||
"compose_event.fields.end_time_label": "Data zakończenia wydarzenia",
|
||||
"compose_event.fields.end_time_placeholder": "Wydarzenie kończy się…",
|
||||
"compose_event.fields.has_end_time": "Wydarzenie ma datę zakończenia",
|
||||
"compose_event.fields.location_label": "Miejsce wydarzenia",
|
||||
"compose_event.fields.name_label": "Nazwa wydarzenia",
|
||||
"compose_event.fields.name_placeholder": "Nazwa",
|
||||
"compose_event.fields.start_time_label": "Data rozpoczęcia wydarzenia",
|
||||
"compose_event.fields.start_time_placeholder": "Wydarzenie rozpoczyna się…",
|
||||
"compose_event.participation_requests.authorize": "Przyjmij",
|
||||
"compose_event.participation_requests.authorize_success": "Przyjęto użytkownika",
|
||||
"compose_event.participation_requests.reject": "Odrzuć",
|
||||
"compose_event.participation_requests.reject_success": "Odrzucono użytkownika",
|
||||
"compose_event.reset_location": "Resetuj miejsce",
|
||||
"compose_event.submit_success": "Wydarzenie zostało utworzone",
|
||||
"compose_event.tabs.edit": "Edytuj szczegóły",
|
||||
"compose_event.tabs.pending": "Zarządzaj prośbami",
|
||||
"compose_event.update": "Aktualizuj",
|
||||
"compose_event.upload_banner": "Wyślij obraz",
|
||||
"compose_form.direct_message_warning": "Ten wpis będzie widoczny tylko dla wszystkich wspomnianych użytkowników.",
|
||||
"compose_form.hashtag_warning": "Ten wpis nie będzie widoczny pod podanymi hashtagami, ponieważ jest oznaczony jako niewidoczny. Tylko publiczne wpisy mogą zostać znalezione z użyciem hashtagów.",
|
||||
"compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię obserwuje, może wyświetlać Twoje wpisy przeznaczone tylko dla obserwujących.",
|
||||
|
@ -344,6 +370,11 @@
|
|||
"confirmations.domain_block.confirm": "Ukryj wszysyko z domeny",
|
||||
"confirmations.domain_block.heading": "Zablokuj {domain}",
|
||||
"confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.",
|
||||
"confirmations.leave_event.confirm": "Opuść wydarzenie",
|
||||
"confirmations.leave_event.message": "Jeśli będziesz chciał(a) dołączyć do wydarzenia jeszcze raz, prośba będzie musiała zostać ponownie zatwierdzona. Czy chcesz kontynuować?",
|
||||
"confirmations.leave_group.confirm": "Opuść",
|
||||
"confirmations.leave_group.message": "Czy na pewno chcesz opuścić tę grupę?",
|
||||
"confirmations.leave_group.heading": "Opuść grupę",
|
||||
"confirmations.mute.confirm": "Wycisz",
|
||||
"confirmations.mute.heading": "Wycisz @{name}",
|
||||
"confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
|
||||
|
@ -494,6 +525,7 @@
|
|||
"empty_column.filters": "Nie wyciszyłeś(-aś) jeszcze żadnego słowa.",
|
||||
"empty_column.follow_recommendations": "Wygląda na to, że nie można wygenerować dla Ciebie sugestii kont do obserwacji. Możesz spróbować użyć wyszukiwania aby odnaleźć ciekawe profile, lub przejrzeć trendujące hashtagi.",
|
||||
"empty_column.follow_requests": "Nie masz żadnych próśb o możliwość obserwacji. Kiedy ktoś utworzy ją, pojawi się tutaj.",
|
||||
"empty_column.group": "Nie ma wpisów w tej grupie.",
|
||||
"empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy(-a)!",
|
||||
"empty_column.home": "Możesz też odwiedzić {public}, aby znaleźć innych użytkowników.",
|
||||
"empty_column.home.local_tab": "zakładkę {site_title}",
|
||||
|
@ -508,6 +540,7 @@
|
|||
"empty_column.remote": "Tu nic nie ma! Zaobserwuj użytkowników {instance}, aby wypełnić tę oś.",
|
||||
"empty_column.scheduled_statuses": "Nie masz żadnych zaplanowanych wpisów. Kiedy dodasz jakiś, pojawi się on tutaj.",
|
||||
"empty_column.search.accounts": "Brak wyników wyszukiwania osób dla „{term}”",
|
||||
"empty_column.search.groups": "Brak wyników wyszukiwania grup dla „{term}”",
|
||||
"empty_column.search.hashtags": "Brak wyników wyszukiwania hashtagów dla „{term}”",
|
||||
"empty_column.search.statuses": "Brak wyników wyszukiwania wpisów dla „{term}”",
|
||||
"empty_column.test": "Testowa oś czasu jest pusta.",
|
||||
|
@ -557,6 +590,22 @@
|
|||
"gdpr.message": "{siteTitle} korzysta z ciasteczek sesji, które są niezbędne dla działania strony.",
|
||||
"gdpr.title": "{siteTitle} korzysta z ciasteczek",
|
||||
"getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).",
|
||||
"group.admin_subheading": "Administratorzy grupy",
|
||||
"groups.empty.title": "Brak grup",
|
||||
"groups.empty.subtitle": "Odkrywaj grupy do których możesz dołączyć lub utwórz własną.",
|
||||
"group.moderator_subheading": "Moderatorzy grupy",
|
||||
"group.user_subheading": "Członkowie grupy",
|
||||
"group.header.alt": "Nagłówek grupy",
|
||||
"group.join": "Dołącz do grupy",
|
||||
"group.leave": "Opuść grupę",
|
||||
"group.manage": "Edytuj grupę",
|
||||
"group.request_join": "Poproś o dołączenie do grupy",
|
||||
"group.role.admin": "Administrator",
|
||||
"group.role.moderator": "Moderator",
|
||||
"group.privacy.locked": "Prywatna",
|
||||
"group.privacy.public": "Publiczna",
|
||||
"group.tabs.all": "Wszystko",
|
||||
"group.tabs.members": "Członkowie",
|
||||
"hashtag.column_header.tag_mode.all": "i {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "lub {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "bez {additional}",
|
||||
|
@ -653,6 +702,21 @@
|
|||
"login_external.errors.instance_fail": "Instancja zwróciła błąd.",
|
||||
"login_external.errors.network_fail": "Połączenie nie powiodło się. Czy jest blokowane przez wtyczkę do przeglądarki?",
|
||||
"login_form.header": "Zaloguj się",
|
||||
"manage_group.create": "Utwórz",
|
||||
"manage_group.fields.name_label": "Nazwa grupy (wymagana)",
|
||||
"manage_group.fields.name_placeholder": "Nazwa grupy",
|
||||
"manage_group.fields.description_label": "Opis",
|
||||
"manage_group.fields.description_placeholder": "Opis",
|
||||
"manage_group.get_started": "Rozpocznijmy!",
|
||||
"manage_group.next": "Dalej",
|
||||
"manage_group.privacy.hint": "To ustawienie nie może zostać później zmienione.",
|
||||
"manage_group.privacy.label": "Ustawienia prywatności",
|
||||
"manage_group.privacy.public.hint": "Widoczna w mechanizmach odkrywania. Każdy może dołączyć.",
|
||||
"manage_group.privacy.public.label": "Publiczna",
|
||||
"manage_group.privacy.private.hint": "Widoczna w mechanizmach odkrywania. Użytkownicy mogą dołączyć po zatwierdzeniu ich prośby.",
|
||||
"manage_group.privacy.private.label": "Prywatna (wymaga zatwierdzenia przez właściciela)",
|
||||
"manage_group.tagline": "Grupy pozwalają łączyć ludzi o podobnych zainteresowaniach.",
|
||||
"manage_group.update": "Aktualizuj",
|
||||
"media_panel.empty_message": "Nie znaleziono mediów.",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "Potwierdzono MFA",
|
||||
|
@ -715,6 +779,8 @@
|
|||
"navigation_bar.compose_edit": "Edytuj wpis",
|
||||
"navigation_bar.compose_quote": "Cytuj wpis",
|
||||
"navigation_bar.compose_reply": "Odpowiedz na wpis",
|
||||
"navigation_bar.create_event": "Utwórz nowe wydarzenie",
|
||||
"navigation_bar.create_group": "Utwórz grupę",
|
||||
"navigation_bar.domain_blocks": "Ukryte domeny",
|
||||
"navigation_bar.favourites": "Ulubione",
|
||||
"navigation_bar.filters": "Wyciszone słowa",
|
||||
|
@ -723,10 +789,14 @@
|
|||
"navigation_bar.in_reply_to": "W odpowiedzi do",
|
||||
"navigation_bar.invites": "Zaproszenia",
|
||||
"navigation_bar.logout": "Wyloguj",
|
||||
"navigation_bar.edit_group": "Edytuj grupę",
|
||||
"navigation_bar.mutes": "Wyciszeni użytkownicy",
|
||||
"navigation_bar.preferences": "Preferencje",
|
||||
"navigation_bar.profile_directory": "Katalog profilów",
|
||||
"navigation_bar.soapbox_config": "Konfiguracja Soapbox",
|
||||
"new_group_panel.action": "Utwórz grupę",
|
||||
"new_group_panel.subtitle": "Nie możesz znaleźć tego, czego szukasz? Utwórz własną prywatną lub publiczną grupę.",
|
||||
"new_group_panel.title": "Utwórz nową grupę",
|
||||
"notification.favourite": "{name} dodał(a) Twój wpis do ulubionych",
|
||||
"notification.follow": "{name} zaczął(-ęła) Cię obserwować",
|
||||
"notification.follow_request": "{name} poprosił(a) Cię o możliwość obserwacji",
|
||||
|
@ -948,6 +1018,7 @@
|
|||
"search.placeholder": "Szukaj",
|
||||
"search_results.accounts": "Ludzie",
|
||||
"search_results.filter_message": "Szukasz wpisów autorstwa @{acct}.",
|
||||
"search_results.groups": "Grupy",
|
||||
"search_results.hashtags": "Hashtagi",
|
||||
"search_results.statuses": "Wpisy",
|
||||
"security.codes.fail": "Nie udało się uzyskać zapasowych kodów",
|
||||
|
@ -1121,6 +1192,7 @@
|
|||
"tabs_bar.all": "Wszystkie",
|
||||
"tabs_bar.dashboard": "Panel administracyjny",
|
||||
"tabs_bar.fediverse": "Fediwersum",
|
||||
"tabs_bar.groups": "Grupy",
|
||||
"tabs_bar.home": "Strona główna",
|
||||
"tabs_bar.local": "Lokalna",
|
||||
"tabs_bar.more": "Więcej",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Group relationship normalizer:
|
||||
* Converts API group relationships into our internal format.
|
||||
*/
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
Record as ImmutableRecord,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
export const GroupRelationshipRecord = ImmutableRecord({
|
||||
id: '',
|
||||
member: false,
|
||||
requested: false,
|
||||
role: null as 'admin' | 'moderator' | 'user' | null,
|
||||
});
|
||||
|
||||
export const normalizeGroupRelationship = (relationship: Record<string, any>) => {
|
||||
return GroupRelationshipRecord(
|
||||
ImmutableMap(fromJS(relationship)),
|
||||
);
|
||||
};
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Group normalizer:
|
||||
* Converts API groups into our internal format.
|
||||
*/
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
Record as ImmutableRecord,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||
import { unescapeHTML } from 'soapbox/utils/html';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { Emoji, GroupRelationship } from 'soapbox/types/entities';
|
||||
|
||||
export const GroupRecord = ImmutableRecord({
|
||||
avatar: '',
|
||||
avatar_static: '',
|
||||
created_at: '',
|
||||
display_name: '',
|
||||
domain: '',
|
||||
emojis: ImmutableList<Emoji>(),
|
||||
header: '',
|
||||
header_static: '',
|
||||
id: '',
|
||||
locked: false,
|
||||
membership_required: false,
|
||||
note: '',
|
||||
statuses_visibility: 'public',
|
||||
uri: '',
|
||||
url: '',
|
||||
|
||||
// Internal fields
|
||||
display_name_html: '',
|
||||
note_emojified: '',
|
||||
note_plain: '',
|
||||
relationship: null as GroupRelationship | null,
|
||||
});
|
||||
|
||||
/** Add avatar, if missing */
|
||||
const normalizeAvatar = (group: ImmutableMap<string, any>) => {
|
||||
const avatar = group.get('avatar');
|
||||
const avatarStatic = group.get('avatar_static');
|
||||
const missing = require('assets/images/avatar-missing.png');
|
||||
|
||||
return group.withMutations(group => {
|
||||
group.set('avatar', avatar || avatarStatic || missing);
|
||||
group.set('avatar_static', avatarStatic || avatar || missing);
|
||||
});
|
||||
};
|
||||
|
||||
/** Add header, if missing */
|
||||
const normalizeHeader = (group: ImmutableMap<string, any>) => {
|
||||
const header = group.get('header');
|
||||
const headerStatic = group.get('header_static');
|
||||
const missing = require('assets/images/header-missing.png');
|
||||
|
||||
return group.withMutations(group => {
|
||||
group.set('header', header || headerStatic || missing);
|
||||
group.set('header_static', headerStatic || header || missing);
|
||||
});
|
||||
};
|
||||
|
||||
/** Normalize emojis */
|
||||
const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
|
||||
const emojis = entity.get('emojis', ImmutableList()).map(normalizeEmoji);
|
||||
return entity.set('emojis', emojis);
|
||||
};
|
||||
|
||||
/** Set display name from username, if applicable */
|
||||
const fixDisplayName = (group: ImmutableMap<string, any>) => {
|
||||
const displayName = group.get('display_name') || '';
|
||||
return group.set('display_name', displayName.trim().length === 0 ? group.get('username') : displayName);
|
||||
};
|
||||
|
||||
/** Emojification, etc */
|
||||
const addInternalFields = (group: ImmutableMap<string, any>) => {
|
||||
const emojiMap = makeEmojiMap(group.get('emojis'));
|
||||
|
||||
return group.withMutations((group: ImmutableMap<string, any>) => {
|
||||
// Emojify group properties
|
||||
group.merge({
|
||||
display_name_html: emojify(escapeTextContentForBrowser(group.get('display_name')), emojiMap),
|
||||
note_emojified: emojify(group.get('note', ''), emojiMap),
|
||||
note_plain: unescapeHTML(group.get('note', '')),
|
||||
});
|
||||
|
||||
// Emojify fields
|
||||
group.update('fields', ImmutableList(), fields => {
|
||||
return fields.map((field: ImmutableMap<string, any>) => {
|
||||
return field.merge({
|
||||
name_emojified: emojify(escapeTextContentForBrowser(field.get('name')), emojiMap),
|
||||
value_emojified: emojify(field.get('value'), emojiMap),
|
||||
value_plain: unescapeHTML(field.get('value')),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getDomainFromURL = (group: ImmutableMap<string, any>): string => {
|
||||
try {
|
||||
const url = group.get('url');
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const guessFqn = (group: ImmutableMap<string, any>): string => {
|
||||
const acct = group.get('acct', '');
|
||||
const [user, domain] = acct.split('@');
|
||||
|
||||
if (domain) {
|
||||
return acct;
|
||||
} else {
|
||||
return [user, getDomainFromURL(group)].join('@');
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeFqn = (group: ImmutableMap<string, any>) => {
|
||||
const fqn = group.get('fqn') || guessFqn(group);
|
||||
return group.set('fqn', fqn);
|
||||
};
|
||||
|
||||
|
||||
/** Rewrite `<p></p>` to empty string. */
|
||||
const fixNote = (group: ImmutableMap<string, any>) => {
|
||||
if (group.get('note') === '<p></p>') {
|
||||
return group.set('note', '');
|
||||
} else {
|
||||
return group;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeGroup = (group: Record<string, any>) => {
|
||||
return GroupRecord(
|
||||
ImmutableMap(fromJS(group)).withMutations(group => {
|
||||
normalizeEmojis(group);
|
||||
normalizeAvatar(group);
|
||||
normalizeHeader(group);
|
||||
normalizeFqn(group);
|
||||
fixDisplayName(group);
|
||||
fixNote(group);
|
||||
addInternalFields(group);
|
||||
}),
|
||||
);
|
||||
};
|
|
@ -9,6 +9,8 @@ export { ChatRecord, normalizeChat } from './chat';
|
|||
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
||||
export { EmojiRecord, normalizeEmoji } from './emoji';
|
||||
export { FilterRecord, normalizeFilter } from './filter';
|
||||
export { GroupRecord, normalizeGroup } from './group';
|
||||
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
|
||||
export { HistoryRecord, normalizeHistory } from './history';
|
||||
export { InstanceRecord, normalizeInstance } from './instance';
|
||||
export { ListRecord, normalizeList } from './list';
|
||||
|
|
|
@ -17,8 +17,9 @@ import { normalizeMention } from 'soapbox/normalizers/mention';
|
|||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
||||
|
||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
|
||||
export type StatusApprovalStatus = 'pending' | 'approval' | 'rejected';
|
||||
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self';
|
||||
|
||||
export type EventJoinMode = 'free' | 'restricted' | 'invite';
|
||||
|
@ -40,6 +41,7 @@ export const EventRecord = ImmutableRecord({
|
|||
export const StatusRecord = ImmutableRecord({
|
||||
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||
application: null as ImmutableMap<string, any> | null,
|
||||
approval_status: 'approved' as StatusApprovalStatus,
|
||||
bookmarked: false,
|
||||
card: null as Card | null,
|
||||
content: '',
|
||||
|
@ -48,7 +50,7 @@ export const StatusRecord = ImmutableRecord({
|
|||
emojis: ImmutableList<Emoji>(),
|
||||
favourited: false,
|
||||
favourites_count: 0,
|
||||
group: null as EmbeddedEntity<any>,
|
||||
group: null as EmbeddedEntity<Group>,
|
||||
in_reply_to_account_id: null as string | null,
|
||||
in_reply_to_id: null as string | null,
|
||||
id: '',
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { fetchGroup } from 'soapbox/actions/groups';
|
||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||
import { Column, Layout } from 'soapbox/components/ui';
|
||||
import GroupHeader from 'soapbox/features/group/components/group-header';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import {
|
||||
CtaBanner,
|
||||
GroupMediaPanel,
|
||||
SignUpPanel,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
|
||||
import { Tabs } from '../components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
all: { id: 'group.tabs.all', defaultMessage: 'All' },
|
||||
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
|
||||
});
|
||||
|
||||
interface IGroupPage {
|
||||
params?: {
|
||||
id?: string
|
||||
}
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Page to display a group. */
|
||||
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||
const intl = useIntl();
|
||||
const match = useRouteMatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const me = useAppSelector(state => state.me);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(id));
|
||||
}, [id]);
|
||||
|
||||
if ((group as any) === false) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.all),
|
||||
to: `/groups/${group?.id}`,
|
||||
name: '/groups/:id',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.members),
|
||||
to: `/groups/${group?.id}/members`,
|
||||
name: '/groups/:id/members',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<Column label={group ? group.display_name : ''} withHeader={false}>
|
||||
<GroupHeader group={group} />
|
||||
|
||||
<Tabs
|
||||
items={items}
|
||||
activeItem={match.path}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</Column>
|
||||
|
||||
{!me && (
|
||||
<BundleContainer fetchComponent={CtaBanner}>
|
||||
{Component => <Component key='cta-banner' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
</Layout.Main>
|
||||
|
||||
<Layout.Aside>
|
||||
{!me && (
|
||||
<BundleContainer fetchComponent={SignUpPanel}>
|
||||
{Component => <Component key='sign-up-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
<BundleContainer fetchComponent={GroupMediaPanel}>
|
||||
{Component => <Component group={group} />}
|
||||
</BundleContainer>
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupPage;
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Column, Layout } from 'soapbox/components/ui';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import {
|
||||
NewGroupPanel,
|
||||
CtaBanner,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
interface IGroupsPage {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Page to display groups. */
|
||||
const GroupsPage: React.FC<IGroupsPage> = ({ children }) => {
|
||||
const me = useAppSelector(state => state.me);
|
||||
// const match = useRouteMatch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<Column withHeader={false}>
|
||||
<div className='space-y-4'>
|
||||
{children}
|
||||
</div>
|
||||
</Column>
|
||||
|
||||
{!me && (
|
||||
<BundleContainer fetchComponent={CtaBanner}>
|
||||
{Component => <Component key='cta-banner' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
</Layout.Main>
|
||||
|
||||
<Layout.Aside>
|
||||
<BundleContainer fetchComponent={NewGroupPanel}>
|
||||
{Component => <Component key='new-group-panel' />}
|
||||
</BundleContainer>
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPage;
|
|
@ -8,6 +8,7 @@ import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'soapbox/actions/importer';
|
|||
import { STREAMING_FOLLOW_RELATIONSHIPS_UPDATE } from 'soapbox/actions/streaming';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const CounterRecord = ImmutableRecord({
|
||||
followers_count: 0,
|
||||
|
@ -17,7 +18,6 @@ const CounterRecord = ImmutableRecord({
|
|||
|
||||
type Counter = ReturnType<typeof CounterRecord>;
|
||||
type State = ImmutableMap<string, Counter>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const normalizeAccount = (state: State, account: APIEntity) => state.set(account.id, CounterRecord({
|
||||
|
|
|
@ -12,10 +12,11 @@ import type { AnyAction } from 'redux';
|
|||
|
||||
const MetaRecord = ImmutableRecord({
|
||||
pleroma: ImmutableMap<string, any>(),
|
||||
role: null as ImmutableMap<string, any> | null,
|
||||
source: ImmutableMap<string, any>(),
|
||||
});
|
||||
|
||||
type Meta = ReturnType<typeof MetaRecord>;
|
||||
export type Meta = ReturnType<typeof MetaRecord>;
|
||||
type State = ImmutableMap<string, Meta>;
|
||||
|
||||
const importAccount = (state: State, account: ImmutableMap<string, any>) => {
|
||||
|
@ -23,6 +24,7 @@ const importAccount = (state: State, account: ImmutableMap<string, any>) => {
|
|||
|
||||
return state.set(accountId, MetaRecord({
|
||||
pleroma: account.get('pleroma', ImmutableMap()).delete('settings_store'),
|
||||
role: account.get('role', null),
|
||||
source: account.get('source', ImmutableMap()),
|
||||
}));
|
||||
};
|
||||
|
|
|
@ -39,10 +39,10 @@ import { normalizeAccount } from 'soapbox/normalizers/account';
|
|||
import { normalizeId } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type AccountRecord = ReturnType<typeof normalizeAccount>;
|
||||
type AccountMap = ImmutableMap<string, any>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
export interface ReducerAccount extends AccountRecord {
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
export const LogEntryRecord = ImmutableRecord({
|
||||
data: ImmutableMap<string, any>(),
|
||||
|
@ -23,7 +24,6 @@ const ReducerRecord = ImmutableRecord({
|
|||
|
||||
type LogEntry = ReturnType<typeof LogEntryRecord>;
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const parseItems = (items: APIEntities) => {
|
||||
|
|
|
@ -11,8 +11,8 @@ import {
|
|||
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, ImmutableOrderedSet<string>>;
|
||||
|
|
|
@ -13,9 +13,9 @@ import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
|||
import { normalizeChatMessage } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type ChatMessageRecord = ReturnType<typeof normalizeChatMessage>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, ChatMessageRecord>;
|
||||
|
|
|
@ -14,9 +14,9 @@ import { normalizeChat } from 'soapbox/normalizers';
|
|||
import { normalizeId } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type ChatRecord = ReturnType<typeof normalizeChat>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
export interface ReducerChat extends ChatRecord {
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
COMPOSE_REPLY_CANCEL,
|
||||
COMPOSE_QUOTE,
|
||||
COMPOSE_QUOTE_CANCEL,
|
||||
COMPOSE_GROUP_POST,
|
||||
COMPOSE_DIRECT,
|
||||
COMPOSE_MENTION,
|
||||
COMPOSE_SUBMIT_REQUEST,
|
||||
|
@ -78,6 +79,7 @@ export const ReducerCompose = ImmutableRecord({
|
|||
caretPosition: null as number | null,
|
||||
content_type: 'text/plain',
|
||||
focusDate: null as Date | null,
|
||||
group_id: null as string | null,
|
||||
idempotencyKey: '',
|
||||
id: null as string | null,
|
||||
in_reply_to: null as string | null,
|
||||
|
@ -202,6 +204,9 @@ const insertEmoji = (compose: Compose, position: number, emojiData: Emoji, needs
|
|||
|
||||
const privacyPreference = (a: string, b: string) => {
|
||||
const order = ['public', 'unlisted', 'private', 'direct'];
|
||||
|
||||
if (a === 'group') return a;
|
||||
|
||||
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
|
||||
};
|
||||
|
||||
|
@ -309,6 +314,7 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||
const defaultCompose = state.get('default')!;
|
||||
|
||||
map.set('group_id', action.status.getIn(['group', 'id']) || action.status.get('group'));
|
||||
map.set('in_reply_to', action.status.get('id'));
|
||||
map.set('to', action.explicitAddressing ? statusToMentionsArray(action.status, action.account) : ImmutableOrderedSet<string>());
|
||||
map.set('text', !action.explicitAddressing ? statusToTextMentions(action.status, action.account) : '');
|
||||
|
@ -351,6 +357,10 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
return updateCompose(state, action.id, () => state.get('default')!.withMutations(map => {
|
||||
map.set('idempotencyKey', uuid());
|
||||
map.set('in_reply_to', action.id.startsWith('reply:') ? action.id.slice(6) : null);
|
||||
if (action.id.startsWith('group:')) {
|
||||
map.set('privacy', 'group');
|
||||
map.set('group_id', action.id.slice(6));
|
||||
}
|
||||
}));
|
||||
case COMPOSE_SUBMIT_FAIL:
|
||||
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
|
||||
|
@ -381,6 +391,14 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
map.set('caretPosition', null);
|
||||
map.set('idempotencyKey', uuid());
|
||||
}));
|
||||
case COMPOSE_GROUP_POST:
|
||||
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||
map.set('privacy', 'group');
|
||||
map.set('group_id', action.group_id);
|
||||
map.set('focusDate', new Date());
|
||||
map.set('caretPosition', null);
|
||||
map.set('idempotencyKey', uuid());
|
||||
}));
|
||||
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||
return updateCompose(state, action.id, compose => compose.update('suggestions', list => list?.clear()).set('suggestion_token', null));
|
||||
case COMPOSE_SUGGESTIONS_READY:
|
||||
|
@ -427,6 +445,7 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
map.set('idempotencyKey', uuid());
|
||||
map.set('content_type', action.contentType || 'text/plain');
|
||||
map.set('quote', action.status.get('quote'));
|
||||
map.set('group_id', action.status.get('group'));
|
||||
|
||||
if (action.v?.software === PLEROMA && action.withRedraft && hasIntegerMediaIds(action.status)) {
|
||||
map.set('media_attachments', ImmutableList());
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
import { Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import {
|
||||
GROUP_EDITOR_RESET,
|
||||
GROUP_EDITOR_TITLE_CHANGE,
|
||||
GROUP_EDITOR_DESCRIPTION_CHANGE,
|
||||
GROUP_EDITOR_PRIVACY_CHANGE,
|
||||
GROUP_EDITOR_MEDIA_CHANGE,
|
||||
GROUP_CREATE_REQUEST,
|
||||
GROUP_CREATE_FAIL,
|
||||
GROUP_CREATE_SUCCESS,
|
||||
GROUP_UPDATE_REQUEST,
|
||||
GROUP_UPDATE_FAIL,
|
||||
GROUP_UPDATE_SUCCESS,
|
||||
GROUP_EDITOR_SET,
|
||||
} from 'soapbox/actions/groups';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
groupId: null as string | null,
|
||||
progress: 0,
|
||||
isUploading: false,
|
||||
isSubmitting: false,
|
||||
isChanged: false,
|
||||
displayName: '',
|
||||
note: '',
|
||||
avatar: null as File | null,
|
||||
header: null as File | null,
|
||||
locked: false,
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
export default function groupEditor(state: State = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case GROUP_EDITOR_RESET:
|
||||
return ReducerRecord();
|
||||
case GROUP_EDITOR_SET:
|
||||
return state.withMutations(map => {
|
||||
map.set('groupId', action.group.id);
|
||||
map.set('displayName', action.group.display_name);
|
||||
map.set('note', action.group.note);
|
||||
});
|
||||
case GROUP_EDITOR_TITLE_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
map.set('displayName', action.value);
|
||||
map.set('isChanged', true);
|
||||
});
|
||||
case GROUP_EDITOR_DESCRIPTION_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
map.set('note', action.value);
|
||||
map.set('isChanged', true);
|
||||
});
|
||||
case GROUP_EDITOR_PRIVACY_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
map.set('locked', action.value);
|
||||
map.set('isChanged', true);
|
||||
});
|
||||
case GROUP_EDITOR_MEDIA_CHANGE:
|
||||
return state.set(action.mediaType, action.value);
|
||||
case GROUP_CREATE_REQUEST:
|
||||
case GROUP_UPDATE_REQUEST:
|
||||
return state.withMutations(map => {
|
||||
map.set('isSubmitting', true);
|
||||
map.set('isChanged', false);
|
||||
});
|
||||
case GROUP_CREATE_FAIL:
|
||||
case GROUP_UPDATE_FAIL:
|
||||
return state.set('isSubmitting', false);
|
||||
case GROUP_CREATE_SUCCESS:
|
||||
case GROUP_UPDATE_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('isSubmitting', false);
|
||||
map.set('groupId', action.group.id);
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import {
|
||||
GROUP_DELETE_SUCCESS,
|
||||
GROUP_MEMBERSHIPS_FETCH_REQUEST,
|
||||
GROUP_MEMBERSHIPS_FETCH_FAIL,
|
||||
GROUP_MEMBERSHIPS_FETCH_SUCCESS,
|
||||
GROUP_MEMBERSHIPS_EXPAND_REQUEST,
|
||||
GROUP_MEMBERSHIPS_EXPAND_FAIL,
|
||||
GROUP_MEMBERSHIPS_EXPAND_SUCCESS,
|
||||
GROUP_PROMOTE_SUCCESS,
|
||||
GROUP_DEMOTE_SUCCESS,
|
||||
GROUP_KICK_SUCCESS,
|
||||
GROUP_BLOCK_SUCCESS,
|
||||
} from 'soapbox/actions/groups';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const ListRecord = ImmutableRecord({
|
||||
next: null as string | null,
|
||||
isLoading: false,
|
||||
items: ImmutableOrderedSet<string>(),
|
||||
});
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
admin: ImmutableMap<string, List>({}),
|
||||
moderator: ImmutableMap<string, List>({}),
|
||||
user: ImmutableMap<string, List>({}),
|
||||
});
|
||||
|
||||
export type GroupRole = 'admin' | 'moderator' | 'user';
|
||||
export type List = ReturnType<typeof ListRecord>;
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
const normalizeList = (state: State, path: string[], memberships: APIEntity[], next: string | null) => {
|
||||
return state.setIn(path, ListRecord({
|
||||
next,
|
||||
items: ImmutableOrderedSet(memberships.map(item => item.account.id)),
|
||||
isLoading: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const appendToList = (state: State, path: string[], memberships: APIEntity[], next: string | null) => {
|
||||
return state.updateIn(path, map => {
|
||||
return (map as List).set('next', next).set('isLoading', false).update('items', list => list.concat(memberships.map(item => item.account.id)));
|
||||
});
|
||||
};
|
||||
|
||||
const updateLists = (state: State, groupId: string, memberships: APIEntity[]) => {
|
||||
const updateList = (state: State, role: string, membership: APIEntity) => {
|
||||
if (role === membership.role) {
|
||||
return state.updateIn([role, groupId], map => (map as List).update('items', set => set.add(membership.account.id)));
|
||||
} else {
|
||||
return state.updateIn([role, groupId], map => (map as List).update('items', set => set.delete(membership.account.id)));
|
||||
}
|
||||
};
|
||||
|
||||
memberships.forEach(membership => {
|
||||
state = updateList(state, 'admin', membership);
|
||||
state = updateList(state, 'moderator', membership);
|
||||
state = updateList(state, 'user', membership);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const removeFromList = (state: State, path: string[], accountId: string) => {
|
||||
return state.updateIn(path, map => {
|
||||
return (map as List).update('items', set => set.delete(accountId));
|
||||
});
|
||||
};
|
||||
|
||||
export default function groupMemberships(state: State = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case GROUP_DELETE_SUCCESS:
|
||||
return state.deleteIn(['admin', action.id]).deleteIn(['moderator', action.id]).deleteIn(['user', action.id]);
|
||||
case GROUP_MEMBERSHIPS_FETCH_REQUEST:
|
||||
case GROUP_MEMBERSHIPS_EXPAND_REQUEST:
|
||||
return state.updateIn([action.role, action.id], map => (map as List || ListRecord()).set('isLoading', true));
|
||||
case GROUP_MEMBERSHIPS_FETCH_FAIL:
|
||||
case GROUP_MEMBERSHIPS_EXPAND_FAIL:
|
||||
return state.updateIn([action.role, action.id], map => (map as List || ListRecord()).set('isLoading', false));
|
||||
case GROUP_MEMBERSHIPS_FETCH_SUCCESS:
|
||||
return normalizeList(state, [action.role, action.id], action.memberships, action.next);
|
||||
case GROUP_MEMBERSHIPS_EXPAND_SUCCESS:
|
||||
return appendToList(state, [action.role, action.id], action.memberships, action.next);
|
||||
case GROUP_PROMOTE_SUCCESS:
|
||||
case GROUP_DEMOTE_SUCCESS:
|
||||
return updateLists(state, action.groupId, action.memberships);
|
||||
case GROUP_KICK_SUCCESS:
|
||||
case GROUP_BLOCK_SUCCESS:
|
||||
state = removeFromList(state, ['admin', action.groupId], action.accountId);
|
||||
state = removeFromList(state, ['moderator', action.groupId], action.accountId);
|
||||
state = removeFromList(state, ['user', action.groupId], action.accountId);
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
GROUP_CREATE_SUCCESS,
|
||||
GROUP_UPDATE_SUCCESS,
|
||||
GROUP_DELETE_SUCCESS,
|
||||
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||
GROUP_JOIN_REQUEST,
|
||||
GROUP_JOIN_SUCCESS,
|
||||
GROUP_JOIN_FAIL,
|
||||
GROUP_LEAVE_REQUEST,
|
||||
GROUP_LEAVE_SUCCESS,
|
||||
GROUP_LEAVE_FAIL,
|
||||
} from 'soapbox/actions/groups';
|
||||
import { normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type GroupRelationshipRecord = ReturnType<typeof normalizeGroupRelationship>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, GroupRelationshipRecord>;
|
||||
|
||||
const normalizeRelationships = (state: State, relationships: APIEntities) => {
|
||||
relationships.forEach(relationship => {
|
||||
state = state.set(relationship.id, normalizeGroupRelationship(relationship));
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default function groupRelationships(state: State = ImmutableMap(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case GROUP_CREATE_SUCCESS:
|
||||
case GROUP_UPDATE_SUCCESS:
|
||||
return state.set(action.group.id, normalizeGroupRelationship({ id: action.group.id, member: true, requested: false, role: 'admin' }));
|
||||
case GROUP_DELETE_SUCCESS:
|
||||
return state.delete(action.id);
|
||||
case GROUP_JOIN_REQUEST:
|
||||
return state.getIn([action.id, 'member']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'member'], true);
|
||||
case GROUP_JOIN_FAIL:
|
||||
return state.setIn([action.id, action.locked ? 'requested' : 'member'], false);
|
||||
case GROUP_LEAVE_REQUEST:
|
||||
return state.setIn([action.id, 'member'], false);
|
||||
case GROUP_LEAVE_FAIL:
|
||||
return state.setIn([action.id, 'member'], true);
|
||||
case GROUP_JOIN_SUCCESS:
|
||||
case GROUP_LEAVE_SUCCESS:
|
||||
return normalizeRelationships(state, [action.relationship]);
|
||||
case GROUP_RELATIONSHIPS_FETCH_SUCCESS:
|
||||
return normalizeRelationships(state, action.relationships);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import { GROUP_FETCH_FAIL, GROUP_DELETE_SUCCESS, GROUP_FETCH_REQUEST } from 'soapbox/actions/groups';
|
||||
import { GROUPS_IMPORT } from 'soapbox/actions/importer';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type GroupRecord = ReturnType<typeof normalizeGroup>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
isLoading: true,
|
||||
items: ImmutableMap<string, GroupRecord | false>({}),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
const normalizeGroups = (state: State, groups: APIEntities) =>
|
||||
state.update('items', items =>
|
||||
groups.reduce((items: ImmutableMap<string, GroupRecord | false>, group) =>
|
||||
items.set(group.id, normalizeGroup(group)), items),
|
||||
).set('isLoading', false);
|
||||
|
||||
export default function groups(state: State = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case GROUPS_IMPORT:
|
||||
return normalizeGroups(state, action.groups);
|
||||
case GROUP_FETCH_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case GROUP_DELETE_SUCCESS:
|
||||
case GROUP_FETCH_FAIL:
|
||||
return state
|
||||
.setIn(['items', action.id], false)
|
||||
.set('isLoading', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -26,6 +26,10 @@ import custom_emojis from './custom-emojis';
|
|||
import domain_lists from './domain-lists';
|
||||
import dropdown_menu from './dropdown-menu';
|
||||
import filters from './filters';
|
||||
import group_editor from './group-editor';
|
||||
import group_memberships from './group-memberships';
|
||||
import group_relationships from './group-relationships';
|
||||
import groups from './groups';
|
||||
import history from './history';
|
||||
import instance from './instance';
|
||||
import listAdder from './list-adder';
|
||||
|
@ -120,6 +124,10 @@ const reducers = {
|
|||
announcements,
|
||||
compose_event,
|
||||
admin_user_index,
|
||||
groups,
|
||||
group_relationships,
|
||||
group_memberships,
|
||||
group_editor,
|
||||
};
|
||||
|
||||
// Build a default state from all reducers: it has the key and `undefined`
|
||||
|
|
|
@ -11,9 +11,9 @@ import {
|
|||
import { normalizeList } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type ListRecord = ReturnType<typeof normalizeList>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, ListRecord | false>;
|
||||
|
|
|
@ -33,10 +33,10 @@ import {
|
|||
} from '../actions/importer';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type Relationship = ReturnType<typeof normalizeRelationship>;
|
||||
type State = ImmutableMap<string, Relationship>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const normalizeRelationships = (state: State, relationships: APIEntities) => {
|
||||
|
@ -57,7 +57,7 @@ const setDomainBlocking = (state: State, accounts: ImmutableList<string>, blocki
|
|||
|
||||
const importPleromaAccount = (state: State, account: APIEntity) => {
|
||||
const relationship = get(account, ['pleroma', 'relationship'], {});
|
||||
if (relationship.id && relationship !== {})
|
||||
if (relationship.id)
|
||||
return normalizeRelationships(state, [relationship]);
|
||||
return state;
|
||||
};
|
||||
|
|
|
@ -27,12 +27,15 @@ import type { APIEntity, Tag } from 'soapbox/types/entities';
|
|||
const ResultsRecord = ImmutableRecord({
|
||||
accounts: ImmutableOrderedSet<string>(),
|
||||
statuses: ImmutableOrderedSet<string>(),
|
||||
groups: ImmutableOrderedSet<string>(),
|
||||
hashtags: ImmutableOrderedSet<Tag>(), // it's a list of maps
|
||||
accountsHasMore: false,
|
||||
statusesHasMore: false,
|
||||
groupsHasMore: false,
|
||||
hashtagsHasMore: false,
|
||||
accountsLoaded: false,
|
||||
statusesLoaded: false,
|
||||
groupsLoaded: false,
|
||||
hashtagsLoaded: false,
|
||||
});
|
||||
|
||||
|
@ -48,9 +51,9 @@ const ReducerRecord = ImmutableRecord({
|
|||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
export type SearchFilter = 'accounts' | 'statuses' | 'hashtags';
|
||||
export type SearchFilter = 'accounts' | 'statuses' | 'groups' | 'hashtags';
|
||||
|
||||
const toIds = (items: APIEntities) => {
|
||||
const toIds = (items: APIEntities = []) => {
|
||||
return ImmutableOrderedSet(items.map(item => item.id));
|
||||
};
|
||||
|
||||
|
@ -60,12 +63,15 @@ const importResults = (state: State, results: APIEntity, searchTerm: string, sea
|
|||
state.set('results', ResultsRecord({
|
||||
accounts: toIds(results.accounts),
|
||||
statuses: toIds(results.statuses),
|
||||
groups: toIds(results.groups),
|
||||
hashtags: ImmutableOrderedSet(results.hashtags.map(normalizeTag)), // it's a list of records
|
||||
accountsHasMore: results.accounts.length >= 20,
|
||||
statusesHasMore: results.statuses.length >= 20,
|
||||
groupsHasMore: results.groups?.length >= 20,
|
||||
hashtagsHasMore: results.hashtags.length >= 20,
|
||||
accountsLoaded: true,
|
||||
statusesLoaded: true,
|
||||
groupsLoaded: true,
|
||||
hashtagsLoaded: true,
|
||||
}));
|
||||
|
||||
|
|
|
@ -42,11 +42,11 @@ import {
|
|||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
type StatusRecord = ReturnType<typeof normalizeStatus>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, ReducerStatus>;
|
||||
|
@ -56,6 +56,7 @@ export interface ReducerStatus extends StatusRecord {
|
|||
reblog: string | null,
|
||||
poll: string | null,
|
||||
quote: string | null,
|
||||
group: string | null,
|
||||
}
|
||||
|
||||
const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
||||
|
@ -64,6 +65,7 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
|||
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
||||
poll: normalizeId(status.getIn(['poll', 'id'])),
|
||||
quote: normalizeId(status.getIn(['quote', 'id'])),
|
||||
group: normalizeId(status.getIn(['group', 'id'])),
|
||||
}) as ReducerStatus;
|
||||
};
|
||||
|
||||
|
|
|
@ -35,7 +35,6 @@ import {
|
|||
} from '../actions/timelines';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { StatusVisibility } from 'soapbox/normalizers/status';
|
||||
import type { APIEntity, Status } from 'soapbox/types/entities';
|
||||
|
||||
const TRUNCATE_LIMIT = 40;
|
||||
|
@ -242,8 +241,10 @@ const timelineDisconnect = (state: State, timelineId: string) => {
|
|||
}));
|
||||
};
|
||||
|
||||
const getTimelinesByVisibility = (visibility: StatusVisibility) => {
|
||||
switch (visibility) {
|
||||
const getTimelinesForStatus = (status: APIEntity) => {
|
||||
switch (status.visibility) {
|
||||
case 'group':
|
||||
return [`group:${status.group?.id || status.group_id}`];
|
||||
case 'direct':
|
||||
return ['direct'];
|
||||
case 'public':
|
||||
|
@ -269,7 +270,7 @@ const importPendingStatus = (state: State, params: APIEntity, idempotencyKey: st
|
|||
const statusId = `末pending-${idempotencyKey}`;
|
||||
|
||||
return state.withMutations(state => {
|
||||
const timelineIds = getTimelinesByVisibility(params.visibility);
|
||||
const timelineIds = getTimelinesForStatus(params);
|
||||
|
||||
timelineIds.forEach(timelineId => {
|
||||
updateTimelineQueue(state, timelineId, statusId);
|
||||
|
@ -293,7 +294,7 @@ const importStatus = (state: State, status: APIEntity, idempotencyKey: string) =
|
|||
return state.withMutations(state => {
|
||||
replacePendingStatus(state, idempotencyKey, status.id);
|
||||
|
||||
const timelineIds = getTimelinesByVisibility(status.visibility);
|
||||
const timelineIds = getTimelinesForStatus(status);
|
||||
|
||||
timelineIds.forEach(timelineId => {
|
||||
updateTimeline(state, timelineId, status.id);
|
||||
|
|
|
@ -40,6 +40,23 @@ import {
|
|||
import {
|
||||
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||
} from 'soapbox/actions/familiar-followers';
|
||||
import {
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL,
|
||||
GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
|
||||
GROUP_BLOCKS_FETCH_REQUEST,
|
||||
GROUP_BLOCKS_FETCH_SUCCESS,
|
||||
GROUP_BLOCKS_FETCH_FAIL,
|
||||
GROUP_BLOCKS_EXPAND_REQUEST,
|
||||
GROUP_BLOCKS_EXPAND_SUCCESS,
|
||||
GROUP_BLOCKS_EXPAND_FAIL,
|
||||
GROUP_UNBLOCK_SUCCESS,
|
||||
} from 'soapbox/actions/groups';
|
||||
import {
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_SUCCESS,
|
||||
|
@ -99,6 +116,8 @@ export const ReducerRecord = ImmutableRecord({
|
|||
familiar_followers: ImmutableMap<string, List>(),
|
||||
event_participations: ImmutableMap<string, List>(),
|
||||
event_participation_requests: ImmutableMap<string, ParticipationRequestList>(),
|
||||
membership_requests: ImmutableMap<string, List>(),
|
||||
group_blocks: ImmutableMap<string, List>(),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
@ -108,7 +127,7 @@ type ReactionList = ReturnType<typeof ReactionListRecord>;
|
|||
type ParticipationRequest = ReturnType<typeof ParticipationRequestRecord>;
|
||||
type ParticipationRequestList = ReturnType<typeof ParticipationRequestListRecord>;
|
||||
type Items = ImmutableOrderedSet<string>;
|
||||
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests', string];
|
||||
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests' | 'membership_requests' | 'group_blocks', string];
|
||||
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
|
||||
|
||||
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
|
||||
|
@ -220,6 +239,31 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) {
|
|||
['event_participation_requests', action.id, 'items'],
|
||||
items => (items as ImmutableOrderedSet<ParticipationRequest>).filter(({ account }) => account !== action.accountId),
|
||||
);
|
||||
case GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['membership_requests', action.id], action.accounts, action.next);
|
||||
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['membership_requests', action.id], action.accounts, action.next);
|
||||
case GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST:
|
||||
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST:
|
||||
return state.setIn(['membership_requests', action.id, 'isLoading'], true);
|
||||
case GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL:
|
||||
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL:
|
||||
return state.setIn(['membership_requests', action.id, 'isLoading'], false);
|
||||
case GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS:
|
||||
case GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS:
|
||||
return state.updateIn(['membership_requests', action.groupId, 'items'], list => (list as ImmutableOrderedSet<string>).filterNot(item => item === action.accountId));
|
||||
case GROUP_BLOCKS_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['group_blocks', action.id], action.accounts, action.next);
|
||||
case GROUP_BLOCKS_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['group_blocks', action.id], action.accounts, action.next);
|
||||
case GROUP_BLOCKS_FETCH_REQUEST:
|
||||
case GROUP_BLOCKS_EXPAND_REQUEST:
|
||||
return state.setIn(['group_blocks', action.id, 'isLoading'], true);
|
||||
case GROUP_BLOCKS_FETCH_FAIL:
|
||||
case GROUP_BLOCKS_EXPAND_FAIL:
|
||||
return state.setIn(['group_blocks', action.id, 'isLoading'], false);
|
||||
case GROUP_UNBLOCK_SUCCESS:
|
||||
return state.updateIn(['group_blocks', action.groupId, 'items'], list => (list as ImmutableOrderedSet<string>).filterNot(item => item === action.accountId));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -144,12 +144,13 @@ export const makeGetStatus = () => {
|
|||
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.groups.items.get(state.statuses.get(id)?.group || ''),
|
||||
(_state: RootState, { username }: APIStatus) => username,
|
||||
getFilters,
|
||||
(state: RootState) => state.me,
|
||||
],
|
||||
|
||||
(statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => {
|
||||
(statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me) => {
|
||||
if (!statusBase || !accountBase) return null;
|
||||
|
||||
const accountUsername = accountBase.acct;
|
||||
|
@ -172,6 +173,8 @@ export const makeGetStatus = () => {
|
|||
map.set('reblog', statusReblog || null);
|
||||
// @ts-ignore :(
|
||||
map.set('account', accountBase || null);
|
||||
// @ts-ignore
|
||||
map.set('group', group || null);
|
||||
map.set('filtered', Boolean(filtered));
|
||||
});
|
||||
},
|
||||
|
@ -215,6 +218,25 @@ export const getAccountGallery = createSelector([
|
|||
}, ImmutableList());
|
||||
});
|
||||
|
||||
export const getGroupGallery = createSelector([
|
||||
(state: RootState, id: string) => state.timelines.get(`group:${id}:media`)?.items || ImmutableOrderedSet<string>(),
|
||||
(state: RootState) => state.statuses,
|
||||
(state: RootState) => state.accounts,
|
||||
], (statusIds, statuses, accounts) => {
|
||||
|
||||
return statusIds.reduce((medias: ImmutableList<any>, statusId: string) => {
|
||||
const status = statuses.get(statusId);
|
||||
if (!status) return medias;
|
||||
if (status.reblog) return medias;
|
||||
if (typeof status.account !== 'string') return medias;
|
||||
|
||||
const account = accounts.get(status.account);
|
||||
|
||||
return medias.concat(
|
||||
status.media_attachments.map(media => media.merge({ status, account })));
|
||||
}, ImmutableList());
|
||||
});
|
||||
|
||||
type APIChat = { id: string, last_message: string };
|
||||
|
||||
export const makeGetChat = () => {
|
||||
|
@ -350,3 +372,16 @@ export const makeGetStatusIds = () => createSelector([
|
|||
return !shouldFilter(status, columnSettings);
|
||||
});
|
||||
});
|
||||
|
||||
export const makeGetGroup = () => {
|
||||
return createSelector([
|
||||
(state: RootState, id: string) => state.groups.items.get(id),
|
||||
(state: RootState, id: string) => state.group_relationships.get(id),
|
||||
], (base, relationship) => {
|
||||
if (!base) return null;
|
||||
|
||||
return base.withMutations(map => {
|
||||
if (relationship) map.set('relationship', relationship);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -11,6 +11,8 @@ import {
|
|||
EmojiRecord,
|
||||
FieldRecord,
|
||||
FilterRecord,
|
||||
GroupRecord,
|
||||
GroupRelationshipRecord,
|
||||
HistoryRecord,
|
||||
InstanceRecord,
|
||||
ListRecord,
|
||||
|
@ -40,6 +42,8 @@ type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
|||
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||
type Field = ReturnType<typeof FieldRecord>;
|
||||
type Filter = ReturnType<typeof FilterRecord>;
|
||||
type Group = ReturnType<typeof GroupRecord>;
|
||||
type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
|
||||
type History = ReturnType<typeof HistoryRecord>;
|
||||
type Instance = ReturnType<typeof InstanceRecord>;
|
||||
type List = ReturnType<typeof ListRecord>;
|
||||
|
@ -82,6 +86,8 @@ export {
|
|||
Emoji,
|
||||
Field,
|
||||
Filter,
|
||||
Group,
|
||||
GroupRelationship,
|
||||
History,
|
||||
Instance,
|
||||
List,
|
||||
|
|
|
@ -463,6 +463,34 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
frontendConfigurations: v.software === PLEROMA,
|
||||
|
||||
/**
|
||||
* Groups.
|
||||
* @see POST /api/v1/groups
|
||||
* @see GET /api/v1/groups
|
||||
* @see GET /api/v1/groups/:id
|
||||
* @see POST /api/v1/groups/:id/join
|
||||
* @see POST /api/v1/groups/:id/leave
|
||||
* @see GET /api/v1/groups/:id/memberships
|
||||
* @see PUT /api/v1/groups/:group_id
|
||||
* @see DELETE /api/v1/groups/:group_id
|
||||
* @see GET /api/v1/groups/:group_id/membership_requests
|
||||
* @see POST /api/v1/groups/:group_id/membership_requests/:account_id/authorize
|
||||
* @see POST /api/v1/groups/:group_id/membership_requests/:account_id/reject
|
||||
* @see DELETE /api/v1/groups/:group_id/statuses/:id
|
||||
* @see POST /api/v1/groups/:group_id/kick?account_ids[]=…
|
||||
* @see GET /api/v1/groups/:group_id/blocks
|
||||
* @see POST /api/v1/groups/:group_id/blocks?account_ids[]=…
|
||||
* @see DELETE /api/v1/groups/:group_id/blocks?account_ids[]=…
|
||||
* @see POST /api/v1/groups/:group_id/promote?role=new_role&account_ids[]=…
|
||||
* @see POST /api/v1/groups/:group_id/demote?role=new_role&account_ids[]=…
|
||||
* @see GET /api/v1/admin/groups
|
||||
* @see GET /api/v1/admin/groups/:group_id
|
||||
* @see POST /api/v1/admin/groups/:group_id/suspend
|
||||
* @see POST /api/v1/admin/groups/:group_id/unsuspend
|
||||
* @see DELETE /api/v1/admin/groups/:group_id
|
||||
*/
|
||||
groups: false,
|
||||
|
||||
/**
|
||||
* Can hide follows/followers lists and counts.
|
||||
* @see PATCH /api/v1/accounts/update_credentials
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
export const PERMISSION_CREATE_GROUPS = 0x0000000000100000;
|
||||
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
|
||||
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
|
||||
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
|
||||
|
||||
type Permission = typeof PERMISSION_CREATE_GROUPS | typeof PERMISSION_INVITE_USERS | typeof PERMISSION_MANAGE_USERS | typeof PERMISSION_MANAGE_REPORTS
|
||||
|
||||
export const hasPermission = (state: RootState, permission: Permission) => {
|
||||
const account = state.accounts_meta.get(state.me as string)!;
|
||||
|
||||
if (!account?.role) return false;
|
||||
|
||||
const permissions = account.getIn(['role', 'permissions']) as number;
|
||||
|
||||
if (!permission) return true;
|
||||
return (permissions & permission) === permission;
|
||||
};
|
|
@ -1,13 +1,14 @@
|
|||
import { RootState } from 'soapbox/store';
|
||||
|
||||
import { PLEROMA, parseVersion } from './features';
|
||||
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Instance } from 'soapbox/types/entities';
|
||||
|
||||
/**
|
||||
* Get the OAuth scopes to use for login & signup.
|
||||
* Mastodon will refuse scopes it doesn't know, so care is needed.
|
||||
*/
|
||||
const getScopes = (state: RootState) => {
|
||||
const instance = state.instance;
|
||||
const getInstanceScopes = (instance: Instance) => {
|
||||
const v = parseVersion(instance.version);
|
||||
|
||||
switch (v.software) {
|
||||
|
@ -18,6 +19,13 @@ const getScopes = (state: RootState) => {
|
|||
}
|
||||
};
|
||||
|
||||
/** Convenience function to get scopes from instance in store. */
|
||||
const getScopes = (state: RootState) => {
|
||||
return getInstanceScopes(state.instance);
|
||||
};
|
||||
|
||||
|
||||
export {
|
||||
getInstanceScopes,
|
||||
getScopes,
|
||||
};
|
|
@ -1,82 +0,0 @@
|
|||
.card {
|
||||
& > a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.account {
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: 1px solid var(--brand-color--med);
|
||||
}
|
||||
}
|
||||
|
||||
.account-gallery__container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.empty-column-indicator {
|
||||
margin: -4px -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-gallery__item {
|
||||
@apply rounded-lg p-1;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
a {
|
||||
background: var(--brand-color--faint);
|
||||
}
|
||||
|
||||
video,
|
||||
img {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
&__icons {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
.svg-icon {
|
||||
@apply h-6 w-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.account__moved-note {
|
||||
padding: 14px 10px;
|
||||
padding-bottom: 16px;
|
||||
background: var(--brand-color--faint);
|
||||
border-top: 1px solid var(--brand-color--med);
|
||||
border-bottom: 1px solid var(--brand-color--med);
|
||||
}
|
||||
|
||||
.account__joined-at {
|
||||
padding-left: 3px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--primary-text-color--faint);
|
||||
|
||||
.svg-icon {
|
||||
padding-right: 3px;
|
||||
}
|
||||
}
|
|
@ -3,7 +3,6 @@
|
|||
@import 'variables';
|
||||
@import 'fonts';
|
||||
@import 'basics';
|
||||
@import 'accounts';
|
||||
@import 'loading';
|
||||
@import 'ui';
|
||||
@import 'emoji-picker';
|
||||
|
|
|
@ -39,9 +39,9 @@ body {
|
|||
}
|
||||
|
||||
&.error {
|
||||
@apply text-gray-400;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
color: var(--primary-text-color--faint);
|
||||
background: var(--brand-color--med);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
@ -83,9 +83,9 @@ noscript {
|
|||
}
|
||||
|
||||
div {
|
||||
@apply text-gray-400;
|
||||
font-size: 14px;
|
||||
margin: 30px auto;
|
||||
color: var(--primary-text-color--faint);
|
||||
max-width: 400px;
|
||||
|
||||
a {
|
||||
|
|
|
@ -25,8 +25,8 @@
|
|||
}
|
||||
|
||||
.column-link {
|
||||
@apply text-gray-900;
|
||||
background: var(--brand-color--med);
|
||||
color: var(--primary-text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
|
|
|
@ -26,9 +26,8 @@
|
|||
}
|
||||
|
||||
&__modifiers {
|
||||
color: var(--primary-text-color);
|
||||
@apply text-gray-900 text-sm;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--background-color);
|
||||
}
|
||||
}
|
||||
|
@ -184,7 +183,7 @@
|
|||
background: var(--brand-color);
|
||||
|
||||
.icon-button {
|
||||
color: var(--primary-text-color);
|
||||
@apply text-gray-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,64 +1,47 @@
|
|||
.crypto-address {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@apply flex flex-col p-5;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
@apply flex items-center mb-1.5;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
margin-right: 10px;
|
||||
@apply flex items-start justify-center w-6 mr-2.5;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
@apply flex ml-auto;
|
||||
|
||||
a {
|
||||
color: var(--primary-text-color--faint);
|
||||
margin-left: 8px;
|
||||
@apply text-gray-400 ml-2;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@apply h-4.5 w-4.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-bottom: 10px;
|
||||
@apply mb-2.5;
|
||||
}
|
||||
|
||||
&__qrcode {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@apply flex items-center justify-center mb-3 p-2.5;
|
||||
}
|
||||
|
||||
&__address {
|
||||
margin-top: auto;
|
||||
@apply mt-auto;
|
||||
}
|
||||
}
|
||||
|
||||
.crypto-donate-modal {
|
||||
.crypto-address {
|
||||
padding: 0;
|
||||
}
|
||||
.crypto-donate-modal .crypto-address {
|
||||
@apply p-0;
|
||||
}
|
||||
|
|
|
@ -51,10 +51,10 @@
|
|||
}
|
||||
|
||||
.media-gallery__item-thumbnail {
|
||||
@apply text-gray-400;
|
||||
cursor: zoom-in;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--primary-text-color--faint);
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
|
|
@ -165,8 +165,8 @@
|
|||
}
|
||||
|
||||
.error-modal {
|
||||
@apply text-gray-900;
|
||||
background: var(--background-color);
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
@ -231,19 +231,16 @@
|
|||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: var(--primary-text-color--faint);
|
||||
@apply text-gray-400;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-modal {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@apply flex-col relative text-gray-400 overflow-hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--background-color);
|
||||
color: var(--primary-text-color--faint);
|
||||
|
||||
.dropdown-menu__separator {
|
||||
@apply block m-2 h-[1px] bg-gray-200 dark:bg-gray-600;
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue