Merge: i18n, font fizes, scss, weblate, notifications, login scopes, polyfills, token enhancements.

This commit is contained in:
Ryle 2023-01-31 17:59:33 +00:00
commit 44f609ba16
No known key found for this signature in database
GPG key ID: FAE00B2FEBA78538
126 changed files with 6189 additions and 3474 deletions

View file

@ -54,12 +54,12 @@ module.exports = {
},
},
polyfills: [
'es:all',
'fetch',
'IntersectionObserver',
'Promise',
'URL',
'URLSearchParams',
'es:all', // core-js
'IntersectionObserver', // npm:intersection-observer
'Promise', // core-js
'ResizeObserver', // npm:resize-observer-polyfill
'URL', // core-js
'URLSearchParams', // core-js
],
},

View file

@ -1 +1,4 @@
app/styles/emoji-picker.scss
app/styles/basics.scss
app/styles/forms.scss
app/styles/loading.scss

View file

@ -10,10 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Admin: redirect the homepage to any URL.
- Compatibility: added compatibility with Friendica.
- Posts: bot badge on statuses from bot accounts.
- Compatibility: improved browser support for older browsers.
- Events: allow to repost events in event menu.
- Groups: Initial support for groups.
### Changed
- Chats: improved display of media attachments.
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
- 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.

View file

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

View file

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

View file

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

View file

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

View file

@ -50,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL';
const fetchOAuthTokens = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FETCH_TOKENS_REQUEST });
return api(getState).get('/api/oauth_tokens.json').then(({ data: tokens }) => {
return api(getState).get('/api/oauth_tokens').then(({ data: tokens }) => {
dispatch({ type: FETCH_TOKENS_SUCCESS, tokens });
}).catch(() => {
dispatch({ type: FETCH_TOKENS_FAIL });

View file

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

View file

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

View file

@ -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'>&middot;</Text>
<Text tag='span' theme='muted' size='sm'>
{approvalStatus === 'pending'
? <FormattedMessage id='status.approval.pending' defaultMessage='Pending approval' />
: <FormattedMessage id='status.approval.rejected' defaultMessage='Rejected' />}
</Text>
</>
)}
{showEdit ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>

View file

@ -157,7 +157,8 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
if (lastTokenUpdated && !valueUpdated) {
return false;
} else {
return super.shouldComponentUpdate!(nextProps, nextState, undefined);
// https://stackoverflow.com/a/35962835
return super.shouldComponentUpdate!.bind(this)(nextProps, nextState, undefined);
}
}

View file

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

View file

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

View file

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

View file

@ -248,10 +248,9 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
<div
role='dialog'
className={classNames({
'my-2 mx-auto relative pointer-events-none flex items-center': true,
'my-2 mx-auto relative pointer-events-none flex items-center min-h-[calc(100%-3.5rem)]': true,
'p-4 md:p-0': type !== 'MEDIA',
})}
style={{ minHeight: 'calc(100% - 3.5rem)' }}
>
{children}
</div>

View file

@ -31,6 +31,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' },
@ -206,6 +207,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'

View file

@ -306,6 +306,14 @@ const SidebarNavigation = () => {
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
/>
{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')}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
@ -24,9 +25,9 @@ const Ad: React.FC<IAd> = ({ ad }) => {
// Fetch the impression URL (if any) upon displaying the ad.
// Don't fetch it more than once.
useQuery(['ads', 'impression', ad.impression], () => {
useQuery(['ads', 'impression', ad.impression], async () => {
if (ad.impression) {
return fetch(ad.impression);
return await axios.get(ad.impression);
}
}, { cacheTime: Infinity, staleTime: Infinity });

View file

@ -1,3 +1,5 @@
import axios from 'axios';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { normalizeAd, normalizeCard } from 'soapbox/normalizers';
@ -28,14 +30,13 @@ const RumbleAdProvider: AdProvider = {
const endpoint = soapboxConfig.extensions.getIn(['ads', 'endpoint']) as string | undefined;
if (endpoint) {
const response = await fetch(endpoint, {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
},
});
try {
const { data } = await axios.get<RumbleApiResponse>(endpoint, {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
},
});
if (response.ok) {
const data = await response.json() as RumbleApiResponse;
return data.ads.map(item => normalizeAd({
impression: item.impression,
card: normalizeCard({
@ -45,6 +46,8 @@ const RumbleAdProvider: AdProvider = {
}),
expires_at: new Date(item.expires * 1000),
}));
} catch (e) {
// do nothing
}
}

View file

@ -1,3 +1,5 @@
import axios from 'axios';
import { getSettings } from 'soapbox/actions/settings';
import { normalizeCard } from 'soapbox/normalizers';
@ -18,18 +20,19 @@ const TruthAdProvider: AdProvider = {
const state = getState();
const settings = getSettings(state);
const response = await fetch('/api/v2/truth/ads?device=desktop', {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
},
});
try {
const { data } = await axios.get<TruthAd[]>('/api/v2/truth/ads?device=desktop', {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
},
});
if (response.ok) {
const data = await response.json() as TruthAd[];
return data.map(item => ({
...item,
card: normalizeCard(item.card),
}));
} catch (e) {
// do nothing
}
return [];

View file

@ -45,17 +45,19 @@ const AuthToken: React.FC<IAuthToken> = ({ token, isCurrent }) => {
<Stack space={2}>
<Stack>
<Text size='md' weight='medium'>{token.app_name}</Text>
<Text size='sm' theme='muted'>
<FormattedDate
value={new Date(token.valid_until)}
hour12
year='numeric'
month='short'
day='2-digit'
hour='numeric'
minute='2-digit'
/>
</Text>
{token.valid_until && (
<Text size='sm' theme='muted'>
<FormattedDate
value={token.valid_until}
hour12
year='numeric'
month='short'
day='2-digit'
hour='numeric'
minute='2-digit'
/>
</Text>
)}
</Stack>
<div className='flex justify-end'>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,7 +50,7 @@ describe('<UI />', () => {
await waitFor(() => {
expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss');
}, {
timeout: 2000,
timeout: 5000,
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 />
) : (

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import ResizeObserver from 'resize-observer-polyfill';
type UseDimensionsRect = { width: number, height: number };
type UseDimensionsResult = [Element | null, any, any]

File diff suppressed because it is too large Load diff

View file

@ -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",
@ -429,6 +433,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?",
@ -443,17 +450,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.",
@ -604,6 +624,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",
@ -619,6 +642,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.",
@ -637,7 +661,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",
@ -691,6 +718,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}",
@ -794,6 +857,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",
@ -858,7 +942,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",
@ -870,6 +956,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.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned you",
@ -1096,6 +1185,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",
@ -1206,6 +1296,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",
@ -1215,11 +1307,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}}",
@ -1278,6 +1375,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",

View file

@ -3,7 +3,7 @@
"accordion.collapse": "Colapsar",
"accordion.expand": "Expandir",
"account.add_or_remove_from_list": "Agregar o eliminar de listas",
"account.badges.bot": "Bot",
"account.badges.bot": "Robot",
"account.birthday": "Nació {date}",
"account.birthday_today": "¡Hoy cumple años!",
"account.block": "Bloquear a @{name}",
@ -18,7 +18,7 @@
"account.endorse.success": "Ahora estás presentando a @{acct} en tu perfil",
"account.familiar_followers": "Seguido por {accounts}",
"account.familiar_followers.empty": "Ninguno de tus conocidos sigue a {name}.",
"account.familiar_followers.more": "{count} {count, plural, one {otr*} other {otr*s}} a quien(es) sigues",
"account.familiar_followers.more": "{count, plural, uno {# otro} other {# otros}} que sigues",
"account.follow": "Seguir",
"account.followers": "Seguidores",
"account.followers.empty": "Todavía nadie sigue a este usuario.",
@ -78,7 +78,7 @@
"account_moderation_modal.roles.user": "User",
"account_moderation_modal.title": "Moderar a @{acct}",
"account_note.hint": "Puedes guardar notas sobre este usuario para ti (no se compartirán con él):",
"account_note.placeholder": "No se proporcionó ningún comentario.",
"account_note.placeholder": "No se proporcionó ningún comentario",
"account_note.save": "Guardar",
"account_note.target": "Note for @{target}",
"account_search.placeholder": "Buscar una cuenta",
@ -102,7 +102,7 @@
"admin.dashcounters.user_count_label": "usuarios totales",
"admin.dashwidgets.email_list_header": "Email list",
"admin.dashwidgets.software_header": "Software",
"admin.latest_accounts_panel.more": "Haz clic para ver {count} {count, plural, one {cuenta} other {cuentas}}",
"admin.latest_accounts_panel.more": "Haga clic para ver {contar, plural, una {# cuenta} other {# cuentas}}",
"admin.latest_accounts_panel.title": "Cuentas más recientes",
"admin.moderation_log.empty_message": "Aún no has realizado ninguna acción de moderación. Cuando lo hagas, se mostrará un historial aquí.",
"admin.reports.actions.close": "Cerrar",
@ -111,7 +111,7 @@
"admin.reports.report_closed_message": "El reporte sobre @{name} fue cerrado",
"admin.reports.report_title": "Reporte sobre {acct}",
"admin.software.backend": "Backend",
"admin.software.frontend": "Frontend",
"admin.software.frontend": "Interfaz",
"admin.statuses.actions.delete_status": "Eliminar publicación",
"admin.statuses.actions.mark_status_not_sensitive": "Marcar publicación como no sensible",
"admin.statuses.actions.mark_status_sensitive": "Marcar publicación como sensible",
@ -139,7 +139,7 @@
"admin_nav.awaiting_approval": "Awaiting Approval",
"admin_nav.dashboard": "Dashboard",
"admin_nav.reports": "Reports",
"age_verification.body": "{siteTitle} requiere que los usuarios tengan al menos {ageMinimum} años para acceder a su plataforma. Cualquier persona menor de {ageMinimum} años no puede acceder a esta plataforma.",
"age_verification.body": "{siteTitle} requiere que los usuarios tengan al menos {ageMinimum, plural, one {# año} other {# años}} años de edad para acceder a su plataforma. Cualquier persona menor de {ageMinimum, plural, one {# año} other {# años}} no puede acceder a esta plataforma.",
"age_verification.fail": "Debes tener {ageMinimum, plural, one {# año} other {# años}} de edad o más.",
"age_verification.header": "Introduce tu fecha de nacimiento",
"alert.unexpected.body": "Lamentamos la interrupción. Si el problema persiste, póngase en contacto con nuestro equipo de soporte. También puedes intentar {clearCookies} (esto cerrará su sesión).",
@ -215,7 +215,7 @@
"chat_message_list.network_failure.title": "¡Ups!",
"chat_message_list_intro.actions.accept": "Aceptar",
"chat_message_list_intro.actions.leave_chat": "Abandonar el chat",
"chat_message_list_intro.actions.message_lifespan": "Los mensajes con más de {day} días de edad fueron borrados.",
"chat_message_list_intro.actions.message_lifespan": "Los mas viejos que {day, plural, one {# día} other {# días}} fueron borrados.",
"chat_message_list_intro.actions.report": "Reportar",
"chat_message_list_intro.intro": "quiere empezar una conversación contigo",
"chat_message_list_intro.leave_chat.confirm": "Abandonar el chat",
@ -233,7 +233,7 @@
"chat_settings.auto_delete.30days": "30 días",
"chat_settings.auto_delete.7days": "7 días",
"chat_settings.auto_delete.90days": "90 días",
"chat_settings.auto_delete.days": "{day} días",
"chat_settings.auto_delete.days": "{day, plural, one {# día} other {# días}}",
"chat_settings.auto_delete.hint": "Los mensajes envíados se autoeliminarán después del período de tiempo seleccionado",
"chat_settings.auto_delete.label": "Autoeliminar mensajes",
"chat_settings.block.confirm": "Bloquear",
@ -250,8 +250,8 @@
"chat_settings.unblock.confirm": "Desbloquear",
"chat_settings.unblock.heading": "Desbloquear a @{acct}",
"chat_settings.unblock.message": "Desbloquear este perfil permitirá que te envíe mensajes directos y vea tu contenido.",
"chat_window.auto_delete_label": "Autoeliminar después de {day} días",
"chat_window.auto_delete_tooltip": "Los mensajes del chat están configurados para autoeliminarse después de {day} días de su envío.",
"chat_window.auto_delete_label": "Autoeliminar después de {day, plural, one {# día} other {# días}}",
"chat_window.auto_delete_tooltip": "Los mensajes del chat están configurados para autoeliminarse después de {day, plural, one {# día} other {# días} de su envío.",
"chats.actions.copy": "Copiar",
"chats.actions.delete": "Delete message",
"chats.actions.deleteForMe": "Eliminar para mi",
@ -278,13 +278,13 @@
"column.aliases.subheading_aliases": "Alias actuales",
"column.app_create": "Crear aplicación",
"column.backups": "Copias de seguridad",
"column.birthdays": "Birthdays",
"column.birthdays": "Cumpleaños",
"column.blocks": "Usuarios bloqueados",
"column.bookmarks": "Bookmarks",
"column.bookmarks": "Marcadores",
"column.chats": "Chats",
"column.community": "Línea de tiempo local",
"column.crypto_donate": "Donate Cryptocurrency",
"column.developers": "Developers",
"column.crypto_donate": "Donar con criptomonedas",
"column.developers": "Desarrolladores",
"column.developers.service_worker": "Service Worker",
"column.direct": "Mensajes directos",
"column.directory": "Browse profiles",
@ -348,7 +348,7 @@
"common.cancel": "Cancel",
"common.error": "Something isn't right. Try reloading the page.",
"compare_history_modal.header": "Edit history",
"compose.character_counter.title": "Used {chars} out of {maxChars} characters",
"compose.character_counter.title": "Usó {chars} de {maxChars} {maxChars, plural, one {carácter} other {caracteres}}",
"compose.edit_success": "Your post was edited",
"compose.invalid_schedule": "You must schedule a post at least 5 minutes out.",
"compose.submit_success": "¡Tu publicación fue enviada!",
@ -476,7 +476,7 @@
"confirmations.unfollow.confirm": "Dejar de seguir",
"crypto_donate.explanation_box.message": "{siteTitle} accepts cryptocurrency donations. You may send a donation to any of the addresses below. Thank you for your support!",
"crypto_donate.explanation_box.title": "Sending cryptocurrency donations",
"crypto_donate_panel.actions.view": "Click to see {count} {count, plural, one {wallet} other {wallets}}",
"crypto_donate_panel.actions.view": "Haga clic para ver {contar, plural, una {# cartera} other {# carteras}}",
"crypto_donate_panel.heading": "Donate Cryptocurrency",
"crypto_donate_panel.intro.message": "{siteTitle} accepts cryptocurrency donations to fund our service. Thank you for your support!",
"datepicker.day": "Day",
@ -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",
@ -881,7 +884,7 @@
"notification.mentioned": "{name} mentioned you",
"notification.move": "{name} moved to {targetName}",
"notification.name": "{link}{others}",
"notification.others": " + {count} {count, plural, one {other} other {others}}",
"notification.others": " + {count, plural, one {# otro} other {# otros}}",
"notification.pleroma:chat_mention": "{name} sent you a message",
"notification.pleroma:emoji_reaction": "{name} reacted to your post",
"notification.pleroma:event_reminder": "Un evento en el que participas empezará pronto",
@ -900,7 +903,7 @@
"notifications.filter.mentions": "Menciones",
"notifications.filter.polls": "Resultados de la votación",
"notifications.filter.statuses": "Updates from people you follow",
"notifications.group": "{count} notificaciones",
"notifications.group": "{count, plural, one {# notificación} other {# notificaciones}}",
"notifications.queue_label": "Click to see {count} new {count, plural, one {notification} other {notifications}}",
"oauth_consumer.tooltip": "Sign in with {provider}",
"oauth_consumers.title": "Other ways to sign in",
@ -1325,7 +1328,7 @@
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "Límite de subida de archivos excedido.",
"upload_error.poll": "Subida de archivos no permitida con encuestas.",
"upload_error.video_duration_limit": "Video exceeds the current duration limit ({limit} seconds)",
"upload_error.video_duration_limit": "El vídeo supera el límite de duración actual ({limit, plural, un {# segundo} other {# segundos}})",
"upload_error.video_size_limit": "Video exceeds the current file size limit ({limit})",
"upload_form.description": "Describir para los usuarios con dificultad visual",
"upload_form.preview": "Preview",

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@
"account.endorse.success": "Sada na svom profilu ističete @{acct}",
"account.familiar_followers": "Prate {accounts}",
"account.familiar_followers.empty": "Nitko koga poznajete ne prati {name}.",
"account.familiar_followers.more": "{count} {count, plural, one {other} other {others}} you follow",
"account.familiar_followers.more": "{count} {count, plural, one {druge} other {druge}} koje pratite",
"account.follow": "Počni pratiti",
"account.followers": "Osoba koje prate",
"account.followers.empty": "Još nitko ne prati ovog korisnika.",
@ -102,7 +102,7 @@
"admin.dashcounters.user_count_label": "ukupno korisnika",
"admin.dashwidgets.email_list_header": "Email list",
"admin.dashwidgets.software_header": "Software",
"admin.latest_accounts_panel.more": "Kliknite da vidite {count} {count, plural, one {account} other {accounts}}",
"admin.latest_accounts_panel.more": "Kliknite da vidite {count, plural, one {# korisnika} other {# korisnike}}",
"admin.latest_accounts_panel.title": "Najnoviji korisnici",
"admin.moderation_log.empty_message": "Još niste izvršili nijednu radnju moderiranja. Kada to učinite, ovdje će se prikazati povijest.",
"admin.reports.actions.close": "Zatvoriti",
@ -139,7 +139,7 @@
"admin_nav.awaiting_approval": "Awaiting Approval",
"admin_nav.dashboard": "Dashboard",
"admin_nav.reports": "Reports",
"age_verification.body": "{siteTitle} zahtijeva da korisnici imaju najmanje {ageMinimum} godina da bi pristupili platformi. Svatko mlađi od {ageMinimum} godina ne može pristupiti ovoj platformi.",
"age_verification.body": "{siteTitle} zahtijeva da korisnici imaju najmanje {ageMinimum, plural, one {# godina} other {# godina}} godina da bi pristupili ovoj platformi. Svatko mlađi od {ageMinimum, plural, one {# godina} other {# godina}} starosti ne može pristupiti ovoj platformi.",
"age_verification.fail": "Morate imati {ageMinimum, plural, one {# godina} other {# godina}} ili više.",
"age_verification.header": "Unesite svoj datum rođenja",
"alert.unexpected.body": "Ispričavamo se zbog prekida. Ako se problem nastavi, obratite se našem timu za podršku. Također možete pokušati {clearCookies} (ovo će vas odjaviti).",
@ -214,7 +214,7 @@
"chat_message_list.network_failure.title": "Ups!",
"chat_message_list_intro.actions.accept": "Prihvatiti",
"chat_message_list_intro.actions.leave_chat": "Napusti razgovor",
"chat_message_list_intro.actions.message_lifespan": "Poruke starije od {day} dana se brišu.",
"chat_message_list_intro.actions.message_lifespan": "Poruke starije od {day, plural, one {# dana} other {# dana}} se brišu.",
"chat_message_list_intro.intro": "želi započeti razgovor s vama",
"chat_message_list_intro.leave_chat.confirm": "Napusti razgovor",
"chat_message_list_intro.leave_chat.heading": "Napusti razgovor",
@ -231,7 +231,7 @@
"chat_settings.auto_delete.30days": "30 dana",
"chat_settings.auto_delete.7days": "7 dana",
"chat_settings.auto_delete.90days": "90 dana",
"chat_settings.auto_delete.days": "{day} dana",
"chat_settings.auto_delete.days": "{day, plural, one {# dana} other {# dana}}",
"chat_settings.auto_delete.hint": "Poslane poruke će se automatski izbrisati nakon odabranog vremenskog razdoblja",
"chat_settings.auto_delete.label": "Automatsko brisanje poruka",
"chat_settings.block.confirm": "Blokiraj",
@ -248,8 +248,8 @@
"chat_settings.unblock.confirm": "Ukloni blokadu",
"chat_settings.unblock.heading": "Ukloni blokadu @{acct}",
"chat_settings.unblock.message": "Uklanjanje blokade će omogućiti ovom profilu da vam šalje poruke i pregledava vaš sadržaj.",
"chat_window.auto_delete_label": "Automatsko brisanje nakon {day} dana",
"chat_window.auto_delete_tooltip": "Poruke razgovora postavljene su na automatsko brisanje {day} dana nakon slanja.",
"chat_window.auto_delete_label": "Automatsko brisanje nakon {day, plural, one {# dana} other {# dana}}",
"chat_window.auto_delete_tooltip": "Poruke razgovora postavljene su na automatsko brisanje nakon {day, plural, one {# dana} other {# dana}} nakon slanja.",
"chats.actions.copy": "Kopiraj",
"chats.actions.delete": "Izbriši poruku",
"chats.actions.deleteForMe": "Izbriši za mene",
@ -267,23 +267,23 @@
"column.admin.moderation_log": "Moderation Log",
"column.admin.reports": "Reports",
"column.admin.reports.menu.moderation_log": "Moderation Log",
"column.admin.users": "Users",
"column.admin.users": "Korisnici",
"column.aliases": "Pseudonimi računa",
"column.aliases.create_error": "Error creating alias",
"column.aliases.create_error": "Pogreška pri stvaranju pseudonima",
"column.aliases.delete": "Delete",
"column.aliases.delete_error": "Error deleting alias",
"column.aliases.delete_error": "Pogreška pri brisanju pseudonima",
"column.aliases.subheading_add_new": "Dodaj novi pseudonim",
"column.aliases.subheading_aliases": "Current aliases",
"column.app_create": "Create app",
"column.aliases.subheading_aliases": "Trenutni pseudonimi",
"column.app_create": "Izradi aplikaciju",
"column.backups": "Sigurnosne kopije",
"column.birthdays": "Birthdays",
"column.birthdays": "Rođendani",
"column.blocks": "Blokirani korisnici",
"column.bookmarks": "Knjižne oznake",
"column.chats": "Razgovori",
"column.community": "Lokalni timeline",
"column.crypto_donate": "Donate Cryptocurrency",
"column.developers": "Developers",
"column.developers.service_worker": "Service Worker",
"column.crypto_donate": "Donirajte kriptovalutu",
"column.developers": "Programeri",
"column.developers.service_worker": "Uslužni radnik",
"column.direct": "Izravna poruka",
"column.directory": "Pregledajte profile",
"column.domain_blocks": "Skrivene domene",
@ -295,33 +295,33 @@
"column.familiar_followers": "Osobe koje poznajete slijede {name}",
"column.favourited_statuses": "Oznake „sviđa mi se”",
"column.favourites": "Sviđa mi se",
"column.federation_restrictions": "Federation Restrictions",
"column.federation_restrictions": "Ograničenja Federacije",
"column.filters": "Utišane riječi",
"column.filters.add_new": "Dodaj novi filtar",
"column.filters.conversations": "Conversations",
"column.filters.create_error": "Error adding filter",
"column.filters.conversations": "Razgovori",
"column.filters.create_error": "Pogreška pri dodavanju filtra",
"column.filters.delete": "Delete",
"column.filters.delete_error": "Error deleting filter",
"column.filters.delete_error": "Pogreška pri brisanju filtra",
"column.filters.drop_header": "Ispustite umjesto da sakrijete",
"column.filters.drop_hint": "Filtrirani postovi nepovratno će nestati, čak i ako se filter kasnije ukloni",
"column.filters.expires": "Expire after",
"column.filters.expires_hint": "Expiration dates are not currently supported",
"column.filters.expires": "Istječe nakon",
"column.filters.expires_hint": "Datumi isteka trenutno nisu podržani",
"column.filters.home_timeline": "Početna vremenska linija",
"column.filters.keyword": "Keyword or phrase",
"column.filters.keyword": "Ključna riječ ili izraz",
"column.filters.notifications": "Obavijesti",
"column.filters.public_timeline": "Javna vremenska linija",
"column.filters.subheading_add_new": "Dodaj novi filtar",
"column.filters.subheading_filters": "Current Filters",
"column.filters.subheading_filters": "Trenutni filtri",
"column.filters.whole_word_header": "Cijela riječ",
"column.filters.whole_word_hint": "Kada je ključna riječ ili fraza samo alfanumerička, primijenit će se samo ako odgovara cijeloj riječi",
"column.follow_requests": "Zahtjevi za slijeđenje",
"column.followers": "Osoba koje prate",
"column.following": "Following",
"column.following": "Pratim",
"column.home": "Naslovnica",
"column.import_data": "Uvoz podataka",
"column.info": "Informacije o poslužitelju",
"column.lists": "Lists",
"column.mentions": "Mentions",
"column.lists": "Popisi",
"column.mentions": "Spominjanja",
"column.mfa": "Autentikacija s više faktora",
"column.mfa_cancel": "Otkaži",
"column.mfa_confirm_button": "Potvrdi",
@ -331,24 +331,24 @@
"column.mutes": "Utišani korisnici",
"column.notifications": "Obavijesti",
"column.pins": "Zakačene obavjesti",
"column.preferences": "Preferences",
"column.preferences": "Postavke",
"column.public": "Federalni timeline",
"column.quotes": "Objavite citate",
"column.reactions": "Reactions",
"column.reblogs": "Reposts",
"column.scheduled_statuses": "Scheduled Posts",
"column.search": "Search",
"column.settings_store": "Settings store",
"column.soapbox_config": "Soapbox config",
"column.reactions": "Reakcije",
"column.reblogs": "Proslijeđene obavijesti",
"column.scheduled_statuses": "Zakazane objave",
"column.search": "Traži",
"column.settings_store": "Postavke trgovine",
"column.soapbox_config": "Soapbox postavke",
"column.test": "Test timeline",
"column_forbidden.body": "You do not have permission to access this page.",
"column_forbidden.title": "Forbidden",
"common.cancel": "Otkaži",
"common.error": "Something isn't right. Try reloading the page.",
"compare_history_modal.header": "Edit history",
"compose.character_counter.title": "Used {chars} out of {maxChars} characters",
"compose.edit_success": "Your post was edited",
"compose.invalid_schedule": "You must schedule a post at least 5 minutes out.",
"compose.character_counter.title": "Iskorišteno {chars} od {maxChars} {maxChars, plural, one {znakova} other {znakova}}",
"compose.edit_success": "Vaša objava je uređena",
"compose.invalid_schedule": "Morate zakazati objavu najmanje 5 minuta.",
"compose.submit_success": "Vaša objava je poslana!",
"compose_event.create": "Stvoriti",
"compose_event.edit_success": "Vaš događaj je uređen",
@ -1205,7 +1205,7 @@
"soapbox_config.saved": "Soapbox config saved!",
"soapbox_config.tile_server_attribution_label": "Atribucija pločica karte",
"soapbox_config.tile_server_label": "Poslužitelj pločica karte",
"soapbox_config.verified_can_edit_name_label": "Allow verified users to edit their own display name.",
"soapbox_config.verified_can_edit_name_label": "Dopustite potvrđenim korisnicima da uređuju vlastito ime za prikaz.",
"sponsored.info.message": "{siteTitle} displays ads to help fund our service.",
"sponsored.info.title": "Why am I seeing this ad?",
"sponsored.subtitle": "Sponsored post",

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import './polyfills';
import * as OfflinePluginRuntime from '@lcdp/offline-plugin/runtime';
import React from 'react';
import { createRoot } from 'react-dom/client';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
app/soapbox/polyfills.ts Normal file
View file

@ -0,0 +1,8 @@
import 'intersection-observer';
import ResizeObserver from 'resize-observer-polyfill';
// Needed by Virtuoso
// https://github.com/petyosi/react-virtuoso#browser-support
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserver;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more