Merge branch 'mastodon-groups' into 'develop'
Mastodon groups See merge request soapbox-pub/soapbox!1992
This commit is contained in:
commit
81f92a0231
|
@ -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.
|
||||
|
|
|
@ -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
|
@ -5,42 +5,44 @@ import type { APIEntity } from 'soapbox/types/entities';
|
|||
|
||||
const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
|
||||
const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
|
||||
const GROUP_IMPORT = 'GROUP_IMPORT';
|
||||
const GROUPS_IMPORT = 'GROUPS_IMPORT';
|
||||
const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||
const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP';
|
||||
|
||||
export function importAccount(account: APIEntity) {
|
||||
return { type: ACCOUNT_IMPORT, account };
|
||||
}
|
||||
const importAccount = (account: APIEntity) =>
|
||||
({ type: ACCOUNT_IMPORT, account });
|
||||
|
||||
export function importAccounts(accounts: APIEntity[]) {
|
||||
return { type: ACCOUNTS_IMPORT, accounts };
|
||||
}
|
||||
const importAccounts = (accounts: APIEntity[]) =>
|
||||
({ type: ACCOUNTS_IMPORT, accounts });
|
||||
|
||||
export function importStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importGroup = (group: APIEntity) =>
|
||||
({ type: GROUP_IMPORT, group });
|
||||
|
||||
const importGroups = (groups: APIEntity[]) =>
|
||||
({ type: GROUPS_IMPORT, groups });
|
||||
|
||||
const importStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
|
||||
};
|
||||
}
|
||||
|
||||
export function importStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
|
||||
};
|
||||
}
|
||||
|
||||
export function importPolls(polls: APIEntity[]) {
|
||||
return { type: POLLS_IMPORT, polls };
|
||||
}
|
||||
const importPolls = (polls: APIEntity[]) =>
|
||||
({ type: POLLS_IMPORT, polls });
|
||||
|
||||
export function importFetchedAccount(account: APIEntity) {
|
||||
return importFetchedAccounts([account]);
|
||||
}
|
||||
const importFetchedAccount = (account: APIEntity) =>
|
||||
importFetchedAccounts([account]);
|
||||
|
||||
export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) {
|
||||
const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => {
|
||||
const { should_refetch } = args;
|
||||
const normalAccounts: APIEntity[] = [];
|
||||
|
||||
|
@ -61,10 +63,27 @@ export function importFetchedAccounts(accounts: APIEntity[], args = { should_ref
|
|||
accounts.forEach(processAccount);
|
||||
|
||||
return importAccounts(normalAccounts);
|
||||
}
|
||||
};
|
||||
|
||||
export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const importFetchedGroup = (group: APIEntity) =>
|
||||
importFetchedGroups([group]);
|
||||
|
||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||
const normalGroups: APIEntity[] = [];
|
||||
|
||||
const processGroup = (group: APIEntity) => {
|
||||
if (!group.id) return;
|
||||
|
||||
normalGroups.push(group);
|
||||
};
|
||||
|
||||
groups.forEach(processGroup);
|
||||
|
||||
return importGroups(normalGroups);
|
||||
};
|
||||
|
||||
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
// Skip broken statuses
|
||||
if (isBroken(status)) return;
|
||||
|
||||
|
@ -96,10 +115,13 @@ export function importFetchedStatus(status: APIEntity, idempotencyKey?: string)
|
|||
dispatch(importFetchedPoll(status.poll));
|
||||
}
|
||||
|
||||
if (status.group?.id) {
|
||||
dispatch(importFetchedGroup(status.group));
|
||||
}
|
||||
|
||||
dispatch(importFetchedAccount(status.account));
|
||||
dispatch(importStatus(status, idempotencyKey));
|
||||
};
|
||||
}
|
||||
|
||||
// Sometimes Pleroma can return an empty account,
|
||||
// or a repost can appear of a deleted account. Skip these statuses.
|
||||
|
@ -117,8 +139,8 @@ const isBroken = (status: APIEntity) => {
|
|||
}
|
||||
};
|
||||
|
||||
export function importFetchedStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importFetchedStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const accounts: APIEntity[] = [];
|
||||
const normalStatuses: APIEntity[] = [];
|
||||
const polls: APIEntity[] = [];
|
||||
|
@ -146,6 +168,10 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
|||
if (status.poll?.id) {
|
||||
polls.push(status.poll);
|
||||
}
|
||||
|
||||
if (status.group?.id) {
|
||||
dispatch(importFetchedGroup(status.group));
|
||||
}
|
||||
}
|
||||
|
||||
statuses.forEach(processStatus);
|
||||
|
@ -154,23 +180,37 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
|||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
};
|
||||
}
|
||||
|
||||
export function importFetchedPoll(poll: APIEntity) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const importFetchedPoll = (poll: APIEntity) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
dispatch(importPolls([poll]));
|
||||
};
|
||||
}
|
||||
|
||||
export function importErrorWhileFetchingAccountByUsername(username: string) {
|
||||
return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username };
|
||||
}
|
||||
const importErrorWhileFetchingAccountByUsername = (username: string) =>
|
||||
({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username });
|
||||
|
||||
export {
|
||||
ACCOUNT_IMPORT,
|
||||
ACCOUNTS_IMPORT,
|
||||
GROUP_IMPORT,
|
||||
GROUPS_IMPORT,
|
||||
STATUS_IMPORT,
|
||||
STATUSES_IMPORT,
|
||||
POLLS_IMPORT,
|
||||
ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
|
||||
importAccount,
|
||||
importAccounts,
|
||||
importGroup,
|
||||
importGroups,
|
||||
importStatus,
|
||||
importStatuses,
|
||||
importPolls,
|
||||
importFetchedAccount,
|
||||
importFetchedAccounts,
|
||||
importFetchedGroup,
|
||||
importFetchedGroups,
|
||||
importFetchedStatus,
|
||||
importFetchedStatuses,
|
||||
importFetchedPoll,
|
||||
importErrorWhileFetchingAccountByUsername,
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||
|
@ -83,6 +83,10 @@ const submitSearch = (filter?: SearchFilter) =>
|
|||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
}
|
||||
|
||||
if (response.data.groups) {
|
||||
dispatch(importFetchedGroups(response.data.groups));
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
|
@ -139,6 +143,10 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
|
|||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
if (data.groups) {
|
||||
dispatch(importFetchedGroups(data.groups));
|
||||
}
|
||||
|
||||
dispatch(expandSearchSuccess(data, value, type));
|
||||
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
|
|
|
@ -156,6 +156,8 @@ const defaultSettings = ImmutableMap({
|
|||
}),
|
||||
}),
|
||||
|
||||
groups: ImmutableMap({}),
|
||||
|
||||
trends: ImmutableMap({
|
||||
show: true,
|
||||
}),
|
||||
|
|
|
@ -219,6 +219,9 @@ const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, don
|
|||
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
||||
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
|
||||
|
||||
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
|
||||
expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
|
||||
|
||||
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
|
||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||
max_id: maxId,
|
||||
|
@ -309,6 +312,7 @@ export {
|
|||
expandAccountMediaTimeline,
|
||||
expandListTimeline,
|
||||
expandGroupTimeline,
|
||||
expandGroupMediaTimeline,
|
||||
expandHashtagTimeline,
|
||||
expandTimelineRequest,
|
||||
expandTimelineSuccess,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||
|
@ -13,6 +13,7 @@ import Badge from './badge';
|
|||
import RelativeTimestamp from './relative-timestamp';
|
||||
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
||||
|
||||
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IInstanceFavicon {
|
||||
|
@ -87,6 +88,7 @@ export interface IAccount {
|
|||
withLinkToProfile?: boolean,
|
||||
withRelationship?: boolean,
|
||||
showEdit?: boolean,
|
||||
approvalStatus?: StatusApprovalStatus,
|
||||
emoji?: string,
|
||||
note?: string,
|
||||
}
|
||||
|
@ -111,6 +113,7 @@ const Account = ({
|
|||
withLinkToProfile = true,
|
||||
withRelationship = true,
|
||||
showEdit = false,
|
||||
approvalStatus,
|
||||
emoji,
|
||||
note,
|
||||
}: IAccount) => {
|
||||
|
@ -259,6 +262,18 @@ const Account = ({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
||||
<Text tag='span' theme='muted' size='sm'>
|
||||
{approvalStatus === 'pending'
|
||||
? <FormattedMessage id='status.approval.pending' defaultMessage='Pending approval' />
|
||||
: <FormattedMessage id='status.approval.rejected' defaultMessage='Rejected' />}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEdit ? (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
|
|
@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge';
|
|||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
|
||||
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
|
||||
});
|
||||
|
@ -56,7 +56,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
|
|||
{floatingAction && action}
|
||||
</div>
|
||||
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
|
||||
</div>
|
||||
<Stack className='p-2.5' space={2}>
|
||||
<HStack space={2} alignItems='center' justifyContent='between'>
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Avatar, HStack, Icon, Stack, Text } from './ui';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
});
|
||||
|
||||
interface IGroupCard {
|
||||
group: GroupEntity
|
||||
}
|
||||
|
||||
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<Stack className='bg-white dark:bg-primary-900 border border-solid border-gray-300 dark:border-primary-800 rounded-lg sm:rounded-xl'>
|
||||
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative -m-[1px] mb-0 rounded-t-lg sm:rounded-t-xl'>
|
||||
{group.header && <img className='h-full w-full object-cover rounded-t-lg sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupCard;
|
|
@ -33,6 +33,7 @@ const messages = defineMessages({
|
|||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
groups: { id: 'column.groups', defaultMessage: 'Groups' },
|
||||
events: { id: 'column.events', defaultMessage: 'Events' },
|
||||
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
|
||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
||||
|
@ -207,6 +208,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{features.groups && (
|
||||
<SidebarLink
|
||||
to='/groups'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={intl.formatMessage(messages.groups)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.lists && (
|
||||
<SidebarLink
|
||||
to='/lists'
|
||||
|
|
|
@ -135,6 +135,14 @@ const SidebarNavigation = () => {
|
|||
|
||||
{renderMessagesLink()}
|
||||
|
||||
{features.groups && (
|
||||
<SidebarNavigationLink
|
||||
to='/groups'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarNavigationLink
|
||||
to={`/@${account.acct}`}
|
||||
icon={require('@tabler/icons/user.svg')}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
|||
import { launchChat } from 'soapbox/actions/chats';
|
||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { editEvent } from 'soapbox/actions/events';
|
||||
import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups';
|
||||
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
|
@ -24,7 +25,7 @@ import copy from 'soapbox/utils/copy';
|
|||
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||
import type { Account, Status } from 'soapbox/types/entities';
|
||||
import type { Account, Group, Status } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -81,6 +82,18 @@ const messages = defineMessages({
|
|||
redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||
replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' },
|
||||
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
|
||||
groupModKick: { id: 'status.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModBlock: { id: 'status.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||
deleteFromGroupHeading: { id: 'confirmations.delete_from_group.heading', defaultMessage: 'Delete from group' },
|
||||
deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' },
|
||||
kickFromGroupHeading: { id: 'confirmations.kick_from_group.heading', defaultMessage: 'Kick group member' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kickFromGroupConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Block group member' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||
blockFromGroupConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
});
|
||||
|
||||
interface IStatusActionBar {
|
||||
|
@ -103,6 +116,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const dispatch = useAppDispatch();
|
||||
|
||||
const me = useAppSelector(state => state.me);
|
||||
const groupRelationship = useAppSelector(state => status.group ? state.group_relationships.get((status.group as Group).id) : null);
|
||||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
@ -285,6 +299,39 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
|
||||
};
|
||||
|
||||
const handleDeleteFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKickFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.kickFromGroupHeading),
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickFromGroupConfirm),
|
||||
onConfirm: () => dispatch(groupKick((status.group as Group).id, account.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.blockFromGroupHeading),
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockFromGroupConfirm),
|
||||
onConfirm: () => dispatch(groupBlock((status.group as Group).id, account.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const _makeMenu = (publicStatus: boolean) => {
|
||||
const mutingConversation = status.muted;
|
||||
const ownAccount = status.getIn(['account', 'id']) === me;
|
||||
|
@ -425,6 +472,26 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
});
|
||||
}
|
||||
|
||||
if (status.group && groupRelationship?.role && ['admin', 'moderator'].includes(groupRelationship.role)) {
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModDelete),
|
||||
action: handleDeleteFromGroup,
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
});
|
||||
// TODO: figure out when an account is not in the group anymore
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.get('username') }),
|
||||
action: handleKickFromGroup,
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.get('username') }),
|
||||
action: handleBlockFromGroup,
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isStaff) {
|
||||
menu.push(null);
|
||||
|
||||
|
@ -491,6 +558,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const menu = _makeMenu(publicStatus);
|
||||
let reblogIcon = require('@tabler/icons/repeat.svg');
|
||||
let replyTitle;
|
||||
let replyDisabled = false;
|
||||
|
||||
if (status.visibility === 'direct') {
|
||||
reblogIcon = require('@tabler/icons/mail.svg');
|
||||
|
@ -498,6 +566,11 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
reblogIcon = require('@tabler/icons/lock.svg');
|
||||
}
|
||||
|
||||
if ((status.group as Group)?.membership_required && !groupRelationship?.member) {
|
||||
replyDisabled = true;
|
||||
replyTitle = intl.formatMessage(messages.replies_disabled_group);
|
||||
}
|
||||
|
||||
const reblogMenu = [{
|
||||
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog),
|
||||
action: handleReblogClick,
|
||||
|
@ -543,6 +616,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
onClick={handleReplyClick}
|
||||
count={replyCount}
|
||||
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
||||
disabled={replyDisabled}
|
||||
/>
|
||||
|
||||
{(features.quotePosts && me) ? (
|
||||
|
|
|
@ -85,6 +85,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
|||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',
|
||||
{
|
||||
'text-black dark:text-white': active && emoji,
|
||||
'hover:text-gray-600 dark:hover:text-white': !filteredProps.disabled,
|
||||
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && !emoji && color === COLORS.accent,
|
||||
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && !emoji && color === COLORS.success,
|
||||
'space-x-1': !text,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
@ -40,6 +40,8 @@ interface IModal {
|
|||
confirmationText?: React.ReactNode,
|
||||
/** Confirmation button theme. */
|
||||
confirmationTheme?: ButtonThemes,
|
||||
/** Whether to use full width style for confirmation button. */
|
||||
confirmationFullWidth?: boolean,
|
||||
/** Callback when the modal is closed. */
|
||||
onClose?: () => void,
|
||||
/** Callback when the secondary action is chosen. */
|
||||
|
@ -66,6 +68,7 @@ const Modal: React.FC<IModal> = ({
|
|||
confirmationDisabled,
|
||||
confirmationText,
|
||||
confirmationTheme,
|
||||
confirmationFullWidth,
|
||||
onClose,
|
||||
secondaryAction,
|
||||
secondaryDisabled = false,
|
||||
|
@ -118,7 +121,7 @@ const Modal: React.FC<IModal> = ({
|
|||
|
||||
{confirmationAction && (
|
||||
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
|
||||
<div className='flex-grow'>
|
||||
<div className={classNames({ 'flex-grow': !confirmationFullWidth })}>
|
||||
{cancelAction && (
|
||||
<Button
|
||||
theme='tertiary'
|
||||
|
@ -129,7 +132,7 @@ const Modal: React.FC<IModal> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<HStack space={2}>
|
||||
<HStack space={2} className={classNames({ 'flex-grow': confirmationFullWidth })}>
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
|
@ -145,6 +148,7 @@ const Modal: React.FC<IModal> = ({
|
|||
onClick={confirmationAction}
|
||||
disabled={confirmationDisabled}
|
||||
ref={buttonRef}
|
||||
block={confirmationFullWidth}
|
||||
>
|
||||
{confirmationText}
|
||||
</Button>
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
|
||||
interface IGroupContainer {
|
||||
id: string
|
||||
}
|
||||
|
||||
const GroupContainer: React.FC<IGroupContainer> = (props) => {
|
||||
const { id, ...rest } = props;
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
|
||||
if (group) {
|
||||
return <GroupCard group={group} {...rest} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default GroupContainer;
|
|
@ -37,7 +37,7 @@ const Welcome = () => {
|
|||
|
||||
return (
|
||||
<Stack className='py-20 px-4 sm:px-0 h-full overflow-y-auto' data-testid='chats-welcome'>
|
||||
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-10'>
|
||||
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-2.5'>
|
||||
<Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'>
|
||||
{intl.formatMessage(messages.title, { br: <br /> })}
|
||||
</Text>
|
||||
|
@ -77,4 +77,4 @@ const Welcome = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
export default Welcome;
|
||||
|
|
|
@ -62,9 +62,10 @@ interface IComposeForm<ID extends string> {
|
|||
autoFocus?: boolean,
|
||||
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
||||
event?: string,
|
||||
group?: string,
|
||||
}
|
||||
|
||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event }: IComposeForm<ID>) => {
|
||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event, group }: IComposeForm<ID>) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
@ -77,7 +78,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
|
||||
const features = useFeatures();
|
||||
|
||||
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose;
|
||||
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt, group_id: groupId } = compose;
|
||||
const prevSpoiler = usePrevious(spoiler);
|
||||
|
||||
const hasPoll = !!compose.poll;
|
||||
|
@ -227,7 +228,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
{features.media && <UploadButtonContainer composeId={id} />}
|
||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
|
||||
{features.polls && <PollButton composeId={id} />}
|
||||
{features.privacyScopes && <PrivacyDropdown composeId={id} />}
|
||||
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
|
||||
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
||||
{features.spoilers && <SpoilerButton composeId={id} />}
|
||||
{features.richText && <MarkdownButton composeId={id} />}
|
||||
|
@ -270,7 +271,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
return (
|
||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||
{scheduledStatusCount > 0 && !event && (
|
||||
{scheduledStatusCount > 0 && !event && !group && (
|
||||
<Warning
|
||||
message={(
|
||||
<FormattedMessage
|
||||
|
@ -291,9 +292,9 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
<WarningContainer composeId={id} />
|
||||
|
||||
{!shouldCondense && !event && <ReplyIndicatorContainer composeId={id} />}
|
||||
{!shouldCondense && !event && !group && <ReplyIndicatorContainer composeId={id} />}
|
||||
|
||||
{!shouldCondense && !event && <ReplyMentions composeId={id} />}
|
||||
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
|
||||
|
||||
<AutosuggestTextarea
|
||||
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
||||
|
@ -349,8 +350,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
<Button type='submit' theme='primary' text={publishText} icon={publishIcon} disabled={disabledButton} />
|
||||
</HStack>
|
||||
{/* <HStack alignItems='center' space={4}>
|
||||
</HStack> */}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -9,11 +9,13 @@ import IconButton from 'soapbox/components/icon-button';
|
|||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { HStack, Tabs, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import GroupContainer from 'soapbox/containers/group-container';
|
||||
import StatusContainer from 'soapbox/containers/status-container';
|
||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
||||
import PlaceholderGroupCard from 'soapbox/features/placeholder/components/placeholder-group-card';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
@ -22,6 +24,7 @@ import type { SearchFilter } from 'soapbox/reducers/search';
|
|||
const messages = defineMessages({
|
||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||
groups: { id: 'search_results.groups', defaultMessage: 'Groups' },
|
||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||
});
|
||||
|
||||
|
@ -30,6 +33,7 @@ const SearchResults = () => {
|
|||
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const value = useAppSelector((state) => state.search.submittedValue);
|
||||
const results = useAppSelector((state) => state.search.results);
|
||||
|
@ -48,7 +52,8 @@ const SearchResults = () => {
|
|||
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
||||
|
||||
const renderFilterBar = () => {
|
||||
const items = [
|
||||
const items = [];
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.accounts),
|
||||
action: () => selectFilter('accounts'),
|
||||
|
@ -59,12 +64,23 @@ const SearchResults = () => {
|
|||
action: () => selectFilter('statuses'),
|
||||
name: 'statuses',
|
||||
},
|
||||
);
|
||||
|
||||
if (features.groups) items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.groups),
|
||||
action: () => selectFilter('groups'),
|
||||
name: 'groups',
|
||||
},
|
||||
);
|
||||
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.hashtags),
|
||||
action: () => selectFilter('hashtags'),
|
||||
name: 'hashtags',
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||
};
|
||||
|
@ -170,6 +186,31 @@ const SearchResults = () => {
|
|||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'groups') {
|
||||
hasMore = results.groupsHasMore;
|
||||
loaded = results.groupsLoaded;
|
||||
placeholderComponent = PlaceholderGroupCard;
|
||||
|
||||
if (results.groups && results.groups.size > 0) {
|
||||
searchResults = results.groups.map((groupId: string) => (
|
||||
<GroupContainer id={groupId} />
|
||||
));
|
||||
resultsIds = results.groups;
|
||||
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
|
||||
searchResults = null;
|
||||
} else if (loaded) {
|
||||
noResultsMessage = (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage
|
||||
id='empty_column.search.groups'
|
||||
defaultMessage='There are no groups results for "{term}"'
|
||||
values={{ term: value }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'hashtags') {
|
||||
hasMore = results.hashtagsHasMore;
|
||||
loaded = results.hashtagsLoaded;
|
||||
|
|
|
@ -3,10 +3,10 @@ import React from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import Account from 'soapbox/components/account';
|
||||
import Badge from 'soapbox/components/badge';
|
||||
import RelativeTimestamp from 'soapbox/components/relative-timestamp';
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import ActionButton from 'soapbox/features/ui/components/action-button';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
@ -51,8 +51,8 @@ const AccountCard: React.FC<IAccountCard> = ({ id }) => {
|
|||
</div>
|
||||
|
||||
<Stack space={4} className='p-3'>
|
||||
<AccountContainer
|
||||
id={account.id}
|
||||
<Account
|
||||
account={account}
|
||||
withRelationship={false}
|
||||
/>
|
||||
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
import { isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
|
||||
import type { Group } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
|
||||
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
|
||||
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
|
||||
});
|
||||
|
||||
interface IGroupHeader {
|
||||
group?: Group | false | null,
|
||||
}
|
||||
|
||||
const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className='-mt-4 -mx-4'>
|
||||
<div>
|
||||
<div className='relative h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50' />
|
||||
</div>
|
||||
|
||||
<div className='px-4 sm:px-6'>
|
||||
<HStack alignItems='bottom' space={5} className='-mt-12'>
|
||||
<div className='flex relative'>
|
||||
<div
|
||||
className='h-24 w-24 bg-gray-400 rounded-full ring-4 ring-white dark:ring-gray-800'
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onJoinGroup = () => dispatch(joinGroup(group.id));
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
message: intl.formatMessage(messages.confirmationMessage),
|
||||
confirm: intl.formatMessage(messages.confirmationConfirm),
|
||||
onConfirm: () => dispatch(leaveGroup(group.id)),
|
||||
}));
|
||||
|
||||
const onAvatarClick = () => {
|
||||
const avatar = normalizeAttachment({
|
||||
type: 'image',
|
||||
url: group.avatar,
|
||||
});
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
|
||||
};
|
||||
|
||||
const handleAvatarClick: React.MouseEventHandler = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onAvatarClick();
|
||||
}
|
||||
};
|
||||
|
||||
const onHeaderClick = () => {
|
||||
const header = normalizeAttachment({
|
||||
type: 'image',
|
||||
url: group.header,
|
||||
});
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
|
||||
};
|
||||
|
||||
const handleHeaderClick: React.MouseEventHandler = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onHeaderClick();
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
let header: React.ReactNode;
|
||||
|
||||
if (group.header) {
|
||||
header = (
|
||||
<StillImage
|
||||
src={group.header}
|
||||
alt={intl.formatMessage(messages.header)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isDefaultHeader(group.header)) {
|
||||
header = (
|
||||
<a href={group.header} onClick={handleHeaderClick} target='_blank'>
|
||||
{header}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return header;
|
||||
};
|
||||
|
||||
const makeActionButton = () => {
|
||||
if (!group.relationship || !group.relationship.member) {
|
||||
return (
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={onJoinGroup}
|
||||
>
|
||||
{group.locked ? <FormattedMessage id='group.request_join' defaultMessage='Request to join group' /> : <FormattedMessage id='group.join' defaultMessage='Join group' />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.relationship.requested) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel request' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.relationship?.role === 'admin') {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
to={`/groups/${group.id}/manage`}
|
||||
>
|
||||
<FormattedMessage id='group.manage' defaultMessage='Manage group' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.leave' defaultMessage='Leave group' />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const actionButton = makeActionButton();
|
||||
|
||||
return (
|
||||
<div className='-mt-4 -mx-4'>
|
||||
<div className='relative'>
|
||||
<div className='relative flex flex-col justify-center h-32 w-full lg:h-[200px] md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
|
||||
{renderHeader()}
|
||||
</div>
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack className='p-3 pt-12' alignItems='center' space={2}>
|
||||
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
{actionButton}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupHeader;
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchGroup, fetchGroupBlocks, groupUnblock } from 'soapbox/actions/groups';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.group_blocked_members', defaultMessage: 'Blocked members' },
|
||||
unblock: { id: 'group.group_mod_unblock', defaultMessage: 'Unblock' },
|
||||
unblocked: { id: 'group.group_mod_unblock.success', defaultMessage: 'Unblocked @{name} from group' },
|
||||
});
|
||||
|
||||
interface IBlockedMember {
|
||||
accountId: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const BlockedMember: React.FC<IBlockedMember> = ({ accountId, groupId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleUnblock = () =>
|
||||
dispatch(groupUnblock(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.unblocked, { name: account.acct }));
|
||||
});
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.unblock)}
|
||||
onClick={handleUnblock}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupBlockedMembers {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
dispatch(fetchGroupBlocks(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship || !accountIds) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.group_blocks' defaultMessage="The group hasn't blocked any users yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
||||
<ScrollableList
|
||||
scrollKey='group_blocks'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{accountIds.map((accountId) =>
|
||||
<BlockedMember key={accountId} accountId={accountId} groupId={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupBlockedMembers;
|
|
@ -0,0 +1,285 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { expandGroupMemberships, fetchGroup, fetchGroupMemberships, groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { CardHeader, CardTitle, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||
|
||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||
import type { GroupRole, List } from 'soapbox/reducers/group-memberships';
|
||||
import type { GroupRelationship } from 'soapbox/types/entities';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
adminSubheading: { id: 'group.admin_subheading', defaultMessage: 'Group administrators' },
|
||||
moderatorSubheading: { id: 'group.moderator_subheading', defaultMessage: 'Group moderators' },
|
||||
userSubheading: { id: 'group.user_subheading', defaultMessage: 'Users' },
|
||||
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
||||
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
||||
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
||||
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
||||
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
|
||||
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
||||
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
||||
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||
});
|
||||
|
||||
interface IGroupMember {
|
||||
accountId: string
|
||||
accountRole: GroupRole
|
||||
groupId: string
|
||||
relationship?: GroupRelationship
|
||||
}
|
||||
|
||||
const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId, relationship }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleKickFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickConfirm),
|
||||
onConfirm: () => dispatch(groupKick(groupId, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.kicked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(groupBlock(groupId, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
||||
if (warning) {
|
||||
return dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||
onConfirm: () => dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
return dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteToGroupAdmin = () => {
|
||||
onPromote('admin', true);
|
||||
};
|
||||
|
||||
const handlePromoteToGroupMod = () => {
|
||||
onPromote('moderator', relationship!.role === 'moderator');
|
||||
};
|
||||
|
||||
const handleDemote = () => {
|
||||
dispatch(groupDemoteAccount(groupId, account.id, 'user')).then(() =>
|
||||
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
||||
).catch(() => {});
|
||||
};
|
||||
|
||||
const makeMenu = () => {
|
||||
const menu: MenuType = [];
|
||||
|
||||
if (!relationship || !relationship.role) return menu;
|
||||
|
||||
if (['admin', 'moderator'].includes(relationship.role) && ['moderator', 'user'].includes(accountRole) && accountRole !== relationship.role) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
action: handleKickFromGroup,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
action: handleBlockFromGroup,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship.role === 'admin' && accountRole !== 'admin' && account.acct === account.username) {
|
||||
menu.push(null);
|
||||
switch (accountRole) {
|
||||
case 'moderator':
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupAdmin,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
||||
action: handleDemote,
|
||||
});
|
||||
break;
|
||||
case 'user':
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupMod,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
};
|
||||
|
||||
const menu = makeMenu();
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
{menu.length > 0 && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
theme='outlined'
|
||||
className='px-2'
|
||||
iconClassName='w-4 h-4'
|
||||
children={null}
|
||||
/>
|
||||
|
||||
<MenuList className='w-56'>
|
||||
{menu.map((menuItem, idx) => {
|
||||
if (typeof menuItem?.text === 'undefined') {
|
||||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
<HStack space={3} alignItems='center'>
|
||||
{menuItem.icon && (
|
||||
<SvgIcon src={menuItem.icon} className='h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
|
||||
)}
|
||||
|
||||
<div className='truncate'>{menuItem.text}</div>
|
||||
</HStack>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupMembers {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||
const admins = useAppSelector((state) => state.group_memberships.admin.get(groupId));
|
||||
const moderators = useAppSelector((state) => state.group_memberships.moderator.get(groupId));
|
||||
const users = useAppSelector((state) => state.group_memberships.user.get(groupId));
|
||||
|
||||
const handleLoadMore = (role: 'admin' | 'moderator' | 'user') => {
|
||||
dispatch(expandGroupMemberships(groupId, role));
|
||||
};
|
||||
|
||||
const handleLoadMoreAdmins = useCallback(debounce(() => {
|
||||
handleLoadMore('admin');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleLoadMoreModerators = useCallback(debounce(() => {
|
||||
handleLoadMore('moderator');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleLoadMoreUsers = useCallback(debounce(() => {
|
||||
handleLoadMore('user');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const renderMemberships = (memberships: List | undefined, role: GroupRole, handler: () => void) => {
|
||||
if (!memberships?.isLoading && !memberships?.items.count()) return;
|
||||
|
||||
return (
|
||||
<React.Fragment key={role}>
|
||||
<CardHeader className='mt-4'>
|
||||
<CardTitle title={intl.formatMessage(messages[`${role}Subheading`])} />
|
||||
</CardHeader>
|
||||
<ScrollableList
|
||||
scrollKey={`group_${role}s-${groupId}`}
|
||||
hasMore={!!memberships?.next}
|
||||
onLoadMore={handler}
|
||||
isLoading={memberships?.isLoading}
|
||||
showLoading={memberships?.isLoading && !memberships?.items?.count()}
|
||||
placeholderComponent={PlaceholderAccount}
|
||||
placeholderCount={3}
|
||||
itemClassName='pb-4 last:pb-0'
|
||||
>
|
||||
{memberships?.items?.map(accountId => (
|
||||
<GroupMember
|
||||
key={accountId}
|
||||
accountId={accountId}
|
||||
accountRole={role}
|
||||
groupId={groupId}
|
||||
relationship={relationship}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
|
||||
dispatch(fetchGroupMemberships(groupId, 'admin'));
|
||||
dispatch(fetchGroupMemberships(groupId, 'moderator'));
|
||||
dispatch(fetchGroupMemberships(groupId, 'user'));
|
||||
}, [groupId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderMemberships(admins, 'admin', handleLoadMoreAdmins)}
|
||||
{renderMemberships(moderators, 'moderator', handleLoadMoreModerators)}
|
||||
{renderMemberships(users, 'user', handleLoadMoreUsers)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMembers;
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { authorizeGroupMembershipRequest, fetchGroup, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
|
||||
authorize: { id: 'group.group_mod_authorize', defaultMessage: 'Accept' },
|
||||
authorized: { id: 'group.group_mod_authorize.success', defaultMessage: 'Accepted @{name} to group' },
|
||||
reject: { id: 'group.group_mod_reject', defaultMessage: 'Reject' },
|
||||
rejected: { id: 'group.group_mod_reject.success', defaultMessage: 'Rejected @{name} from group' },
|
||||
});
|
||||
|
||||
interface IMembershipRequest {
|
||||
accountId: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const MembershipRequest: React.FC<IMembershipRequest> = ({ accountId, groupId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleAuthorize = () =>
|
||||
dispatch(authorizeGroupMembershipRequest(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.authorized, { name: account.acct }));
|
||||
});
|
||||
|
||||
const handleReject = () =>
|
||||
dispatch(rejectGroupMembershipRequest(groupId, accountId)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.rejected, { name: account.acct }));
|
||||
});
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
<HStack space={2}>
|
||||
<Button
|
||||
theme='secondary'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.authorize)}
|
||||
onClick={handleAuthorize}
|
||||
/>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.reject)}
|
||||
onClick={handleReject}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IGroupMembershipRequests {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
dispatch(fetchGroupMembershipRequests(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship || !accountIds) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
||||
<ScrollableList
|
||||
scrollKey='group_membership_requests'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{accountIds.map((accountId) =>
|
||||
<MembershipRequest key={accountId} accountId={accountId} groupId={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMembershipRequests;
|
|
@ -0,0 +1,76 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { groupCompose } from 'soapbox/actions/compose';
|
||||
import { fetchGroup } from 'soapbox/actions/groups';
|
||||
import { connectGroupStream } from 'soapbox/actions/streaming';
|
||||
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
||||
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
|
||||
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
||||
import { useAppDispatch, useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import Timeline from '../ui/components/timeline';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
interface IGroupTimeline {
|
||||
params: RouteParams,
|
||||
}
|
||||
|
||||
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||
const account = useOwnAccount();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||
|
||||
const handleLoadMore = (maxId: string) => {
|
||||
dispatch(expandGroupTimeline(groupId, { maxId }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
dispatch(expandGroupTimeline(groupId));
|
||||
|
||||
dispatch(groupCompose(`group:${groupId}`, groupId));
|
||||
|
||||
const disconnect = dispatch(connectGroupStream(groupId));
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
{!!account && relationship?.member && (
|
||||
<div className='px-2 py-4 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||
<HStack alignItems='start' space={4}>
|
||||
<Link to={`/@${account.acct}`}>
|
||||
<Avatar src={account.avatar} size={46} />
|
||||
</Link>
|
||||
|
||||
<ComposeForm
|
||||
id={`group:${groupId}`}
|
||||
shouldCondense
|
||||
autoFocus={false}
|
||||
group={groupId}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
<Timeline
|
||||
scrollKey='group_timeline'
|
||||
timelineId={`group:${groupId}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
|
||||
divideType='border'
|
||||
showGroup={false}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTimeline;
|
|
@ -0,0 +1,96 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { deleteGroup, editGroup, fetchGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { CardBody, Column, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
|
||||
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' },
|
||||
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' },
|
||||
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Blocked members' },
|
||||
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' },
|
||||
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' },
|
||||
deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' },
|
||||
});
|
||||
|
||||
interface IManageGroup {
|
||||
params: RouteParams
|
||||
}
|
||||
|
||||
const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
}, [id]);
|
||||
|
||||
if (!group || !group.relationship) {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||
return (<ColumnForbidden />);
|
||||
}
|
||||
|
||||
const onEditGroup = () =>
|
||||
dispatch(editGroup(group));
|
||||
|
||||
const onDeleteGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteGroup(id)),
|
||||
}));
|
||||
|
||||
const navigateToPending = () => history.push(`/groups/${id}/manage/requests`);
|
||||
const navigateToBlocks = () => history.push(`/groups/${id}/manage/blocks`);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}>
|
||||
<CardBody className='space-y-4'>
|
||||
{group.relationship.role === 'admin' && (
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}>
|
||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} />
|
||||
<ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} />
|
||||
</List>
|
||||
{group.relationship.role === 'admin' && (
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.deleteGroup)} onClick={onDeleteGroup} />
|
||||
</List>
|
||||
)}
|
||||
</CardBody>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageGroup;
|
|
@ -0,0 +1,106 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { fetchGroups } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||
|
||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const getOrderedGroups = createSelector([
|
||||
(state: RootState) => state.groups.items,
|
||||
(state: RootState) => state.groups.isLoading,
|
||||
(state: RootState) => state.group_relationships,
|
||||
], (groups, isLoading, group_relationships) => ({
|
||||
groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||
.map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||
.filter((item) => item.relationship?.member)
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||
isLoading,
|
||||
}));
|
||||
|
||||
const Groups: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { groups, isLoading } = useAppSelector((state) => getOrderedGroups(state));
|
||||
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroups());
|
||||
}, []);
|
||||
|
||||
const createGroup = () => {
|
||||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
if (!groups) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = (
|
||||
<Stack space={6} alignItems='center' justifyContent='center' className='p-6 h-full'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className='gap-4'>
|
||||
{canCreateGroup && (
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end xl:hidden'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
</Button>
|
||||
)}
|
||||
<ScrollableList
|
||||
scrollKey='groups'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !groups.count()}
|
||||
placeholderComponent={PlaceholderGroupCard}
|
||||
placeholderCount={3}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Link key={group.id} to={`/groups/${group.id}`}>
|
||||
<GroupCard group={group as GroupEntity} />
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Groups;
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderGroupCard = () => {
|
||||
const groupNameLength = randomIntFromInterval(5, 25);
|
||||
const roleLength = randomIntFromInterval(5, 15);
|
||||
const privacyLength = randomIntFromInterval(5, 15);
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden animate-pulse'>
|
||||
<Stack className='bg-white dark:bg-primary-900 border border-solid border-gray-300 dark:border-primary-800 rounded-lg sm:rounded-xl'>
|
||||
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative -m-[1px] mb-0 rounded-t-lg sm:rounded-t-xl'>
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<div className='h-16 w-16 rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900' />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold'>{generateText(groupNameLength)}</Text>
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
<span>{generateText(roleLength)}</span>
|
||||
<span>{generateText(privacyLength)}</span>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderGroupCard;
|
|
@ -90,6 +90,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
timestamp={actualStatus.created_at}
|
||||
avatarSize={42}
|
||||
hideActions
|
||||
approvalStatus={actualStatus.approval_status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const ComposeButton = () => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const onOpenCompose = () => dispatch(openModal('COMPOSE'));
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,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;
|
|
@ -36,6 +36,7 @@ import {
|
|||
EventMapModal,
|
||||
EventParticipantsModal,
|
||||
PolicyModal,
|
||||
ManageGroupModal,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
|
||||
import BundleContainer from '../containers/bundle-container';
|
||||
|
@ -79,6 +80,7 @@ const MODAL_COMPONENTS = {
|
|||
'EVENT_MAP': EventMapModal,
|
||||
'EVENT_PARTICIPANTS': EventParticipantsModal,
|
||||
'POLICY': PolicyModal,
|
||||
'MANAGE_GROUP': ManageGroupModal,
|
||||
};
|
||||
|
||||
export type ModalType = keyof typeof MODAL_COMPONENTS | null;
|
||||
|
|
|
@ -214,7 +214,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
|||
<FormGroup
|
||||
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />}
|
||||
>
|
||||
<div className='flex items-center justify-center bg-gray-200 dark:bg-gray-900/50 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
|
||||
<div className='flex items-center justify-center bg-primary-100 dark:bg-gray-800 rounded-lg text-primary-500 dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
|
||||
{banner ? (
|
||||
<>
|
||||
<img className='h-full w-full object-cover' src={banner.url} alt='' />
|
||||
|
@ -223,7 +223,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
|||
) : (
|
||||
<UploadButton disabled={isUploading} onSelectFile={handleFiles} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
|
|
|
@ -1,23 +1,18 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { HStack, Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
const messages = defineMessages({
|
||||
upload: { id: 'compose_event.upload_banner', defaultMessage: 'Upload event banner' },
|
||||
});
|
||||
|
||||
interface IUploadButton {
|
||||
disabled?: boolean,
|
||||
onSelectFile: (files: FileList) => void,
|
||||
}
|
||||
|
||||
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const fileElement = useRef<HTMLInputElement>(null);
|
||||
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/'));
|
||||
|
||||
|
@ -32,27 +27,25 @@ const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton
|
||||
<HStack className='h-full w-full text-primary-500 dark:text-accent-blue cursor-pointer' space={3} alignItems='center' justifyContent='center' element='label'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/photo-plus.svg')}
|
||||
className='h-8 w-8 text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
|
||||
title={intl.formatMessage(messages.upload)}
|
||||
disabled={disabled}
|
||||
className='h-7 w-7'
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
<label>
|
||||
<span className='sr-only'>{intl.formatMessage(messages.upload)}</span>
|
||||
<input
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||
</Text>
|
||||
<input
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { submitGroupEditor } from 'soapbox/actions/groups';
|
||||
import { Modal, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import DetailsStep from './steps/details-step';
|
||||
import PrivacyStep from './steps/privacy-step';
|
||||
|
||||
const messages = defineMessages({
|
||||
next: { id: 'manage_group.next', defaultMessage: 'Next' },
|
||||
create: { id: 'manage_group.create', defaultMessage: 'Create' },
|
||||
update: { id: 'manage_group.update', defaultMessage: 'Update' },
|
||||
});
|
||||
|
||||
enum Steps {
|
||||
ONE = 'ONE',
|
||||
TWO = 'TWO',
|
||||
}
|
||||
|
||||
const manageGroupSteps = {
|
||||
ONE: PrivacyStep,
|
||||
TWO: DetailsStep,
|
||||
};
|
||||
|
||||
interface IManageGroupModal {
|
||||
onClose: (type?: string) => void,
|
||||
}
|
||||
|
||||
const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = useAppSelector((state) => state.group_editor.groupId);
|
||||
|
||||
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
|
||||
|
||||
const onClickClose = () => {
|
||||
onClose('manage_group');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitGroupEditor(true));
|
||||
};
|
||||
|
||||
const confirmationText = useMemo(() => {
|
||||
switch (currentStep) {
|
||||
case Steps.TWO:
|
||||
return intl.formatMessage(id ? messages.update : messages.create);
|
||||
default:
|
||||
return intl.formatMessage(messages.next);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const handleNextStep = () => {
|
||||
switch (currentStep) {
|
||||
case Steps.ONE:
|
||||
setCurrentStep(Steps.TWO);
|
||||
break;
|
||||
case Steps.TWO:
|
||||
handleSubmit();
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const StepToRender = manageGroupSteps[currentStep];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={id
|
||||
? <FormattedMessage id='navigation_bar.edit_group' defaultMessage='Edit Group' />
|
||||
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
|
||||
confirmationAction={handleNextStep}
|
||||
confirmationText={confirmationText}
|
||||
confirmationDisabled={isSubmitting}
|
||||
confirmationFullWidth
|
||||
onClose={onClickClose}
|
||||
>
|
||||
<Stack space={2}>
|
||||
<StepToRender />
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageGroupModal;
|
|
@ -0,0 +1,180 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import {
|
||||
changeGroupEditorTitle,
|
||||
changeGroupEditorDescription,
|
||||
changeGroupEditorMedia,
|
||||
} from 'soapbox/actions/groups';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Avatar, Form, FormGroup, HStack, Input, Text, Textarea } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
import resizeImage from 'soapbox/utils/resize-image';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
interface IMediaInput {
|
||||
src: string | null,
|
||||
accept: string,
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
|
||||
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
|
||||
});
|
||||
|
||||
const HeaderPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
|
||||
return (
|
||||
<label
|
||||
className='h-24 sm:h-36 w-full text-primary-500 dark:text-accent-blue bg-primary-100 dark:bg-gray-800 cursor-pointer relative rounded-lg sm:shadow dark:sm:shadow-inset overflow-hidden'
|
||||
>
|
||||
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
|
||||
<HStack
|
||||
className={classNames('h-full w-full top-0 absolute transition-opacity', {
|
||||
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
|
||||
})}
|
||||
space={3}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/photo-plus.svg')}
|
||||
className='h-7 w-7'
|
||||
/>
|
||||
|
||||
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||
</Text>
|
||||
|
||||
<input
|
||||
name='header'
|
||||
type='file'
|
||||
accept={accept}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</HStack>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
|
||||
return (
|
||||
<label className='h-[72px] w-[72px] bg-primary-500 cursor-pointer rounded-full absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 ring-2 ring-white dark:ring-primary-900'>
|
||||
{src && <Avatar src={src} size={72} />}
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
|
||||
className={classNames('h-full w-full left-0 top-0 rounded-full absolute transition-opacity', {
|
||||
'opacity-0 hover:opacity-90 bg-primary-500': src,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/camera-plus.svg')}
|
||||
className='h-7 w-7 text-white'
|
||||
/>
|
||||
</HStack>
|
||||
<span className='sr-only'>Upload avatar</span>
|
||||
<input
|
||||
name='avatar'
|
||||
type='file'
|
||||
accept={accept}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailsStep = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const groupId = useAppSelector((state) => state.group_editor.groupId);
|
||||
const isUploading = useAppSelector((state) => state.group_editor.isUploading);
|
||||
const name = useAppSelector((state) => state.group_editor.displayName);
|
||||
const description = useAppSelector((state) => state.group_editor.note);
|
||||
|
||||
const [avatarSrc, setAvatarSrc] = useState<string | null>(null);
|
||||
const [headerSrc, setHeaderSrc] = useState<string | null>(null);
|
||||
|
||||
const attachmentTypes = useAppSelector(
|
||||
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
|
||||
)?.filter(type => type.startsWith('image/')).toArray().join(',');
|
||||
|
||||
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
dispatch(changeGroupEditorTitle(target.value));
|
||||
};
|
||||
|
||||
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
|
||||
dispatch(changeGroupEditorDescription(target.value));
|
||||
};
|
||||
|
||||
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
const rawFile = e.target.files?.item(0);
|
||||
|
||||
if (!rawFile) return;
|
||||
|
||||
if (e.target.name === 'avatar') {
|
||||
resizeImage(rawFile, 400 * 400).then(file => {
|
||||
dispatch(changeGroupEditorMedia('avatar', file));
|
||||
setAvatarSrc(URL.createObjectURL(file));
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
resizeImage(rawFile, 1920 * 1080).then(file => {
|
||||
dispatch(changeGroupEditorMedia('header', file));
|
||||
setHeaderSrc(URL.createObjectURL(file));
|
||||
}).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
|
||||
dispatch((_, getState) => {
|
||||
const group = getState().groups.items.get(groupId);
|
||||
if (!group) return;
|
||||
if (group.avatar && !isDefaultAvatar(group.avatar)) setAvatarSrc(group.avatar);
|
||||
if (group.header && !isDefaultHeader(group.header)) setHeaderSrc(group.header);
|
||||
});
|
||||
}, [groupId]);
|
||||
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div className='flex mb-12 relative'>
|
||||
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
|
||||
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
|
||||
</div>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name (required)' />}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
|
||||
value={name}
|
||||
onChange={onChangeName}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='manage_group.fields.description_label' defaultMessage='Description' />}
|
||||
>
|
||||
<Textarea
|
||||
autoComplete='off'
|
||||
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
|
||||
value={description}
|
||||
onChange={onChangeDescription}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailsStep;
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { changeGroupEditorPrivacy } from 'soapbox/actions/groups';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Form, FormGroup, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const PrivacyStep = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const locked = useAppSelector((state) => state.group_editor.locked);
|
||||
|
||||
const onChangePrivacy = (value: boolean) => {
|
||||
dispatch(changeGroupEditorPrivacy(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack className='max-w-sm mx-auto' space={2}>
|
||||
<Text size='3xl' weight='bold' align='center'>
|
||||
<FormattedMessage id='manage_group.get_started' defaultMessage="Let's get started!" />
|
||||
</Text>
|
||||
<Text size='lg' theme='muted' align='center'>
|
||||
<FormattedMessage id='manage_group.tagline' defaultMessage='Groups connect you with others based on shared interests.' />
|
||||
</Text>
|
||||
</Stack>
|
||||
<Form>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='manage_group.privacy.label' defaultMessage='Privacy settings' />}
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='manage_group.privacy.public.label' defaultMessage='Public' />}
|
||||
hint={<FormattedMessage id='manage_group.privacy.public.hint' defaultMessage='Discoverable. Anyone can join.' />}
|
||||
onSelect={() => onChangePrivacy(false)}
|
||||
isSelected={!locked}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
label={<FormattedMessage id='manage_group.privacy.private.label' defaultMessage='Private (Owner approval required)' />}
|
||||
hint={<FormattedMessage id='manage_group.privacy.private.hint' defaultMessage='Discoverable. Users can join after their request is approved.' />}
|
||||
onSelect={() => onChangePrivacy(true)}
|
||||
isSelected={locked}
|
||||
/>
|
||||
</List>
|
||||
</FormGroup>
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage id='manage_group.privacy.hint' defaultMessage='These settings cannot be changed later.' />
|
||||
</Text>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyStep;
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||
|
||||
const NewGroupPanel = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||
|
||||
const createGroup = () => {
|
||||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
if (!canCreateGroup) return null;
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
<Stack>
|
||||
<Text size='lg' weight='bold'>
|
||||
<FormattedMessage id='new_group_panel.title' defaultMessage='Create New Group' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage id='new_group_panel.subtitle' defaultMessage="Can't find what you're looking for? Start your own private or public group." />
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewGroupPanel;
|
|
@ -51,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 />
|
||||
) : (
|
||||
|
|
|
@ -39,7 +39,7 @@ const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
|
|||
action={
|
||||
<Link className='text-right' to='/suggestions'>
|
||||
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
|
||||
<FormattedMessage id='feed_suggestions.view_all' defaultMessage='View all' />
|
||||
<FormattedMessage id='feed_suggestions.view_all' defaultMessage='View all' />
|
||||
</Text>
|
||||
</Link>
|
||||
}
|
||||
|
|
|
@ -29,6 +29,8 @@ import AdminPage from 'soapbox/pages/admin-page';
|
|||
import ChatsPage from 'soapbox/pages/chats-page';
|
||||
import DefaultPage from 'soapbox/pages/default-page';
|
||||
import EventPage from 'soapbox/pages/event-page';
|
||||
import GroupPage from 'soapbox/pages/group-page';
|
||||
import GroupsPage from 'soapbox/pages/groups-page';
|
||||
import HomePage from 'soapbox/pages/home-page';
|
||||
import ProfilePage from 'soapbox/pages/profile-page';
|
||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||
|
@ -112,6 +114,12 @@ import {
|
|||
EventInformation,
|
||||
EventDiscussion,
|
||||
Events,
|
||||
Groups,
|
||||
GroupMembers,
|
||||
GroupTimeline,
|
||||
ManageGroup,
|
||||
GroupBlockedMembers,
|
||||
GroupMembershipRequests,
|
||||
} from './util/async-components';
|
||||
import { WrappedRoute } from './util/react-router-helpers';
|
||||
|
||||
|
@ -272,6 +280,13 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
{features.events && <WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />}
|
||||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||
|
||||
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage/blocks' exact page={DefaultPage} component={GroupBlockedMembers} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={DefaultPage} component={GroupMembershipRequests} content={children} />}
|
||||
|
||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
||||
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
||||
|
|
|
@ -541,3 +541,39 @@ export function EventParticipantsModal() {
|
|||
export function Events() {
|
||||
return import(/* webpackChunkName: "features/events" */'../../events');
|
||||
}
|
||||
|
||||
export function Groups() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups');
|
||||
}
|
||||
|
||||
export function GroupMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||
}
|
||||
|
||||
export function GroupTimeline() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
||||
}
|
||||
|
||||
export function ManageGroup() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/manage-group');
|
||||
}
|
||||
|
||||
export function GroupBlockedMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-blocked-members');
|
||||
}
|
||||
|
||||
export function GroupMembershipRequests() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-membership-requests');
|
||||
}
|
||||
|
||||
export function ManageGroupModal() {
|
||||
return import(/* webpackChunkName: "features/manage_group_modal" */'../components/modals/manage-group-modal/manage-group-modal');
|
||||
}
|
||||
|
||||
export function NewGroupPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
|
||||
}
|
||||
|
||||
export function GroupMediaPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Group relationship normalizer:
|
||||
* Converts API group relationships into our internal format.
|
||||
*/
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
Record as ImmutableRecord,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
export const GroupRelationshipRecord = ImmutableRecord({
|
||||
id: '',
|
||||
member: false,
|
||||
requested: false,
|
||||
role: null as 'admin' | 'moderator' | 'user' | null,
|
||||
});
|
||||
|
||||
export const normalizeGroupRelationship = (relationship: Record<string, any>) => {
|
||||
return GroupRelationshipRecord(
|
||||
ImmutableMap(fromJS(relationship)),
|
||||
);
|
||||
};
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Group normalizer:
|
||||
* Converts API groups into our internal format.
|
||||
*/
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
Record as ImmutableRecord,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||
import { unescapeHTML } from 'soapbox/utils/html';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { Emoji, GroupRelationship } from 'soapbox/types/entities';
|
||||
|
||||
export const GroupRecord = ImmutableRecord({
|
||||
avatar: '',
|
||||
avatar_static: '',
|
||||
created_at: '',
|
||||
display_name: '',
|
||||
domain: '',
|
||||
emojis: ImmutableList<Emoji>(),
|
||||
header: '',
|
||||
header_static: '',
|
||||
id: '',
|
||||
locked: false,
|
||||
membership_required: false,
|
||||
note: '',
|
||||
statuses_visibility: 'public',
|
||||
uri: '',
|
||||
url: '',
|
||||
|
||||
// Internal fields
|
||||
display_name_html: '',
|
||||
note_emojified: '',
|
||||
note_plain: '',
|
||||
relationship: null as GroupRelationship | null,
|
||||
});
|
||||
|
||||
/** Add avatar, if missing */
|
||||
const normalizeAvatar = (group: ImmutableMap<string, any>) => {
|
||||
const avatar = group.get('avatar');
|
||||
const avatarStatic = group.get('avatar_static');
|
||||
const missing = require('assets/images/avatar-missing.png');
|
||||
|
||||
return group.withMutations(group => {
|
||||
group.set('avatar', avatar || avatarStatic || missing);
|
||||
group.set('avatar_static', avatarStatic || avatar || missing);
|
||||
});
|
||||
};
|
||||
|
||||
/** Add header, if missing */
|
||||
const normalizeHeader = (group: ImmutableMap<string, any>) => {
|
||||
const header = group.get('header');
|
||||
const headerStatic = group.get('header_static');
|
||||
const missing = require('assets/images/header-missing.png');
|
||||
|
||||
return group.withMutations(group => {
|
||||
group.set('header', header || headerStatic || missing);
|
||||
group.set('header_static', headerStatic || header || missing);
|
||||
});
|
||||
};
|
||||
|
||||
/** Normalize emojis */
|
||||
const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
|
||||
const emojis = entity.get('emojis', ImmutableList()).map(normalizeEmoji);
|
||||
return entity.set('emojis', emojis);
|
||||
};
|
||||
|
||||
/** Set display name from username, if applicable */
|
||||
const fixDisplayName = (group: ImmutableMap<string, any>) => {
|
||||
const displayName = group.get('display_name') || '';
|
||||
return group.set('display_name', displayName.trim().length === 0 ? group.get('username') : displayName);
|
||||
};
|
||||
|
||||
/** Emojification, etc */
|
||||
const addInternalFields = (group: ImmutableMap<string, any>) => {
|
||||
const emojiMap = makeEmojiMap(group.get('emojis'));
|
||||
|
||||
return group.withMutations((group: ImmutableMap<string, any>) => {
|
||||
// Emojify group properties
|
||||
group.merge({
|
||||
display_name_html: emojify(escapeTextContentForBrowser(group.get('display_name')), emojiMap),
|
||||
note_emojified: emojify(group.get('note', ''), emojiMap),
|
||||
note_plain: unescapeHTML(group.get('note', '')),
|
||||
});
|
||||
|
||||
// Emojify fields
|
||||
group.update('fields', ImmutableList(), fields => {
|
||||
return fields.map((field: ImmutableMap<string, any>) => {
|
||||
return field.merge({
|
||||
name_emojified: emojify(escapeTextContentForBrowser(field.get('name')), emojiMap),
|
||||
value_emojified: emojify(field.get('value'), emojiMap),
|
||||
value_plain: unescapeHTML(field.get('value')),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getDomainFromURL = (group: ImmutableMap<string, any>): string => {
|
||||
try {
|
||||
const url = group.get('url');
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const guessFqn = (group: ImmutableMap<string, any>): string => {
|
||||
const acct = group.get('acct', '');
|
||||
const [user, domain] = acct.split('@');
|
||||
|
||||
if (domain) {
|
||||
return acct;
|
||||
} else {
|
||||
return [user, getDomainFromURL(group)].join('@');
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeFqn = (group: ImmutableMap<string, any>) => {
|
||||
const fqn = group.get('fqn') || guessFqn(group);
|
||||
return group.set('fqn', fqn);
|
||||
};
|
||||
|
||||
|
||||
/** Rewrite `<p></p>` to empty string. */
|
||||
const fixNote = (group: ImmutableMap<string, any>) => {
|
||||
if (group.get('note') === '<p></p>') {
|
||||
return group.set('note', '');
|
||||
} else {
|
||||
return group;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeGroup = (group: Record<string, any>) => {
|
||||
return GroupRecord(
|
||||
ImmutableMap(fromJS(group)).withMutations(group => {
|
||||
normalizeEmojis(group);
|
||||
normalizeAvatar(group);
|
||||
normalizeHeader(group);
|
||||
normalizeFqn(group);
|
||||
fixDisplayName(group);
|
||||
fixNote(group);
|
||||
addInternalFields(group);
|
||||
}),
|
||||
);
|
||||
};
|
|
@ -9,6 +9,8 @@ export { ChatRecord, normalizeChat } from './chat';
|
|||
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
||||
export { EmojiRecord, normalizeEmoji } from './emoji';
|
||||
export { FilterRecord, normalizeFilter } from './filter';
|
||||
export { GroupRecord, normalizeGroup } from './group';
|
||||
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
|
||||
export { HistoryRecord, normalizeHistory } from './history';
|
||||
export { InstanceRecord, normalizeInstance } from './instance';
|
||||
export { ListRecord, normalizeList } from './list';
|
||||
|
|
|
@ -17,8 +17,9 @@ import { normalizeMention } from 'soapbox/normalizers/mention';
|
|||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
||||
|
||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
|
||||
export type StatusApprovalStatus = 'pending' | 'approval' | 'rejected';
|
||||
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self';
|
||||
|
||||
export type EventJoinMode = 'free' | 'restricted' | 'invite';
|
||||
|
@ -40,6 +41,7 @@ export const EventRecord = ImmutableRecord({
|
|||
export const StatusRecord = ImmutableRecord({
|
||||
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||
application: null as ImmutableMap<string, any> | null,
|
||||
approval_status: 'approved' as StatusApprovalStatus,
|
||||
bookmarked: false,
|
||||
card: null as Card | null,
|
||||
content: '',
|
||||
|
@ -48,7 +50,7 @@ export const StatusRecord = ImmutableRecord({
|
|||
emojis: ImmutableList<Emoji>(),
|
||||
favourited: false,
|
||||
favourites_count: 0,
|
||||
group: null as EmbeddedEntity<any>,
|
||||
group: null as EmbeddedEntity<Group>,
|
||||
in_reply_to_account_id: null as string | null,
|
||||
in_reply_to_id: null as string | null,
|
||||
id: '',
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { fetchGroup } from 'soapbox/actions/groups';
|
||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||
import { Column, Layout } from 'soapbox/components/ui';
|
||||
import GroupHeader from 'soapbox/features/group/components/group-header';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import {
|
||||
CtaBanner,
|
||||
GroupMediaPanel,
|
||||
SignUpPanel,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
|
||||
import { Tabs } from '../components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
all: { id: 'group.tabs.all', defaultMessage: 'All' },
|
||||
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
|
||||
});
|
||||
|
||||
interface IGroupPage {
|
||||
params?: {
|
||||
id?: string
|
||||
}
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Page to display a group. */
|
||||
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||
const intl = useIntl();
|
||||
const match = useRouteMatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const me = useAppSelector(state => state.me);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(id));
|
||||
}, [id]);
|
||||
|
||||
if ((group as any) === false) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.all),
|
||||
to: `/groups/${group?.id}`,
|
||||
name: '/groups/:id',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.members),
|
||||
to: `/groups/${group?.id}/members`,
|
||||
name: '/groups/:id/members',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<Column label={group ? group.display_name : ''} withHeader={false}>
|
||||
<GroupHeader group={group} />
|
||||
|
||||
<Tabs
|
||||
items={items}
|
||||
activeItem={match.path}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</Column>
|
||||
|
||||
{!me && (
|
||||
<BundleContainer fetchComponent={CtaBanner}>
|
||||
{Component => <Component key='cta-banner' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
</Layout.Main>
|
||||
|
||||
<Layout.Aside>
|
||||
{!me && (
|
||||
<BundleContainer fetchComponent={SignUpPanel}>
|
||||
{Component => <Component key='sign-up-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
<BundleContainer fetchComponent={GroupMediaPanel}>
|
||||
{Component => <Component group={group} />}
|
||||
</BundleContainer>
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupPage;
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Column, Layout } from 'soapbox/components/ui';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import {
|
||||
NewGroupPanel,
|
||||
CtaBanner,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
interface IGroupsPage {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Page to display groups. */
|
||||
const GroupsPage: React.FC<IGroupsPage> = ({ children }) => {
|
||||
const me = useAppSelector(state => state.me);
|
||||
// const match = useRouteMatch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<Column withHeader={false}>
|
||||
<div className='space-y-4'>
|
||||
{children}
|
||||
</div>
|
||||
</Column>
|
||||
|
||||
{!me && (
|
||||
<BundleContainer fetchComponent={CtaBanner}>
|
||||
{Component => <Component key='cta-banner' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
</Layout.Main>
|
||||
|
||||
<Layout.Aside>
|
||||
<BundleContainer fetchComponent={NewGroupPanel}>
|
||||
{Component => <Component key='new-group-panel' />}
|
||||
</BundleContainer>
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPage;
|
|
@ -8,6 +8,7 @@ import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'soapbox/actions/importer';
|
|||
import { STREAMING_FOLLOW_RELATIONSHIPS_UPDATE } from 'soapbox/actions/streaming';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const CounterRecord = ImmutableRecord({
|
||||
followers_count: 0,
|
||||
|
@ -17,7 +18,6 @@ const CounterRecord = ImmutableRecord({
|
|||
|
||||
type Counter = ReturnType<typeof CounterRecord>;
|
||||
type State = ImmutableMap<string, Counter>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const normalizeAccount = (state: State, account: APIEntity) => state.set(account.id, CounterRecord({
|
||||
|
|
|
@ -12,10 +12,11 @@ import type { AnyAction } from 'redux';
|
|||
|
||||
const MetaRecord = ImmutableRecord({
|
||||
pleroma: ImmutableMap<string, any>(),
|
||||
role: null as ImmutableMap<string, any> | null,
|
||||
source: ImmutableMap<string, any>(),
|
||||
});
|
||||
|
||||
type Meta = ReturnType<typeof MetaRecord>;
|
||||
export type Meta = ReturnType<typeof MetaRecord>;
|
||||
type State = ImmutableMap<string, Meta>;
|
||||
|
||||
const importAccount = (state: State, account: ImmutableMap<string, any>) => {
|
||||
|
@ -23,6 +24,7 @@ const importAccount = (state: State, account: ImmutableMap<string, any>) => {
|
|||
|
||||
return state.set(accountId, MetaRecord({
|
||||
pleroma: account.get('pleroma', ImmutableMap()).delete('settings_store'),
|
||||
role: account.get('role', null),
|
||||
source: account.get('source', ImmutableMap()),
|
||||
}));
|
||||
};
|
||||
|
|
|
@ -39,10 +39,10 @@ import { normalizeAccount } from 'soapbox/normalizers/account';
|
|||
import { normalizeId } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type AccountRecord = ReturnType<typeof normalizeAccount>;
|
||||
type AccountMap = ImmutableMap<string, any>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
export interface ReducerAccount extends AccountRecord {
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
export const LogEntryRecord = ImmutableRecord({
|
||||
data: ImmutableMap<string, any>(),
|
||||
|
@ -23,7 +24,6 @@ const ReducerRecord = ImmutableRecord({
|
|||
|
||||
type LogEntry = ReturnType<typeof LogEntryRecord>;
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const parseItems = (items: APIEntities) => {
|
||||
|
|
|
@ -11,8 +11,8 @@ import {
|
|||
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, ImmutableOrderedSet<string>>;
|
||||
|
|
|
@ -13,9 +13,9 @@ import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
|||
import { normalizeChatMessage } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type ChatMessageRecord = ReturnType<typeof normalizeChatMessage>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, ChatMessageRecord>;
|
||||
|
|
|
@ -14,9 +14,9 @@ import { normalizeChat } from 'soapbox/normalizers';
|
|||
import { normalizeId } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type ChatRecord = ReturnType<typeof normalizeChat>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
export interface ReducerChat extends ChatRecord {
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
COMPOSE_REPLY_CANCEL,
|
||||
COMPOSE_QUOTE,
|
||||
COMPOSE_QUOTE_CANCEL,
|
||||
COMPOSE_GROUP_POST,
|
||||
COMPOSE_DIRECT,
|
||||
COMPOSE_MENTION,
|
||||
COMPOSE_SUBMIT_REQUEST,
|
||||
|
@ -78,6 +79,7 @@ export const ReducerCompose = ImmutableRecord({
|
|||
caretPosition: null as number | null,
|
||||
content_type: 'text/plain',
|
||||
focusDate: null as Date | null,
|
||||
group_id: null as string | null,
|
||||
idempotencyKey: '',
|
||||
id: null as string | null,
|
||||
in_reply_to: null as string | null,
|
||||
|
@ -202,6 +204,9 @@ const insertEmoji = (compose: Compose, position: number, emojiData: Emoji, needs
|
|||
|
||||
const privacyPreference = (a: string, b: string) => {
|
||||
const order = ['public', 'unlisted', 'private', 'direct'];
|
||||
|
||||
if (a === 'group') return a;
|
||||
|
||||
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
|
||||
};
|
||||
|
||||
|
@ -309,6 +314,7 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||
const defaultCompose = state.get('default')!;
|
||||
|
||||
map.set('group_id', action.status.getIn(['group', 'id']) || action.status.get('group'));
|
||||
map.set('in_reply_to', action.status.get('id'));
|
||||
map.set('to', action.explicitAddressing ? statusToMentionsArray(action.status, action.account) : ImmutableOrderedSet<string>());
|
||||
map.set('text', !action.explicitAddressing ? statusToTextMentions(action.status, action.account) : '');
|
||||
|
@ -351,6 +357,10 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
return updateCompose(state, action.id, () => state.get('default')!.withMutations(map => {
|
||||
map.set('idempotencyKey', uuid());
|
||||
map.set('in_reply_to', action.id.startsWith('reply:') ? action.id.slice(6) : null);
|
||||
if (action.id.startsWith('group:')) {
|
||||
map.set('privacy', 'group');
|
||||
map.set('group_id', action.id.slice(6));
|
||||
}
|
||||
}));
|
||||
case COMPOSE_SUBMIT_FAIL:
|
||||
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
|
||||
|
@ -381,6 +391,14 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
map.set('caretPosition', null);
|
||||
map.set('idempotencyKey', uuid());
|
||||
}));
|
||||
case COMPOSE_GROUP_POST:
|
||||
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||
map.set('privacy', 'group');
|
||||
map.set('group_id', action.group_id);
|
||||
map.set('focusDate', new Date());
|
||||
map.set('caretPosition', null);
|
||||
map.set('idempotencyKey', uuid());
|
||||
}));
|
||||
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||
return updateCompose(state, action.id, compose => compose.update('suggestions', list => list?.clear()).set('suggestion_token', null));
|
||||
case COMPOSE_SUGGESTIONS_READY:
|
||||
|
@ -427,6 +445,7 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
map.set('idempotencyKey', uuid());
|
||||
map.set('content_type', action.contentType || 'text/plain');
|
||||
map.set('quote', action.status.get('quote'));
|
||||
map.set('group_id', action.status.get('group'));
|
||||
|
||||
if (action.v?.software === PLEROMA && action.withRedraft && hasIntegerMediaIds(action.status)) {
|
||||
map.set('media_attachments', ImmutableList());
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
import { Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import {
|
||||
GROUP_EDITOR_RESET,
|
||||
GROUP_EDITOR_TITLE_CHANGE,
|
||||
GROUP_EDITOR_DESCRIPTION_CHANGE,
|
||||
GROUP_EDITOR_PRIVACY_CHANGE,
|
||||
GROUP_EDITOR_MEDIA_CHANGE,
|
||||
GROUP_CREATE_REQUEST,
|
||||
GROUP_CREATE_FAIL,
|
||||
GROUP_CREATE_SUCCESS,
|
||||
GROUP_UPDATE_REQUEST,
|
||||
GROUP_UPDATE_FAIL,
|
||||
GROUP_UPDATE_SUCCESS,
|
||||
GROUP_EDITOR_SET,
|
||||
} from 'soapbox/actions/groups';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
groupId: null as string | null,
|
||||
progress: 0,
|
||||
isUploading: false,
|
||||
isSubmitting: false,
|
||||
isChanged: false,
|
||||
displayName: '',
|
||||
note: '',
|
||||
avatar: null as File | null,
|
||||
header: null as File | null,
|
||||
locked: false,
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
export default function groupEditor(state: State = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case GROUP_EDITOR_RESET:
|
||||
return ReducerRecord();
|
||||
case GROUP_EDITOR_SET:
|
||||
return state.withMutations(map => {
|
||||
map.set('groupId', action.group.id);
|
||||
map.set('displayName', action.group.display_name);
|
||||
map.set('note', action.group.note);
|
||||
});
|
||||
case GROUP_EDITOR_TITLE_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
map.set('displayName', action.value);
|
||||
map.set('isChanged', true);
|
||||
});
|
||||
case GROUP_EDITOR_DESCRIPTION_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
map.set('note', action.value);
|
||||
map.set('isChanged', true);
|
||||
});
|
||||
case GROUP_EDITOR_PRIVACY_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
map.set('locked', action.value);
|
||||
map.set('isChanged', true);
|
||||
});
|
||||
case GROUP_EDITOR_MEDIA_CHANGE:
|
||||
return state.set(action.mediaType, action.value);
|
||||
case GROUP_CREATE_REQUEST:
|
||||
case GROUP_UPDATE_REQUEST:
|
||||
return state.withMutations(map => {
|
||||
map.set('isSubmitting', true);
|
||||
map.set('isChanged', false);
|
||||
});
|
||||
case GROUP_CREATE_FAIL:
|
||||
case GROUP_UPDATE_FAIL:
|
||||
return state.set('isSubmitting', false);
|
||||
case GROUP_CREATE_SUCCESS:
|
||||
case GROUP_UPDATE_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('isSubmitting', false);
|
||||
map.set('groupId', action.group.id);
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import {
|
||||
GROUP_DELETE_SUCCESS,
|
||||
GROUP_MEMBERSHIPS_FETCH_REQUEST,
|
||||
GROUP_MEMBERSHIPS_FETCH_FAIL,
|
||||
GROUP_MEMBERSHIPS_FETCH_SUCCESS,
|
||||
GROUP_MEMBERSHIPS_EXPAND_REQUEST,
|
||||
GROUP_MEMBERSHIPS_EXPAND_FAIL,
|
||||
GROUP_MEMBERSHIPS_EXPAND_SUCCESS,
|
||||
GROUP_PROMOTE_SUCCESS,
|
||||
GROUP_DEMOTE_SUCCESS,
|
||||
GROUP_KICK_SUCCESS,
|
||||
GROUP_BLOCK_SUCCESS,
|
||||
} from 'soapbox/actions/groups';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const ListRecord = ImmutableRecord({
|
||||
next: null as string | null,
|
||||
isLoading: false,
|
||||
items: ImmutableOrderedSet<string>(),
|
||||
});
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
admin: ImmutableMap<string, List>({}),
|
||||
moderator: ImmutableMap<string, List>({}),
|
||||
user: ImmutableMap<string, List>({}),
|
||||
});
|
||||
|
||||
export type GroupRole = 'admin' | 'moderator' | 'user';
|
||||
export type List = ReturnType<typeof ListRecord>;
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
const normalizeList = (state: State, path: string[], memberships: APIEntity[], next: string | null) => {
|
||||
return state.setIn(path, ListRecord({
|
||||
next,
|
||||
items: ImmutableOrderedSet(memberships.map(item => item.account.id)),
|
||||
isLoading: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const appendToList = (state: State, path: string[], memberships: APIEntity[], next: string | null) => {
|
||||
return state.updateIn(path, map => {
|
||||
return (map as List).set('next', next).set('isLoading', false).update('items', list => list.concat(memberships.map(item => item.account.id)));
|
||||
});
|
||||
};
|
||||
|
||||
const updateLists = (state: State, groupId: string, memberships: APIEntity[]) => {
|
||||
const updateList = (state: State, role: string, membership: APIEntity) => {
|
||||
if (role === membership.role) {
|
||||
return state.updateIn([role, groupId], map => (map as List).update('items', set => set.add(membership.account.id)));
|
||||
} else {
|
||||
return state.updateIn([role, groupId], map => (map as List).update('items', set => set.delete(membership.account.id)));
|
||||
}
|
||||
};
|
||||
|
||||
memberships.forEach(membership => {
|
||||
state = updateList(state, 'admin', membership);
|
||||
state = updateList(state, 'moderator', membership);
|
||||
state = updateList(state, 'user', membership);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const removeFromList = (state: State, path: string[], accountId: string) => {
|
||||
return state.updateIn(path, map => {
|
||||
return (map as List).update('items', set => set.delete(accountId));
|
||||
});
|
||||
};
|
||||
|
||||
export default function groupMemberships(state: State = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case GROUP_DELETE_SUCCESS:
|
||||
return state.deleteIn(['admin', action.id]).deleteIn(['moderator', action.id]).deleteIn(['user', action.id]);
|
||||
case GROUP_MEMBERSHIPS_FETCH_REQUEST:
|
||||
case GROUP_MEMBERSHIPS_EXPAND_REQUEST:
|
||||
return state.updateIn([action.role, action.id], map => (map as List || ListRecord()).set('isLoading', true));
|
||||
case GROUP_MEMBERSHIPS_FETCH_FAIL:
|
||||
case GROUP_MEMBERSHIPS_EXPAND_FAIL:
|
||||
return state.updateIn([action.role, action.id], map => (map as List || ListRecord()).set('isLoading', false));
|
||||
case GROUP_MEMBERSHIPS_FETCH_SUCCESS:
|
||||
return normalizeList(state, [action.role, action.id], action.memberships, action.next);
|
||||
case GROUP_MEMBERSHIPS_EXPAND_SUCCESS:
|
||||
return appendToList(state, [action.role, action.id], action.memberships, action.next);
|
||||
case GROUP_PROMOTE_SUCCESS:
|
||||
case GROUP_DEMOTE_SUCCESS:
|
||||
return updateLists(state, action.groupId, action.memberships);
|
||||
case GROUP_KICK_SUCCESS:
|
||||
case GROUP_BLOCK_SUCCESS:
|
||||
state = removeFromList(state, ['admin', action.groupId], action.accountId);
|
||||
state = removeFromList(state, ['moderator', action.groupId], action.accountId);
|
||||
state = removeFromList(state, ['user', action.groupId], action.accountId);
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
GROUP_CREATE_SUCCESS,
|
||||
GROUP_UPDATE_SUCCESS,
|
||||
GROUP_DELETE_SUCCESS,
|
||||
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||
GROUP_JOIN_REQUEST,
|
||||
GROUP_JOIN_SUCCESS,
|
||||
GROUP_JOIN_FAIL,
|
||||
GROUP_LEAVE_REQUEST,
|
||||
GROUP_LEAVE_SUCCESS,
|
||||
GROUP_LEAVE_FAIL,
|
||||
} from 'soapbox/actions/groups';
|
||||
import { normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type GroupRelationshipRecord = ReturnType<typeof normalizeGroupRelationship>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, GroupRelationshipRecord>;
|
||||
|
||||
const normalizeRelationships = (state: State, relationships: APIEntities) => {
|
||||
relationships.forEach(relationship => {
|
||||
state = state.set(relationship.id, normalizeGroupRelationship(relationship));
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default function groupRelationships(state: State = ImmutableMap(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case GROUP_CREATE_SUCCESS:
|
||||
case GROUP_UPDATE_SUCCESS:
|
||||
return state.set(action.group.id, normalizeGroupRelationship({ id: action.group.id, member: true, requested: false, role: 'admin' }));
|
||||
case GROUP_DELETE_SUCCESS:
|
||||
return state.delete(action.id);
|
||||
case GROUP_JOIN_REQUEST:
|
||||
return state.getIn([action.id, 'member']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'member'], true);
|
||||
case GROUP_JOIN_FAIL:
|
||||
return state.setIn([action.id, action.locked ? 'requested' : 'member'], false);
|
||||
case GROUP_LEAVE_REQUEST:
|
||||
return state.setIn([action.id, 'member'], false);
|
||||
case GROUP_LEAVE_FAIL:
|
||||
return state.setIn([action.id, 'member'], true);
|
||||
case GROUP_JOIN_SUCCESS:
|
||||
case GROUP_LEAVE_SUCCESS:
|
||||
return normalizeRelationships(state, [action.relationship]);
|
||||
case GROUP_RELATIONSHIPS_FETCH_SUCCESS:
|
||||
return normalizeRelationships(state, action.relationships);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import { GROUP_FETCH_FAIL, GROUP_DELETE_SUCCESS, GROUP_FETCH_REQUEST } from 'soapbox/actions/groups';
|
||||
import { GROUPS_IMPORT } from 'soapbox/actions/importer';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type GroupRecord = ReturnType<typeof normalizeGroup>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
isLoading: true,
|
||||
items: ImmutableMap<string, GroupRecord | false>({}),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
const normalizeGroups = (state: State, groups: APIEntities) =>
|
||||
state.update('items', items =>
|
||||
groups.reduce((items: ImmutableMap<string, GroupRecord | false>, group) =>
|
||||
items.set(group.id, normalizeGroup(group)), items),
|
||||
).set('isLoading', false);
|
||||
|
||||
export default function groups(state: State = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case GROUPS_IMPORT:
|
||||
return normalizeGroups(state, action.groups);
|
||||
case GROUP_FETCH_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case GROUP_DELETE_SUCCESS:
|
||||
case GROUP_FETCH_FAIL:
|
||||
return state
|
||||
.setIn(['items', action.id], false)
|
||||
.set('isLoading', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -26,6 +26,10 @@ import custom_emojis from './custom-emojis';
|
|||
import domain_lists from './domain-lists';
|
||||
import dropdown_menu from './dropdown-menu';
|
||||
import filters from './filters';
|
||||
import group_editor from './group-editor';
|
||||
import group_memberships from './group-memberships';
|
||||
import group_relationships from './group-relationships';
|
||||
import groups from './groups';
|
||||
import history from './history';
|
||||
import instance from './instance';
|
||||
import listAdder from './list-adder';
|
||||
|
@ -120,6 +124,10 @@ const reducers = {
|
|||
announcements,
|
||||
compose_event,
|
||||
admin_user_index,
|
||||
groups,
|
||||
group_relationships,
|
||||
group_memberships,
|
||||
group_editor,
|
||||
};
|
||||
|
||||
// Build a default state from all reducers: it has the key and `undefined`
|
||||
|
|
|
@ -11,9 +11,9 @@ import {
|
|||
import { normalizeList } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type ListRecord = ReturnType<typeof normalizeList>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, ListRecord | false>;
|
||||
|
|
|
@ -33,10 +33,10 @@ import {
|
|||
} from '../actions/importer';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
type Relationship = ReturnType<typeof normalizeRelationship>;
|
||||
type State = ImmutableMap<string, Relationship>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const normalizeRelationships = (state: State, relationships: APIEntities) => {
|
||||
|
|
|
@ -27,12 +27,15 @@ import type { APIEntity, Tag } from 'soapbox/types/entities';
|
|||
const ResultsRecord = ImmutableRecord({
|
||||
accounts: ImmutableOrderedSet<string>(),
|
||||
statuses: ImmutableOrderedSet<string>(),
|
||||
groups: ImmutableOrderedSet<string>(),
|
||||
hashtags: ImmutableOrderedSet<Tag>(), // it's a list of maps
|
||||
accountsHasMore: false,
|
||||
statusesHasMore: false,
|
||||
groupsHasMore: false,
|
||||
hashtagsHasMore: false,
|
||||
accountsLoaded: false,
|
||||
statusesLoaded: false,
|
||||
groupsLoaded: false,
|
||||
hashtagsLoaded: false,
|
||||
});
|
||||
|
||||
|
@ -48,9 +51,9 @@ const ReducerRecord = ImmutableRecord({
|
|||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
export type SearchFilter = 'accounts' | 'statuses' | 'hashtags';
|
||||
export type SearchFilter = 'accounts' | 'statuses' | 'groups' | 'hashtags';
|
||||
|
||||
const toIds = (items: APIEntities) => {
|
||||
const toIds = (items: APIEntities = []) => {
|
||||
return ImmutableOrderedSet(items.map(item => item.id));
|
||||
};
|
||||
|
||||
|
@ -60,12 +63,15 @@ const importResults = (state: State, results: APIEntity, searchTerm: string, sea
|
|||
state.set('results', ResultsRecord({
|
||||
accounts: toIds(results.accounts),
|
||||
statuses: toIds(results.statuses),
|
||||
groups: toIds(results.groups),
|
||||
hashtags: ImmutableOrderedSet(results.hashtags.map(normalizeTag)), // it's a list of records
|
||||
accountsHasMore: results.accounts.length >= 20,
|
||||
statusesHasMore: results.statuses.length >= 20,
|
||||
groupsHasMore: results.groups?.length >= 20,
|
||||
hashtagsHasMore: results.hashtags.length >= 20,
|
||||
accountsLoaded: true,
|
||||
statusesLoaded: true,
|
||||
groupsLoaded: true,
|
||||
hashtagsLoaded: true,
|
||||
}));
|
||||
|
||||
|
|
|
@ -42,11 +42,11 @@ import {
|
|||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
type StatusRecord = ReturnType<typeof normalizeStatus>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
type State = ImmutableMap<string, ReducerStatus>;
|
||||
|
@ -56,6 +56,7 @@ export interface ReducerStatus extends StatusRecord {
|
|||
reblog: string | null,
|
||||
poll: string | null,
|
||||
quote: string | null,
|
||||
group: string | null,
|
||||
}
|
||||
|
||||
const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
||||
|
@ -64,6 +65,7 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
|||
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
||||
poll: normalizeId(status.getIn(['poll', 'id'])),
|
||||
quote: normalizeId(status.getIn(['quote', 'id'])),
|
||||
group: normalizeId(status.getIn(['group', 'id'])),
|
||||
}) as ReducerStatus;
|
||||
};
|
||||
|
||||
|
|
|
@ -35,7 +35,6 @@ import {
|
|||
} from '../actions/timelines';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { StatusVisibility } from 'soapbox/normalizers/status';
|
||||
import type { APIEntity, Status } from 'soapbox/types/entities';
|
||||
|
||||
const TRUNCATE_LIMIT = 40;
|
||||
|
@ -242,8 +241,10 @@ const timelineDisconnect = (state: State, timelineId: string) => {
|
|||
}));
|
||||
};
|
||||
|
||||
const getTimelinesByVisibility = (visibility: StatusVisibility) => {
|
||||
switch (visibility) {
|
||||
const getTimelinesForStatus = (status: APIEntity) => {
|
||||
switch (status.visibility) {
|
||||
case 'group':
|
||||
return [`group:${status.group?.id || status.group_id}`];
|
||||
case 'direct':
|
||||
return ['direct'];
|
||||
case 'public':
|
||||
|
@ -269,7 +270,7 @@ const importPendingStatus = (state: State, params: APIEntity, idempotencyKey: st
|
|||
const statusId = `末pending-${idempotencyKey}`;
|
||||
|
||||
return state.withMutations(state => {
|
||||
const timelineIds = getTimelinesByVisibility(params.visibility);
|
||||
const timelineIds = getTimelinesForStatus(params);
|
||||
|
||||
timelineIds.forEach(timelineId => {
|
||||
updateTimelineQueue(state, timelineId, statusId);
|
||||
|
@ -293,7 +294,7 @@ const importStatus = (state: State, status: APIEntity, idempotencyKey: string) =
|
|||
return state.withMutations(state => {
|
||||
replacePendingStatus(state, idempotencyKey, status.id);
|
||||
|
||||
const timelineIds = getTimelinesByVisibility(status.visibility);
|
||||
const timelineIds = getTimelinesForStatus(status);
|
||||
|
||||
timelineIds.forEach(timelineId => {
|
||||
updateTimeline(state, timelineId, status.id);
|
||||
|
|
|
@ -40,6 +40,23 @@ import {
|
|||
import {
|
||||
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||
} from 'soapbox/actions/familiar-followers';
|
||||
import {
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL,
|
||||
GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
|
||||
GROUP_BLOCKS_FETCH_REQUEST,
|
||||
GROUP_BLOCKS_FETCH_SUCCESS,
|
||||
GROUP_BLOCKS_FETCH_FAIL,
|
||||
GROUP_BLOCKS_EXPAND_REQUEST,
|
||||
GROUP_BLOCKS_EXPAND_SUCCESS,
|
||||
GROUP_BLOCKS_EXPAND_FAIL,
|
||||
GROUP_UNBLOCK_SUCCESS,
|
||||
} from 'soapbox/actions/groups';
|
||||
import {
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_SUCCESS,
|
||||
|
@ -99,6 +116,8 @@ export const ReducerRecord = ImmutableRecord({
|
|||
familiar_followers: ImmutableMap<string, List>(),
|
||||
event_participations: ImmutableMap<string, List>(),
|
||||
event_participation_requests: ImmutableMap<string, ParticipationRequestList>(),
|
||||
membership_requests: ImmutableMap<string, List>(),
|
||||
group_blocks: ImmutableMap<string, List>(),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
@ -108,7 +127,7 @@ type ReactionList = ReturnType<typeof ReactionListRecord>;
|
|||
type ParticipationRequest = ReturnType<typeof ParticipationRequestRecord>;
|
||||
type ParticipationRequestList = ReturnType<typeof ParticipationRequestListRecord>;
|
||||
type Items = ImmutableOrderedSet<string>;
|
||||
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests', string];
|
||||
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests' | 'membership_requests' | 'group_blocks', string];
|
||||
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
|
||||
|
||||
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
|
||||
|
@ -220,6 +239,31 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) {
|
|||
['event_participation_requests', action.id, 'items'],
|
||||
items => (items as ImmutableOrderedSet<ParticipationRequest>).filter(({ account }) => account !== action.accountId),
|
||||
);
|
||||
case GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['membership_requests', action.id], action.accounts, action.next);
|
||||
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['membership_requests', action.id], action.accounts, action.next);
|
||||
case GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST:
|
||||
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST:
|
||||
return state.setIn(['membership_requests', action.id, 'isLoading'], true);
|
||||
case GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL:
|
||||
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL:
|
||||
return state.setIn(['membership_requests', action.id, 'isLoading'], false);
|
||||
case GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS:
|
||||
case GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS:
|
||||
return state.updateIn(['membership_requests', action.groupId, 'items'], list => (list as ImmutableOrderedSet<string>).filterNot(item => item === action.accountId));
|
||||
case GROUP_BLOCKS_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['group_blocks', action.id], action.accounts, action.next);
|
||||
case GROUP_BLOCKS_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['group_blocks', action.id], action.accounts, action.next);
|
||||
case GROUP_BLOCKS_FETCH_REQUEST:
|
||||
case GROUP_BLOCKS_EXPAND_REQUEST:
|
||||
return state.setIn(['group_blocks', action.id, 'isLoading'], true);
|
||||
case GROUP_BLOCKS_FETCH_FAIL:
|
||||
case GROUP_BLOCKS_EXPAND_FAIL:
|
||||
return state.setIn(['group_blocks', action.id, 'isLoading'], false);
|
||||
case GROUP_UNBLOCK_SUCCESS:
|
||||
return state.updateIn(['group_blocks', action.groupId, 'items'], list => (list as ImmutableOrderedSet<string>).filterNot(item => item === action.accountId));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -144,12 +144,13 @@ export const makeGetStatus = () => {
|
|||
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.groups.items.get(state.statuses.get(id)?.group || ''),
|
||||
(_state: RootState, { username }: APIStatus) => username,
|
||||
getFilters,
|
||||
(state: RootState) => state.me,
|
||||
],
|
||||
|
||||
(statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => {
|
||||
(statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me) => {
|
||||
if (!statusBase || !accountBase) return null;
|
||||
|
||||
const accountUsername = accountBase.acct;
|
||||
|
@ -172,6 +173,8 @@ export const makeGetStatus = () => {
|
|||
map.set('reblog', statusReblog || null);
|
||||
// @ts-ignore :(
|
||||
map.set('account', accountBase || null);
|
||||
// @ts-ignore
|
||||
map.set('group', group || null);
|
||||
map.set('filtered', Boolean(filtered));
|
||||
});
|
||||
},
|
||||
|
@ -215,6 +218,25 @@ export const getAccountGallery = createSelector([
|
|||
}, ImmutableList());
|
||||
});
|
||||
|
||||
export const getGroupGallery = createSelector([
|
||||
(state: RootState, id: string) => state.timelines.get(`group:${id}:media`)?.items || ImmutableOrderedSet<string>(),
|
||||
(state: RootState) => state.statuses,
|
||||
(state: RootState) => state.accounts,
|
||||
], (statusIds, statuses, accounts) => {
|
||||
|
||||
return statusIds.reduce((medias: ImmutableList<any>, statusId: string) => {
|
||||
const status = statuses.get(statusId);
|
||||
if (!status) return medias;
|
||||
if (status.reblog) return medias;
|
||||
if (typeof status.account !== 'string') return medias;
|
||||
|
||||
const account = accounts.get(status.account);
|
||||
|
||||
return medias.concat(
|
||||
status.media_attachments.map(media => media.merge({ status, account })));
|
||||
}, ImmutableList());
|
||||
});
|
||||
|
||||
type APIChat = { id: string, last_message: string };
|
||||
|
||||
export const makeGetChat = () => {
|
||||
|
@ -350,3 +372,16 @@ export const makeGetStatusIds = () => createSelector([
|
|||
return !shouldFilter(status, columnSettings);
|
||||
});
|
||||
});
|
||||
|
||||
export const makeGetGroup = () => {
|
||||
return createSelector([
|
||||
(state: RootState, id: string) => state.groups.items.get(id),
|
||||
(state: RootState, id: string) => state.group_relationships.get(id),
|
||||
], (base, relationship) => {
|
||||
if (!base) return null;
|
||||
|
||||
return base.withMutations(map => {
|
||||
if (relationship) map.set('relationship', relationship);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -11,6 +11,8 @@ import {
|
|||
EmojiRecord,
|
||||
FieldRecord,
|
||||
FilterRecord,
|
||||
GroupRecord,
|
||||
GroupRelationshipRecord,
|
||||
HistoryRecord,
|
||||
InstanceRecord,
|
||||
ListRecord,
|
||||
|
@ -40,6 +42,8 @@ type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
|||
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||
type Field = ReturnType<typeof FieldRecord>;
|
||||
type Filter = ReturnType<typeof FilterRecord>;
|
||||
type Group = ReturnType<typeof GroupRecord>;
|
||||
type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
|
||||
type History = ReturnType<typeof HistoryRecord>;
|
||||
type Instance = ReturnType<typeof InstanceRecord>;
|
||||
type List = ReturnType<typeof ListRecord>;
|
||||
|
@ -82,6 +86,8 @@ export {
|
|||
Emoji,
|
||||
Field,
|
||||
Filter,
|
||||
Group,
|
||||
GroupRelationship,
|
||||
History,
|
||||
Instance,
|
||||
List,
|
||||
|
|
|
@ -463,6 +463,34 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
frontendConfigurations: v.software === PLEROMA,
|
||||
|
||||
/**
|
||||
* Groups.
|
||||
* @see POST /api/v1/groups
|
||||
* @see GET /api/v1/groups
|
||||
* @see GET /api/v1/groups/:id
|
||||
* @see POST /api/v1/groups/:id/join
|
||||
* @see POST /api/v1/groups/:id/leave
|
||||
* @see GET /api/v1/groups/:id/memberships
|
||||
* @see PUT /api/v1/groups/:group_id
|
||||
* @see DELETE /api/v1/groups/:group_id
|
||||
* @see GET /api/v1/groups/:group_id/membership_requests
|
||||
* @see POST /api/v1/groups/:group_id/membership_requests/:account_id/authorize
|
||||
* @see POST /api/v1/groups/:group_id/membership_requests/:account_id/reject
|
||||
* @see DELETE /api/v1/groups/:group_id/statuses/:id
|
||||
* @see POST /api/v1/groups/:group_id/kick?account_ids[]=…
|
||||
* @see GET /api/v1/groups/:group_id/blocks
|
||||
* @see POST /api/v1/groups/:group_id/blocks?account_ids[]=…
|
||||
* @see DELETE /api/v1/groups/:group_id/blocks?account_ids[]=…
|
||||
* @see POST /api/v1/groups/:group_id/promote?role=new_role&account_ids[]=…
|
||||
* @see POST /api/v1/groups/:group_id/demote?role=new_role&account_ids[]=…
|
||||
* @see GET /api/v1/admin/groups
|
||||
* @see GET /api/v1/admin/groups/:group_id
|
||||
* @see POST /api/v1/admin/groups/:group_id/suspend
|
||||
* @see POST /api/v1/admin/groups/:group_id/unsuspend
|
||||
* @see DELETE /api/v1/admin/groups/:group_id
|
||||
*/
|
||||
groups: false,
|
||||
|
||||
/**
|
||||
* Can hide follows/followers lists and counts.
|
||||
* @see PATCH /api/v1/accounts/update_credentials
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
export const PERMISSION_CREATE_GROUPS = 0x0000000000100000;
|
||||
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
|
||||
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
|
||||
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
|
||||
|
||||
type Permission = typeof PERMISSION_CREATE_GROUPS | typeof PERMISSION_INVITE_USERS | typeof PERMISSION_MANAGE_USERS | typeof PERMISSION_MANAGE_REPORTS
|
||||
|
||||
export const hasPermission = (state: RootState, permission: Permission) => {
|
||||
const account = state.accounts_meta.get(state.me as string)!;
|
||||
|
||||
if (!account?.role) return false;
|
||||
|
||||
const permissions = account.getIn(['role', 'permissions']) as number;
|
||||
|
||||
if (!permission) return true;
|
||||
return (permissions & permission) === permission;
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -39,9 +39,9 @@ body {
|
|||
}
|
||||
|
||||
&.error {
|
||||
@apply text-gray-400;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
color: var(--primary-text-color--faint);
|
||||
background: var(--brand-color--med);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
@ -83,9 +83,9 @@ noscript {
|
|||
}
|
||||
|
||||
div {
|
||||
@apply text-gray-400;
|
||||
font-size: 14px;
|
||||
margin: 30px auto;
|
||||
color: var(--primary-text-color--faint);
|
||||
max-width: 400px;
|
||||
|
||||
a {
|
||||
|
|
|
@ -25,8 +25,8 @@
|
|||
}
|
||||
|
||||
.column-link {
|
||||
@apply text-gray-900;
|
||||
background: var(--brand-color--med);
|
||||
color: var(--primary-text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
|
|
|
@ -26,9 +26,8 @@
|
|||
}
|
||||
|
||||
&__modifiers {
|
||||
color: var(--primary-text-color);
|
||||
@apply text-gray-900 text-sm;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--background-color);
|
||||
}
|
||||
}
|
||||
|
@ -184,7 +183,7 @@
|
|||
background: var(--brand-color);
|
||||
|
||||
.icon-button {
|
||||
color: var(--primary-text-color);
|
||||
@apply text-gray-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,64 +1,47 @@
|
|||
.crypto-address {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@apply flex flex-col p-5;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
@apply flex items-center mb-1.5;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
margin-right: 10px;
|
||||
@apply flex items-start justify-center w-6 mr-2.5;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
@apply flex ml-auto;
|
||||
|
||||
a {
|
||||
color: var(--primary-text-color--faint);
|
||||
margin-left: 8px;
|
||||
@apply text-gray-400 ml-2;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@apply h-4.5 w-4.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-bottom: 10px;
|
||||
@apply mb-2.5;
|
||||
}
|
||||
|
||||
&__qrcode {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@apply flex items-center justify-center mb-3 p-2.5;
|
||||
}
|
||||
|
||||
&__address {
|
||||
margin-top: auto;
|
||||
@apply mt-auto;
|
||||
}
|
||||
}
|
||||
|
||||
.crypto-donate-modal {
|
||||
.crypto-address {
|
||||
padding: 0;
|
||||
}
|
||||
.crypto-donate-modal .crypto-address {
|
||||
@apply p-0;
|
||||
}
|
||||
|
|
|
@ -51,10 +51,10 @@
|
|||
}
|
||||
|
||||
.media-gallery__item-thumbnail {
|
||||
@apply text-gray-400;
|
||||
cursor: zoom-in;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--primary-text-color--faint);
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
|
|
@ -165,8 +165,8 @@
|
|||
}
|
||||
|
||||
.error-modal {
|
||||
@apply text-gray-900;
|
||||
background: var(--background-color);
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
@ -231,19 +231,16 @@
|
|||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: var(--primary-text-color--faint);
|
||||
@apply text-gray-400;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-modal {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@apply flex-col relative text-gray-400 overflow-hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--background-color);
|
||||
color: var(--primary-text-color--faint);
|
||||
|
||||
.dropdown-menu__separator {
|
||||
@apply block m-2 h-[1px] bg-gray-200 dark:bg-gray-600;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue