Merge branch 'mastodon-groups' into 'develop'

Mastodon groups

See merge request soapbox-pub/soapbox!1992
This commit is contained in:
marcin mikołajczak 2023-01-28 15:47:13 +00:00
commit 81f92a0231
91 changed files with 4019 additions and 240 deletions

View File

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.

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,

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

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

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

@ -33,6 +33,7 @@ const messages = defineMessages({
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
groups: { id: 'column.groups', defaultMessage: 'Groups' },
events: { id: 'column.events', defaultMessage: 'Events' },
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
@ -207,6 +208,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
)}
{features.groups && (
<SidebarLink
to='/groups'
icon={require('@tabler/icons/circles.svg')}
text={intl.formatMessage(messages.groups)}
onClick={onClose}
/>
)}
{features.lists && (
<SidebarLink
to='/lists'

View File

@ -135,6 +135,14 @@ const SidebarNavigation = () => {
{renderMessagesLink()}
{features.groups && (
<SidebarNavigationLink
to='/groups'
icon={require('@tabler/icons/circles.svg')}
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
/>
)}
<SidebarNavigationLink
to={`/@${account.acct}`}
icon={require('@tabler/icons/user.svg')}

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

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

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

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

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

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

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

@ -8,9 +8,9 @@ import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/intera
import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings';
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
import Account from 'soapbox/components/account';
import Icon from 'soapbox/components/icon';
import { HStack, Text, Emoji } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import StatusContainer from 'soapbox/containers/status-container';
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
import { makeGetNotification } from 'soapbox/selectors';
@ -289,16 +289,16 @@ const Notification: React.FC<INotificaton> = (props) => {
case 'follow':
case 'user_approved':
return account && typeof account === 'object' ? (
<AccountContainer
id={account.id}
<Account
account={account}
hidden={hidden}
avatarSize={avatarSize}
/>
) : null;
case 'follow_request':
return account && typeof account === 'object' ? (
<AccountContainer
id={account.id}
<Account
account={account}
hidden={hidden}
avatarSize={avatarSize}
actionType='follow_request'
@ -306,8 +306,8 @@ const Notification: React.FC<INotificaton> = (props) => {
) : null;
case 'move':
return account && typeof account === 'object' && notification.target && typeof notification.target === 'object' ? (
<AccountContainer
id={notification.target.id}
<Account
account={notification.target}
hidden={hidden}
avatarSize={avatarSize}
/>

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>

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,88 @@
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='flex flex-wrap -m-1'>
{nineAttachments.map((attachment, _index) => (
<MediaItem
key={`${attachment.getIn(['status', 'id'])}+${attachment.id}`}
attachment={attachment}
displayWidth={255}
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,7 +51,7 @@ const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
if (!nineAttachments.isEmpty()) {
return (
<div className='flex flex-wrap'>
<div className='flex flex-wrap -m-1'>
{nineAttachments.map((attachment, _index) => (
<MediaItem
key={`${attachment.getIn(['status', 'id'])}+${attachment.id}`}
@ -74,7 +74,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

@ -319,10 +319,14 @@
"column.follow_requests": "Follow requests",
"column.followers": "Followers",
"column.following": "Following",
"column.group_blocked_members": "Blocked members",
"column.group_pending_requests": "Pending requests",
"column.groups": "Groups",
"column.home": "Home",
"column.import_data": "Import data",
"column.info": "Server information",
"column.lists": "Lists",
"column.manage_group": "Manage group",
"column.mentions": "Mentions",
"column.mfa": "Multi-Factor Authentication",
"column.mfa_cancel": "Cancel",
@ -431,6 +435,9 @@
"confirmations.block.confirm": "Block",
"confirmations.block.heading": "Block @{name}",
"confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.block_from_group.confirm": "Block",
"confirmations.block_from_group.heading": "Block group member",
"confirmations.block_from_group.message": "Are you sure you want to block @{name} from interacting with this group?",
"confirmations.cancel.confirm": "Discard",
"confirmations.cancel.heading": "Discard post",
"confirmations.cancel.message": "Are you sure you want to cancel creating this post?",
@ -445,17 +452,30 @@
"confirmations.delete_event.confirm": "Delete",
"confirmations.delete_event.heading": "Delete event",
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
"confirmations.delete_from_group.heading": "Delete from group",
"confirmations.delete_from_group.message": "Are you sure you want to delete @{name}'s post?",
"confirmations.delete_group.confirm": "Delete",
"confirmations.delete_group.heading": "Delete group",
"confirmations.delete_group.message": "Are you sure you want to delete this group? This is a permanent action that cannot be undone.",
"confirmations.delete_list.confirm": "Delete",
"confirmations.delete_list.heading": "Delete list",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
"confirmations.domain_block.confirm": "Hide entire domain",
"confirmations.domain_block.heading": "Block {domain}",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.",
"confirmations.kick_from_group.confirm": "Kick",
"confirmations.kick_from_group.heading": "Kick group member",
"confirmations.kick_from_group.message": "Are you sure you want to kick @{name} from this group?",
"confirmations.leave_event.confirm": "Leave event",
"confirmations.leave_event.message": "If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?",
"confirmations.leave_group.confirm": "Leave",
"confirmations.leave_group.heading": "Leave group",
"confirmations.leave_group.message": "You are about to leave the group. Do you want to continue?",
"confirmations.mute.confirm": "Mute",
"confirmations.mute.heading": "Mute @{name}",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.promote_in_group.confirm": "Promote",
"confirmations.promote_in_group.message": "Are you sure you want to promote @{name}? You will not be able to demote them.",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.heading": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.",
@ -606,6 +626,9 @@
"empty_column.filters": "You haven't created any muted words yet.",
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
"empty_column.group": "There are no posts in this group yet.",
"empty_column.group_blocks": "The group hasn't blocked any users yet.",
"empty_column.group_membership_requests": "There are no pending membership requests for this group.",
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
"empty_column.home.local_tab": "the {site_title} tab",
@ -621,6 +644,7 @@
"empty_column.remote": "There is nothing here! Manually follow users from {instance} to fill it up.",
"empty_column.scheduled_statuses": "You don't have any scheduled statuses yet. When you add one, it will show up here.",
"empty_column.search.accounts": "There are no people results for \"{term}\"",
"empty_column.search.groups": "There are no groups results for \"{term}\"",
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
"empty_column.test": "The test timeline is empty.",
@ -696,6 +720,42 @@
"gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.",
"gdpr.title": "{siteTitle} uses cookies",
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
"group.admin_subheading": "Group administrators",
"group.cancel_request": "Cancel request",
"group.group_mod_authorize": "Accept",
"group.group_mod_authorize.success": "Accepted @{name} to group",
"group.group_mod_block": "Block @{name} from group",
"group.group_mod_block.success": "Blocked @{name} from group",
"group.group_mod_demote": "Demote @{name}",
"group.group_mod_demote.success": "Demoted @{name} to group user",
"group.group_mod_kick": "Kick @{name} from group",
"group.group_mod_kick.success": "Kicked @{name} from group",
"group.group_mod_promote_admin": "Promote @{name} to group administrator",
"group.group_mod_promote_admin.success": "Promoted @{name} to group administrator",
"group.group_mod_promote_mod": "Promote @{name} to group moderator",
"group.group_mod_promote_mod.success": "Promoted @{name} to group moderator",
"group.group_mod_reject": "Reject",
"group.group_mod_reject.success": "Rejected @{name} from group",
"group.group_mod_unblock": "Unblock",
"group.group_mod_unblock.success": "Unblocked @{name} from group",
"group.header.alt": "Group header",
"group.join": "Join group",
"group.join.request_success": "Requested to join the group",
"group.join.success": "Joined the group",
"group.leave": "Leave group",
"group.leave.success": "Left the group",
"group.manage": "Manage group",
"group.moderator_subheading": "Group moderators",
"group.privacy.locked": "Private",
"group.privacy.public": "Public",
"group.request_join": "Request to join group",
"group.role.admin": "Admin",
"group.role.moderator": "Moderator",
"group.tabs.all": "All",
"group.tabs.members": "Members",
"group.user_subheading": "Users",
"groups.empty.subtitle": "Start discovering groups to join or create your own.",
"groups.empty.title": "No Groups yet",
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
@ -799,6 +859,27 @@
"login_external.errors.instance_fail": "The instance returned an error.",
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
"login_form.header": "Sign In",
"manage_group.blocked_members": "Blocked members",
"manage_group.create": "Create",
"manage_group.delete_group": "Delete group",
"manage_group.edit_group": "Edit group",
"manage_group.edit_success": "The group was edited",
"manage_group.fields.description_label": "Description",
"manage_group.fields.description_placeholder": "Description",
"manage_group.fields.name_label": "Group name (required)",
"manage_group.fields.name_placeholder": "Group Name",
"manage_group.get_started": "Let's get started!",
"manage_group.next": "Next",
"manage_group.pending_requests": "Pending requests",
"manage_group.privacy.hint": "These settings cannot be changed later.",
"manage_group.privacy.label": "Privacy settings",
"manage_group.privacy.private.hint": "Discoverable. Users can join after their request is approved.",
"manage_group.privacy.private.label": "Private (Owner approval required)",
"manage_group.privacy.public.hint": "Discoverable. Anyone can join.",
"manage_group.privacy.public.label": "Public",
"manage_group.submit_success": "The group was created",
"manage_group.tagline": "Groups connect you with others based on shared interests.",
"manage_group.update": "Update",
"media_panel.empty_message": "No media found.",
"media_panel.title": "Media",
"mfa.confirm.success_message": "MFA confirmed",
@ -865,7 +946,9 @@
"navigation_bar.compose_quote": "Quote post",
"navigation_bar.compose_reply": "Reply to post",
"navigation_bar.create_event": "Create new event",
"navigation_bar.create_group": "Create Group",
"navigation_bar.domain_blocks": "Domain blocks",
"navigation_bar.edit_group": "Edit Group",
"navigation_bar.favourites": "Likes",
"navigation_bar.filters": "Filters",
"navigation_bar.follow_requests": "Follow requests",
@ -877,6 +960,9 @@
"navigation_bar.preferences": "Preferences",
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.soapbox_config": "Soapbox config",
"new_group_panel.action": "Create group",
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
"new_group_panel.title": "Create New Group",
"notification.favourite": "{name} liked your post",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
@ -1107,6 +1193,7 @@
"search.placeholder": "Search",
"search_results.accounts": "People",
"search_results.filter_message": "You are searching for posts from @{acct}.",
"search_results.groups": "Groups",
"search_results.hashtags": "Hashtags",
"search_results.statuses": "Posts",
"security.codes.fail": "Failed to fetch backup codes",
@ -1217,6 +1304,8 @@
"sponsored.subtitle": "Sponsored post",
"status.admin_account": "Moderate @{name}",
"status.admin_status": "Open this post in the moderation interface",
"status.approval.pending": "Pending approval",
"status.approval.rejected": "Rejected",
"status.bookmark": "Bookmark",
"status.bookmarked": "Bookmark added.",
"status.cancel_reblog_private": "Un-repost",
@ -1226,11 +1315,16 @@
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
"status.disabled_replies.group_membership": "Only group members can reply",
"status.edit": "Edit",
"status.embed": "Embed post",
"status.external": "View post on {domain}",
"status.favourite": "Like",
"status.filtered": "Filtered",
"status.group": "Posted in {group}",
"status.group_mod_block": "Block @{name} from group",
"status.group_mod_delete": "Delete post from group",
"status.group_mod_kick": "Kick @{name} from group",
"status.interactions.favourites": "{count, plural, one {Like} other {Likes}}",
"status.interactions.quotes": "{count, plural, one {Quote} other {Quotes}}",
"status.interactions.reblogs": "{count, plural, one {Repost} other {Reposts}}",
@ -1291,6 +1385,7 @@
"tabs_bar.all": "All",
"tabs_bar.dashboard": "Dashboard",
"tabs_bar.fediverse": "Fediverse",
"tabs_bar.groups": "Groups",
"tabs_bar.home": "Home",
"tabs_bar.local": "Local",
"tabs_bar.more": "More",

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

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;

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

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

View File

@ -40,6 +40,23 @@ import {
import {
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
} from 'soapbox/actions/familiar-followers';
import {
GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS,
GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS,
GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST,
GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST,
GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL,
GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL,
GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS,
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
GROUP_BLOCKS_FETCH_REQUEST,
GROUP_BLOCKS_FETCH_SUCCESS,
GROUP_BLOCKS_FETCH_FAIL,
GROUP_BLOCKS_EXPAND_REQUEST,
GROUP_BLOCKS_EXPAND_SUCCESS,
GROUP_BLOCKS_EXPAND_FAIL,
GROUP_UNBLOCK_SUCCESS,
} from 'soapbox/actions/groups';
import {
REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS,
@ -99,6 +116,8 @@ export const ReducerRecord = ImmutableRecord({
familiar_followers: ImmutableMap<string, List>(),
event_participations: ImmutableMap<string, List>(),
event_participation_requests: ImmutableMap<string, ParticipationRequestList>(),
membership_requests: ImmutableMap<string, List>(),
group_blocks: ImmutableMap<string, List>(),
});
type State = ReturnType<typeof ReducerRecord>;
@ -108,7 +127,7 @@ type ReactionList = ReturnType<typeof ReactionListRecord>;
type ParticipationRequest = ReturnType<typeof ParticipationRequestRecord>;
type ParticipationRequestList = ReturnType<typeof ParticipationRequestListRecord>;
type Items = ImmutableOrderedSet<string>;
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests', string];
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests' | 'membership_requests' | 'group_blocks', string];
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
@ -220,6 +239,31 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) {
['event_participation_requests', action.id, 'items'],
items => (items as ImmutableOrderedSet<ParticipationRequest>).filter(({ account }) => account !== action.accountId),
);
case GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS:
return normalizeList(state, ['membership_requests', action.id], action.accounts, action.next);
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS:
return appendToList(state, ['membership_requests', action.id], action.accounts, action.next);
case GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST:
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST:
return state.setIn(['membership_requests', action.id, 'isLoading'], true);
case GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL:
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL:
return state.setIn(['membership_requests', action.id, 'isLoading'], false);
case GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS:
case GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS:
return state.updateIn(['membership_requests', action.groupId, 'items'], list => (list as ImmutableOrderedSet<string>).filterNot(item => item === action.accountId));
case GROUP_BLOCKS_FETCH_SUCCESS:
return normalizeList(state, ['group_blocks', action.id], action.accounts, action.next);
case GROUP_BLOCKS_EXPAND_SUCCESS:
return appendToList(state, ['group_blocks', action.id], action.accounts, action.next);
case GROUP_BLOCKS_FETCH_REQUEST:
case GROUP_BLOCKS_EXPAND_REQUEST:
return state.setIn(['group_blocks', action.id, 'isLoading'], true);
case GROUP_BLOCKS_FETCH_FAIL:
case GROUP_BLOCKS_EXPAND_FAIL:
return state.setIn(['group_blocks', action.id, 'isLoading'], false);
case GROUP_UNBLOCK_SUCCESS:
return state.updateIn(['group_blocks', action.groupId, 'items'], list => (list as ImmutableOrderedSet<string>).filterNot(item => item === action.accountId));
default:
return state;
}

View File

@ -144,12 +144,13 @@ export const makeGetStatus = () => {
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''),
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''),
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''),
(state: RootState, { id }: APIStatus) => state.groups.items.get(state.statuses.get(id)?.group || ''),
(_state: RootState, { username }: APIStatus) => username,
getFilters,
(state: RootState) => state.me,
],
(statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => {
(statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me) => {
if (!statusBase || !accountBase) return null;
const accountUsername = accountBase.acct;
@ -172,6 +173,8 @@ export const makeGetStatus = () => {
map.set('reblog', statusReblog || null);
// @ts-ignore :(
map.set('account', accountBase || null);
// @ts-ignore
map.set('group', group || null);
map.set('filtered', Boolean(filtered));
});
},
@ -215,6 +218,25 @@ export const getAccountGallery = createSelector([
}, ImmutableList());
});
export const getGroupGallery = createSelector([
(state: RootState, id: string) => state.timelines.get(`group:${id}:media`)?.items || ImmutableOrderedSet<string>(),
(state: RootState) => state.statuses,
(state: RootState) => state.accounts,
], (statusIds, statuses, accounts) => {
return statusIds.reduce((medias: ImmutableList<any>, statusId: string) => {
const status = statuses.get(statusId);
if (!status) return medias;
if (status.reblog) return medias;
if (typeof status.account !== 'string') return medias;
const account = accounts.get(status.account);
return medias.concat(
status.media_attachments.map(media => media.merge({ status, account })));
}, ImmutableList());
});
type APIChat = { id: string, last_message: string };
export const makeGetChat = () => {
@ -350,3 +372,16 @@ export const makeGetStatusIds = () => createSelector([
return !shouldFilter(status, columnSettings);
});
});
export const makeGetGroup = () => {
return createSelector([
(state: RootState, id: string) => state.groups.items.get(id),
(state: RootState, id: string) => state.group_relationships.get(id),
], (base, relationship) => {
if (!base) return null;
return base.withMutations(map => {
if (relationship) map.set('relationship', relationship);
});
});
};

View File

@ -11,6 +11,8 @@ import {
EmojiRecord,
FieldRecord,
FilterRecord,
GroupRecord,
GroupRelationshipRecord,
HistoryRecord,
InstanceRecord,
ListRecord,
@ -40,6 +42,8 @@ type ChatMessage = ReturnType<typeof ChatMessageRecord>;
type Emoji = ReturnType<typeof EmojiRecord>;
type Field = ReturnType<typeof FieldRecord>;
type Filter = ReturnType<typeof FilterRecord>;
type Group = ReturnType<typeof GroupRecord>;
type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
type History = ReturnType<typeof HistoryRecord>;
type Instance = ReturnType<typeof InstanceRecord>;
type List = ReturnType<typeof ListRecord>;
@ -82,6 +86,8 @@ export {
Emoji,
Field,
Filter,
Group,
GroupRelationship,
History,
Instance,
List,

View File

@ -463,6 +463,34 @@ const getInstanceFeatures = (instance: Instance) => {
*/
frontendConfigurations: v.software === PLEROMA,
/**
* Groups.
* @see POST /api/v1/groups
* @see GET /api/v1/groups
* @see GET /api/v1/groups/:id
* @see POST /api/v1/groups/:id/join
* @see POST /api/v1/groups/:id/leave
* @see GET /api/v1/groups/:id/memberships
* @see PUT /api/v1/groups/:group_id
* @see DELETE /api/v1/groups/:group_id
* @see GET /api/v1/groups/:group_id/membership_requests
* @see POST /api/v1/groups/:group_id/membership_requests/:account_id/authorize
* @see POST /api/v1/groups/:group_id/membership_requests/:account_id/reject
* @see DELETE /api/v1/groups/:group_id/statuses/:id
* @see POST /api/v1/groups/:group_id/kick?account_ids[]=
* @see GET /api/v1/groups/:group_id/blocks
* @see POST /api/v1/groups/:group_id/blocks?account_ids[]=
* @see DELETE /api/v1/groups/:group_id/blocks?account_ids[]=
* @see POST /api/v1/groups/:group_id/promote?role=new_role&account_ids[]=
* @see POST /api/v1/groups/:group_id/demote?role=new_role&account_ids[]=
* @see GET /api/v1/admin/groups
* @see GET /api/v1/admin/groups/:group_id
* @see POST /api/v1/admin/groups/:group_id/suspend
* @see POST /api/v1/admin/groups/:group_id/unsuspend
* @see DELETE /api/v1/admin/groups/:group_id
*/
groups: false,
/**
* Can hide follows/followers lists and counts.
* @see PATCH /api/v1/accounts/update_credentials

View File

@ -0,0 +1,19 @@
import type { RootState } from 'soapbox/store';
export const PERMISSION_CREATE_GROUPS = 0x0000000000100000;
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
type Permission = typeof PERMISSION_CREATE_GROUPS | typeof PERMISSION_INVITE_USERS | typeof PERMISSION_MANAGE_USERS | typeof PERMISSION_MANAGE_REPORTS
export const hasPermission = (state: RootState, permission: Permission) => {
const account = state.accounts_meta.get(state.me as string)!;
if (!account?.role) return false;
const permissions = account.getIn(['role', 'permissions']) as number;
if (!permission) return true;
return (permissions & permission) === permission;
};

View File

@ -69,12 +69,12 @@
}
.account__joined-at {
@apply text-gray-400;
padding-left: 3px;
font-size: 14px;
display: flex;
white-space: nowrap;
flex-shrink: 0;
color: var(--primary-text-color--faint);
.svg-icon {
padding-right: 3px;

View File

@ -39,9 +39,9 @@ body {
}
&.error {
@apply text-gray-400;
position: absolute;
text-align: center;
color: var(--primary-text-color--faint);
background: var(--brand-color--med);
width: 100%;
height: 100%;
@ -83,9 +83,9 @@ noscript {
}
div {
@apply text-gray-400;
font-size: 14px;
margin: 30px auto;
color: var(--primary-text-color--faint);
max-width: 400px;
a {

View File

@ -25,8 +25,8 @@
}
.column-link {
@apply text-gray-900;
background: var(--brand-color--med);
color: var(--primary-text-color);
display: flex;
align-items: center;
font-size: 16px;

View File

@ -26,9 +26,8 @@
}
&__modifiers {
color: var(--primary-text-color);
@apply text-gray-900 text-sm;
font-family: inherit;
font-size: 14px;
background: var(--background-color);
}
}
@ -184,7 +183,7 @@
background: var(--brand-color);
.icon-button {
color: var(--primary-text-color);
@apply text-gray-900;
}
}
}

View File

@ -1,64 +1,47 @@
.crypto-address {
padding: 20px;
display: flex;
flex-direction: column;
@apply flex flex-col p-5;
&__head {
display: flex;
align-items: center;
margin-bottom: 6px;
@apply flex items-center mb-1.5;
}
&__title {
font-weight: bold;
@apply font-bold;
}
&__icon {
display: flex;
align-items: flex-start;
justify-content: center;
width: 24px;
margin-right: 10px;
@apply flex items-start justify-center w-6 mr-2.5;
img {
width: 100%;
@apply w-full;
}
}
&__actions {
margin-left: auto;
display: flex;
@apply flex ml-auto;
a {
color: var(--primary-text-color--faint);
margin-left: 8px;
@apply text-gray-400 ml-2;
}
.svg-icon {
width: 18px;
height: 18px;
@apply h-4.5 w-4.5;
}
}
&__note {
margin-bottom: 10px;
@apply mb-2.5;
}
&__qrcode {
margin-bottom: 12px;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
@apply flex items-center justify-center mb-3 p-2.5;
}
&__address {
margin-top: auto;
@apply mt-auto;
}
}
.crypto-donate-modal {
.crypto-address {
padding: 0;
}
.crypto-donate-modal .crypto-address {
@apply p-0;
}

View File

@ -51,10 +51,10 @@
}
.media-gallery__item-thumbnail {
@apply text-gray-400;
cursor: zoom-in;
display: block;
text-decoration: none;
color: var(--primary-text-color--faint);
line-height: 0;
position: relative;
z-index: 1;

View File

@ -165,8 +165,8 @@
}
.error-modal {
@apply text-gray-900;
background: var(--background-color);
color: var(--primary-text-color);
border-radius: 8px;
overflow: hidden;
display: flex;
@ -231,19 +231,16 @@
&:hover,
&:focus,
&:active {
color: var(--primary-text-color--faint);
@apply text-gray-400;
background-color: var(--background-color);
}
}
}
.actions-modal {
position: relative;
flex-direction: column;
overflow: hidden;
@apply flex-col relative text-gray-400 overflow-hidden;
border-radius: 10px;
border: 1px solid var(--background-color);
color: var(--primary-text-color--faint);
.dropdown-menu__separator {
@apply block m-2 h-[1px] bg-gray-200 dark:bg-gray-600;

View File

@ -13,7 +13,7 @@
}
.svg-icon {
@apply right-4 rtl:left-4 rtl:right-auto;
@apply right-4 rtl:left-4 rtl:right-auto text-gray-400;
@include font-size(16);
cursor: default;
display: inline-block;
@ -23,7 +23,6 @@
z-index: 2;
width: 18px;
height: 18px;
color: var(--primary-text-color--faint);
opacity: 0;
pointer-events: none;
@ -51,9 +50,9 @@
.column {
.search {
@apply border border-solid border-b-gray-900/20;
padding: 10px 15px;
background-color: var(--foreground-color);
border-bottom: 1px solid hsla(var(--primary-text-color_hsl), 0.2);
}
.search__icon .svg-icon {

View File

@ -68,7 +68,7 @@
overflow-y: scroll;
overflow-x: hidden;
height: 270px;
padding: 0 6px 6px 6px;
padding: 0 6px 6px;
will-change: transform; /* avoids "repaints on scroll" in mobile Chrome */
}
@ -101,7 +101,7 @@
.emoji-mart-search input::-webkit-search-results-decoration {
/* remove webkit/blink styles for <input type="search">
* via https://stackoverflow.com/a/9422689 */
-webkit-appearance: none;
appearance: none;
}
.emoji-mart-search-icon {
@ -127,7 +127,7 @@
.emoji-mart-category .emoji-mart-emoji:hover::before {
@apply bg-gray-50 dark:bg-primary-800;
z-index: 0;
content: "";
content: '';
position: absolute;
top: 0;
left: 0;
@ -139,7 +139,6 @@
.emoji-mart-category-label {
z-index: 2;
position: relative;
position: -webkit-sticky;
position: sticky;
top: 0;
}
@ -176,7 +175,7 @@
}
.emoji-mart-emoji-native {
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji", sans-serif;
font-family: 'Segoe UI Emoji', 'Segoe UI Symbol', 'Segoe UI', 'Apple Color Emoji', 'Twemoji Mozilla', 'Noto Color Emoji', 'Android Emoji', sans-serif;
}
.emoji-mart-no-results {

View File

@ -101,7 +101,7 @@ select {
}
.hint {
color: var(--primary-text-color--faint);
@apply text-gray-400;
a {
color: var(--highlight-text-color);
@ -120,8 +120,8 @@ select {
}
p.hint {
@apply text-gray-400;
margin-bottom: 15px;
color: var(--primary-text-color--faint);
&.subtle-hint {
text-align: center;
@ -151,9 +151,9 @@ select {
display: flex;
& > label {
@apply text-gray-900;
font-family: inherit;
font-size: 14px;
color: var(--primary-text-color);
font-weight: 500;
min-width: 150px;
flex: 0 0 auto;
@ -204,10 +204,10 @@ select {
}
.input.radio_buttons .radio label {
@apply text-gray-900;
margin-bottom: 5px;
font-family: inherit;
font-size: 14px;
color: var(--primary-text-color);
display: block;
width: auto;
}
@ -219,8 +219,7 @@ select {
input[type=url],
input[type=password],
textarea {
color: var(--primary-text-color--faint);
border-color: var(--primary-text-color--faint);
@apply text-gray-400 border-gray-400;
}
}
@ -231,17 +230,15 @@ select {
input[type=password],
textarea,
.rfipbtn {
@apply border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-black dark:text-white;
@apply border border-solid border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-black dark:text-white;
box-sizing: border-box;
font-size: 16px;
color: var(--primary-text-color);
display: block;
width: 100%;
outline: 0;
font-family: inherit;
resize: vertical;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid var(--input-border-color);
border-radius: 6px;
padding: 8px 12px;
transition: 0.2s;
@ -280,8 +277,7 @@ select {
input[type=url][disabled],
input[type=password][disabled],
textarea[disabled] {
color: var(--primary-text-color--faint);
border-color: var(--primary-text-color--faint);
@apply text-gray-400 border-gray-400;
}
.input.field_with_errors {
@ -403,6 +399,10 @@ select {
// padding-top: 0.5rem;
// }
.label_input__wrapper {
position: relative;
}
h2 {
font-size: 20px;
line-height: normal;
@ -413,9 +413,9 @@ select {
.simple_form {
.warning {
@apply text-gray-900;
box-sizing: border-box;
background: rgba($error-value-color, 0.5);
color: var(--primary-text-color);
text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);
box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);
border-radius: 4px;
@ -423,13 +423,12 @@ select {
margin-bottom: 15px;
a {
color: var(--primary-text-color);
text-decoration: underline;
@apply text-gray-900 underline;
&:hover,
&:focus,
&:active {
text-decoration: none;
@apply no-underline;
}
}

View File

@ -128,14 +128,7 @@
}
.load-more {
display: block;
color: var(--primary-text-color);
background-color: transparent;
border: 0;
margin: 0;
padding: 15px;
box-sizing: border-box;
width: 100%;
@apply block w-full m-0 p-4 border-0 box-border text-gray-900 bg-transparent;
&:hover,
&:focus {
@ -152,10 +145,10 @@
}
.regeneration-indicator {
@apply text-gray-900;
text-align: center;
font-size: 16px;
font-weight: 500;
color: var(--primary-text-color);
background: var(--accent-color--faint);
cursor: default;
display: flex;
@ -177,9 +170,7 @@
&__label {
strong {
display: block;
margin-bottom: 10px;
color: var(--primary-text-color);
@apply block mb-2.5 text-gray-900;
}
span {

View File

@ -29,18 +29,14 @@ body,
// Primary variables
--brand-color: hsl(var(--brand-color_hsl));
--accent-color: hsl(var(--accent-color_hsl));
--primary-text-color: var(--gray-900);
--background-color: hsl(var(--background-color_hsl));
--foreground-color: hsl(var(--foreground-color_hsl));
--warning-color: hsla(var(--warning-color_hsl));
// Meta-variables
--brand-color_hsl: var(--brand-color_h), var(--brand-color_s), var(--brand-color_l);
--accent-color_hsl: var(--accent-color_h), var(--accent-color_s), var(--accent-color_l);
--primary-text-color_hsl: var(--primary-text-color_h), var(--primary-text-color_s), var(--primary-text-color_l);
--background-color_hsl: var(--background-color_h), var(--background-color_s), var(--background-color_l);
--foreground-color_hsl: var(--foreground-color_h), var(--foreground-color_s), var(--foreground-color_l);
--warning-color_hsl: var(--warning-color_h), var(--warning-color_s), var(--warning-color_l);
--accent-color_h: calc(var(--brand-color_h) - 15);
--accent-color_s: 86%;
--accent-color_l: 44%;
@ -49,23 +45,11 @@ body,
--brand-color--faint: hsla(var(--brand-color_hsl), 0.1);
--brand-color--med: hsla(var(--brand-color_hsl), 0.2);
--accent-color--faint: hsla(var(--accent-color_hsl), 0.15);
--accent-color--med: hsla(var(--accent-color_hsl), 0.25);
--accent-color--bright: hsl(
var(--accent-color_h),
var(--accent-color_s),
calc(var(--accent-color_l) + 3%)
);
--primary-text-color--faint: var(--gray-400);
--warning-color--faint: hsla(var(--warning-color_hsl), 0.5);
// Colors
--gray-900: #08051b;
// --gray-800: #1d1932;
--gray-500: #656175;
--gray-400: #868393;
// Forms
--input-border-color: #d1d5db;
}
.theme-mode-light {
@ -77,18 +61,12 @@ body,
);
// Meta-variables
--primary-text-color_h: 0;
--primary-text-color_s: 0%;
--primary-text-color_l: 0%;
--background-color_h: 0;
--background-color_s: 0%;
--background-color_l: 94.9%;
--foreground-color_h: 0;
--foreground-color_s: 0%;
--foreground-color_l: 100%;
--warning-color_h: 0;
--warning-color_s: 100%;
--warning-color_l: 66%;
// Modifiers
--brand-color--hicontrast: hsl(

View File

@ -3,7 +3,6 @@
display: inline-flex;
align-items: center;
padding: 0;
color: var(--primary-text-color);
border: 0;
background: transparent;
cursor: pointer;
@ -130,20 +129,13 @@
}
.setting-text {
color: var(--primary-text-color--faint);
background: transparent;
border: 0;
border-bottom: 2px solid var(--brand-color);
box-sizing: border-box;
display: block;
@apply block w-full mb-2.5 border-0 border-b-2 border-solid box-border text-gray-400 bg-transparent;
font-family: inherit;
margin-bottom: 10px;
padding: 7px 0;
width: 100%;
&:focus,
&:active {
color: var(--primary-text-color);
@apply text-gray-900;
border-bottom-color: var(--highlight-text-color);
}
@ -172,11 +164,3 @@
30% { opacity: 0.75; }
100% { opacity: 1; }
}
.text-muted {
color: var(--gray-500);
}
.mb-10 {
margin-bottom: 10px;
}

View File

@ -67,7 +67,7 @@
"@sentry/browser": "^7.11.1",
"@sentry/react": "^7.11.1",
"@sentry/tracing": "^7.11.1",
"@tabler/icons": "^1.113.0",
"@tabler/icons": "^1.117.0",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.7",

View File

@ -2362,10 +2362,10 @@
dependencies:
defer-to-connect "^2.0.0"
"@tabler/icons@^1.113.0":
version "1.113.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.113.0.tgz#aeee5f38284d9996abec1bda46c237ef53cde8d4"
integrity sha512-DjxsvR/0HFHD/utQlM+q3wpl1W2n+jgEZkyfkCkc295rCoAfeXHIBfz/9ROrSHkr205Kq/M8KpQR0Nd4kjwODQ==
"@tabler/icons@^1.117.0":
version "1.117.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.117.0.tgz#2ffafca94f868940cf84a839e284c243e095c45a"
integrity sha512-4UGF8fMcROiy++CCNlzTz6p22rxFQD/fAMfaw/8Uanopl41X2SCZTmpnotS3C6Qdrk99m8eMZySa5w1y99gFqQ==
"@tailwindcss/forms@^0.5.3":
version "0.5.3"