some basic groups ui
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
12825f9350
commit
7c4aca51dc
13 changed files with 425 additions and 9 deletions
|
@ -47,6 +47,7 @@ const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
||||||
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||||
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||||
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||||
|
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST';
|
||||||
|
|
||||||
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||||
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||||
|
@ -469,6 +470,15 @@ const undoUploadCompose = (composeId: string, media_id: string) => ({
|
||||||
media_id: media_id,
|
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) => {
|
const clearComposeSuggestions = (composeId: string) => {
|
||||||
if (cancelFetchComposeSuggestionsAccounts) {
|
if (cancelFetchComposeSuggestionsAccounts) {
|
||||||
cancelFetchComposeSuggestionsAccounts();
|
cancelFetchComposeSuggestionsAccounts();
|
||||||
|
@ -721,7 +731,7 @@ const eventDiscussionCompose = (composeId: string, status: Status) =>
|
||||||
const instance = state.instance;
|
const instance = state.instance;
|
||||||
const { explicitAddressing } = getFeatures(instance);
|
const { explicitAddressing } = getFeatures(instance);
|
||||||
|
|
||||||
dispatch({
|
return dispatch({
|
||||||
type: COMPOSE_EVENT_REPLY,
|
type: COMPOSE_EVENT_REPLY,
|
||||||
id: composeId,
|
id: composeId,
|
||||||
status: status,
|
status: status,
|
||||||
|
@ -748,6 +758,7 @@ export {
|
||||||
COMPOSE_UPLOAD_FAIL,
|
COMPOSE_UPLOAD_FAIL,
|
||||||
COMPOSE_UPLOAD_PROGRESS,
|
COMPOSE_UPLOAD_PROGRESS,
|
||||||
COMPOSE_UPLOAD_UNDO,
|
COMPOSE_UPLOAD_UNDO,
|
||||||
|
COMPOSE_GROUP_POST,
|
||||||
COMPOSE_SUGGESTIONS_CLEAR,
|
COMPOSE_SUGGESTIONS_CLEAR,
|
||||||
COMPOSE_SUGGESTIONS_READY,
|
COMPOSE_SUGGESTIONS_READY,
|
||||||
COMPOSE_SUGGESTION_SELECT,
|
COMPOSE_SUGGESTION_SELECT,
|
||||||
|
@ -800,6 +811,7 @@ export {
|
||||||
uploadComposeSuccess,
|
uploadComposeSuccess,
|
||||||
uploadComposeFail,
|
uploadComposeFail,
|
||||||
undoUploadCompose,
|
undoUploadCompose,
|
||||||
|
groupCompose,
|
||||||
clearComposeSuggestions,
|
clearComposeSuggestions,
|
||||||
fetchComposeSuggestions,
|
fetchComposeSuggestions,
|
||||||
readyComposeSuggestionsEmojis,
|
readyComposeSuggestionsEmojis,
|
||||||
|
|
|
@ -65,6 +65,9 @@ const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: f
|
||||||
return importAccounts(normalAccounts);
|
return importAccounts(normalAccounts);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importFetchedGroup = (group: APIEntity) =>
|
||||||
|
importFetchedGroups([group]);
|
||||||
|
|
||||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||||
const normalGroups: APIEntity[] = [];
|
const normalGroups: APIEntity[] = [];
|
||||||
|
|
||||||
|
@ -112,6 +115,10 @@ const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||||
dispatch(importFetchedPoll(status.poll));
|
dispatch(importFetchedPoll(status.poll));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.group?.id) {
|
||||||
|
dispatch(importFetchedGroup(status.group));
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(importFetchedAccount(status.account));
|
dispatch(importFetchedAccount(status.account));
|
||||||
dispatch(importStatus(status, idempotencyKey));
|
dispatch(importStatus(status, idempotencyKey));
|
||||||
};
|
};
|
||||||
|
@ -161,6 +168,10 @@ const importFetchedStatuses = (statuses: APIEntity[]) =>
|
||||||
if (status.poll?.id) {
|
if (status.poll?.id) {
|
||||||
polls.push(status.poll);
|
polls.push(status.poll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.group?.id) {
|
||||||
|
dispatch(importFetchedGroup(status.group));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses.forEach(processStatus);
|
statuses.forEach(processStatus);
|
||||||
|
@ -196,6 +207,7 @@ export {
|
||||||
importPolls,
|
importPolls,
|
||||||
importFetchedAccount,
|
importFetchedAccount,
|
||||||
importFetchedAccounts,
|
importFetchedAccounts,
|
||||||
|
importFetchedGroup,
|
||||||
importFetchedGroups,
|
importFetchedGroups,
|
||||||
importFetchedStatus,
|
importFetchedStatus,
|
||||||
importFetchedStatuses,
|
importFetchedStatuses,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import classNames from 'clsx';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||||
import { NavLink, useHistory } from 'react-router-dom';
|
import { Link, NavLink, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
|
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
|
||||||
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
|
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
|
||||||
|
@ -26,6 +26,7 @@ import { Card, HStack, Stack, Text } from './ui';
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
import type {
|
import type {
|
||||||
Account as AccountEntity,
|
Account as AccountEntity,
|
||||||
|
Group as GroupEntity,
|
||||||
Status as StatusEntity,
|
Status as StatusEntity,
|
||||||
} from 'soapbox/types/entities';
|
} from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
@ -299,6 +300,8 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const group = actualStatus.group as GroupEntity | null;
|
||||||
|
|
||||||
const handlers = muted ? undefined : {
|
const handlers = muted ? undefined : {
|
||||||
reply: handleHotkeyReply,
|
reply: handleHotkeyReply,
|
||||||
favourite: handleHotkeyFavourite,
|
favourite: handleHotkeyFavourite,
|
||||||
|
@ -342,6 +345,26 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{group && (
|
||||||
|
<div className='pt-4 px-4'>
|
||||||
|
<HStack alignItems='center' space={1}>
|
||||||
|
<Icon src={require('@tabler/icons/users.svg')} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
|
||||||
|
<Text size='sm' theme='muted' weight='medium'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.group'
|
||||||
|
defaultMessage='Posted in {group}'
|
||||||
|
values={{ group: (
|
||||||
|
<Link className='hover:underline' to={`/groups/${group.id}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
</Link>
|
||||||
|
) }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
variant={variant}
|
variant={variant}
|
||||||
className={classNames('status__wrapper', `status-${actualStatus.visibility}`, {
|
className={classNames('status__wrapper', `status-${actualStatus.visibility}`, {
|
||||||
|
|
|
@ -63,9 +63,10 @@ interface IComposeForm<ID extends string> {
|
||||||
autoFocus?: boolean,
|
autoFocus?: boolean,
|
||||||
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
||||||
event?: string,
|
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 history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
@ -228,7 +229,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
{features.media && <UploadButtonContainer composeId={id} />}
|
{features.media && <UploadButtonContainer composeId={id} />}
|
||||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
|
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
|
||||||
{features.polls && <PollButton composeId={id} />}
|
{features.polls && <PollButton composeId={id} />}
|
||||||
{features.privacyScopes && <PrivacyDropdown composeId={id} />}
|
{features.privacyScopes && !group && <PrivacyDropdown composeId={id} />}
|
||||||
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
||||||
{features.spoilers && <SpoilerButton composeId={id} />}
|
{features.spoilers && <SpoilerButton composeId={id} />}
|
||||||
{features.richText && <MarkdownButton composeId={id} />}
|
{features.richText && <MarkdownButton composeId={id} />}
|
||||||
|
@ -278,7 +279,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||||
{scheduledStatusCount > 0 && !event && (
|
{scheduledStatusCount > 0 && !event && !group && (
|
||||||
<Warning
|
<Warning
|
||||||
message={(
|
message={(
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -299,9 +300,9 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
<WarningContainer composeId={id} />
|
<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
|
<AutosuggestTextarea
|
||||||
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
||||||
|
@ -357,8 +358,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
<Button type='submit' theme='primary' text={publishText} disabled={disabledButton} />
|
<Button type='submit' theme='primary' text={publishText} disabled={disabledButton} />
|
||||||
</HStack>
|
</HStack>
|
||||||
{/* <HStack alignItems='center' space={4}>
|
|
||||||
</HStack> */}
|
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
166
app/soapbox/features/group/components/group-header.tsx
Normal file
166
app/soapbox/features/group/components/group-header.tsx
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import StillImage from 'soapbox/components/still-image';
|
||||||
|
import { Avatar, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||||
|
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||||
|
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||||
|
import type { Group } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
header: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IGroupHeader {
|
||||||
|
group?: Group | false | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const ownAccount = useOwnAccount();
|
||||||
|
|
||||||
|
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 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 makeMenu = () => {
|
||||||
|
const menu: MenuType = [];
|
||||||
|
|
||||||
|
return menu;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = makeMenu();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='-mt-4 -mx-4'>
|
||||||
|
<div>
|
||||||
|
<div className='relative flex flex-col justify-center h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
|
||||||
|
{group.header && (
|
||||||
|
<a href={group.header} onClick={handleHeaderClick} target='_blank'>
|
||||||
|
<StillImage
|
||||||
|
src={group.header}
|
||||||
|
alt={intl.formatMessage(messages.header)}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='absolute top-2 left-2'>
|
||||||
|
<HStack alignItems='center' space={1}>
|
||||||
|
{/* {info} */}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='px-4 sm:px-6'>
|
||||||
|
<HStack className='-mt-12' alignItems='bottom' space={5}>
|
||||||
|
<div className='flex'>
|
||||||
|
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||||
|
<Avatar
|
||||||
|
src={group.avatar}
|
||||||
|
size={96}
|
||||||
|
className='relative h-24 w-24 rounded-full ring-4 ring-white dark:ring-primary-900'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 flex justify-end w-full sm:pb-1'>
|
||||||
|
<HStack space={2} className='mt-10'>
|
||||||
|
{ownAccount && (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupHeader;
|
27
app/soapbox/features/group/components/group-info-panel.tsx
Normal file
27
app/soapbox/features/group/components/group-info-panel.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Markup from 'soapbox/components/markup';
|
||||||
|
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { Group } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
interface IGroupInfoPanel {
|
||||||
|
group: Group,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupInfoPanel: React.FC<IGroupInfoPanel> = ({ group }) => (
|
||||||
|
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
|
||||||
|
<Stack space={2}>
|
||||||
|
<Stack>
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{group.note.length > 0 && (
|
||||||
|
<Markup size='sm' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default GroupInfoPanel;
|
62
app/soapbox/features/group/group-timeline.tsx
Normal file
62
app/soapbox/features/group/group-timeline.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
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 { Stack } from 'soapbox/components/ui';
|
||||||
|
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import Timeline from '../ui/components/timeline';
|
||||||
|
|
||||||
|
type RouteParams = { id: string };
|
||||||
|
|
||||||
|
interface IGroupTimeline {
|
||||||
|
params: RouteParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const groupId = props.params.id;
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
return dispatch(expandGroupTimeline(groupId));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGroup(groupId));
|
||||||
|
dispatch(expandGroupTimeline(groupId));
|
||||||
|
|
||||||
|
const disconnect = dispatch(connectGroupStream(groupId));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(groupCompose(`group:${groupId}`, groupId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={2}>
|
||||||
|
<div className='p-2 pt-0 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||||
|
<ComposeForm id={`group:${groupId}`} autoFocus={false} group={groupId} />
|
||||||
|
</div>
|
||||||
|
<div className='p-0 sm:p-2 shadow-none'>
|
||||||
|
<Timeline
|
||||||
|
scrollKey='group_timeline'
|
||||||
|
timelineId={`group:${groupId}`}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is no post in this group yet.' />}
|
||||||
|
divideType='space'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupTimeline;
|
|
@ -30,6 +30,7 @@ import AdminPage from 'soapbox/pages/admin-page';
|
||||||
import ChatsPage from 'soapbox/pages/chats-page';
|
import ChatsPage from 'soapbox/pages/chats-page';
|
||||||
import DefaultPage from 'soapbox/pages/default-page';
|
import DefaultPage from 'soapbox/pages/default-page';
|
||||||
import EventPage from 'soapbox/pages/event-page';
|
import EventPage from 'soapbox/pages/event-page';
|
||||||
|
import GroupPage from 'soapbox/pages/group-page';
|
||||||
import HomePage from 'soapbox/pages/home-page';
|
import HomePage from 'soapbox/pages/home-page';
|
||||||
import ProfilePage from 'soapbox/pages/profile-page';
|
import ProfilePage from 'soapbox/pages/profile-page';
|
||||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||||
|
@ -111,6 +112,7 @@ import {
|
||||||
EventInformation,
|
EventInformation,
|
||||||
EventDiscussion,
|
EventDiscussion,
|
||||||
Events,
|
Events,
|
||||||
|
GroupTimeline,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { WrappedRoute } from './util/react-router-helpers';
|
import { WrappedRoute } from './util/react-router-helpers';
|
||||||
|
|
||||||
|
@ -272,6 +274,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
||||||
<WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />
|
<WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />
|
||||||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||||
|
|
||||||
|
<WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||||
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
||||||
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
||||||
|
|
|
@ -541,3 +541,11 @@ export function EventParticipantsModal() {
|
||||||
export function Events() {
|
export function Events() {
|
||||||
return import(/* webpackChunkName: "features/events" */'../../events');
|
return import(/* webpackChunkName: "features/events" */'../../events');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupTimeline() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupInfoPanel() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/components/group-info-panel');
|
||||||
|
}
|
||||||
|
|
80
app/soapbox/pages/group-page.tsx
Normal file
80
app/soapbox/pages/group-page.tsx
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
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 {
|
||||||
|
GroupInfoPanel,
|
||||||
|
SignUpPanel,
|
||||||
|
CtaBanner,
|
||||||
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetGroup } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
interface IGroupPage {
|
||||||
|
params?: {
|
||||||
|
id?: string,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page to display a group. */
|
||||||
|
const ProfilePage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
|
const id = params?.id || '';
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const getGroup = useCallback(makeGetGroup(), []);
|
||||||
|
const group = useAppSelector(state => getGroup(state, id));
|
||||||
|
|
||||||
|
const me = useAppSelector(state => state.me);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGroup(id));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (group === false) {
|
||||||
|
return (
|
||||||
|
<MissingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
<Column label={group ? group.display_name : ''} withHeader={false}>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<GroupHeader group={group} />
|
||||||
|
|
||||||
|
{group && (
|
||||||
|
<BundleContainer fetchComponent={GroupInfoPanel}>
|
||||||
|
{Component => <Component group={group} />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
<LinkFooter key='link-footer' />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePage;
|
|
@ -11,6 +11,7 @@ import {
|
||||||
COMPOSE_REPLY_CANCEL,
|
COMPOSE_REPLY_CANCEL,
|
||||||
COMPOSE_QUOTE,
|
COMPOSE_QUOTE,
|
||||||
COMPOSE_QUOTE_CANCEL,
|
COMPOSE_QUOTE_CANCEL,
|
||||||
|
COMPOSE_GROUP_POST,
|
||||||
COMPOSE_DIRECT,
|
COMPOSE_DIRECT,
|
||||||
COMPOSE_MENTION,
|
COMPOSE_MENTION,
|
||||||
COMPOSE_SUBMIT_REQUEST,
|
COMPOSE_SUBMIT_REQUEST,
|
||||||
|
@ -386,6 +387,14 @@ export default function compose(state = initialState, action: AnyAction) {
|
||||||
map.set('caretPosition', null);
|
map.set('caretPosition', null);
|
||||||
map.set('idempotencyKey', uuid());
|
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:
|
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||||
return updateCompose(state, action.id, compose => compose.update('suggestions', list => list?.clear()).set('suggestion_token', null));
|
return updateCompose(state, action.id, compose => compose.update('suggestions', list => list?.clear()).set('suggestion_token', null));
|
||||||
case COMPOSE_SUGGESTIONS_READY:
|
case COMPOSE_SUGGESTIONS_READY:
|
||||||
|
|
|
@ -65,6 +65,7 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
||||||
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
||||||
poll: normalizeId(status.getIn(['poll', 'id'])),
|
poll: normalizeId(status.getIn(['poll', 'id'])),
|
||||||
quote: normalizeId(status.getIn(['quote', 'id'])),
|
quote: normalizeId(status.getIn(['quote', 'id'])),
|
||||||
|
group: normalizeId(status.getIn(['group', 'id'])),
|
||||||
}) as ReducerStatus;
|
}) as ReducerStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -353,3 +353,16 @@ export const makeGetStatusIds = () => createSelector([
|
||||||
return !shouldFilter(status, columnSettings);
|
return !shouldFilter(status, columnSettings);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const makeGetGroup = () => {
|
||||||
|
return createSelector([
|
||||||
|
(state: RootState, id: string) => state.groups.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in a new issue