Merge remote-tracking branch 'soapbox/develop' into lexical

This commit is contained in:
marcin mikołajczak 2023-03-23 15:09:22 +01:00
commit dadaadcdde
67 changed files with 695 additions and 555 deletions

View File

@ -8,13 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Posts: Support posts filtering on recent Mastodon versions - Posts: Support posts filtering on recent Mastodon versions
- Reactions: Support custom emoji reactions
- Compatbility: Support Mastodon v2 timeline filters.
### Changed ### Changed
- Posts: truncate Nostr pubkeys in reply mentions. - Posts: truncate Nostr pubkeys in reply mentions.
- Posts: upgraded emoji picker component.
### Fixed ### Fixed
- Posts: fixed emojis being cut off in reactions modal. - Posts: fixed emojis being cut off in reactions modal.
- Posts: fix audio player progress bar visibility. - Posts: fix audio player progress bar visibility.
- Posts: added missing gap in pending status.
- Compatibility: fixed quote posting compatibility with custom Pleroma forks.
- Profile: fix "load more" button height on account gallery page.
- 18n: fixed Chinese language being detected from the browser.
- Conversations: fixed pagination (Mastodon).
## [3.2.0] - 2023-02-15 ## [3.2.0] - 2023-02-15

View File

@ -1,26 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_7_1989" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="73" y="38" width="46" height="65">
<path d="M95.9997 38C90.3542 38 85.7775 42.1122 85.7775 47.1848V56.3696C85.7775 59.2605 87.2924 61.9828 89.8664 63.7174C90.1516 63.9096 90.0851 64.347 89.7556 64.4456L81.7371 66.8472C76.8048 68.3245 73.3901 72.3258 73.0313 76.9334C72.9897 77.4671 73.0675 78.0013 73.2009 78.5197L79.5 103C106.943 103 115.635 97.2124 118.109 94.7132C118.778 94.0378 119 93.0929 119 92.1426V77.739C119 72.7973 115.481 68.4099 110.263 66.8472L102.244 64.4455C101.914 64.3468 101.848 63.9096 102.133 63.7174C104.707 61.9828 106.222 59.2605 106.222 56.3696V47.1848C106.222 42.1122 101.645 38 95.9997 38Z" fill="#5448EE"/>
</mask>
<g mask="url(#mask0_7_1989)">
<path d="M95.9997 38C90.3542 38 85.7775 42.1122 85.7775 47.1848V56.3696C85.7775 59.2605 87.2924 61.9828 89.8664 63.7174C90.1516 63.9096 90.0851 64.347 89.7556 64.4456L81.7371 66.8472C76.8048 68.3245 73.3901 72.3258 73.0313 76.9334C72.9897 77.4671 73.0675 78.0013 73.2009 78.5197L79.5 103C106.943 103 115.635 97.2124 118.109 94.7132C118.778 94.0378 119 93.0929 119 92.1426V77.739C119 72.7973 115.481 68.4099 110.263 66.8472L102.244 64.4455C101.914 64.3468 101.848 63.9096 102.133 63.7174C104.707 61.9828 106.222 59.2605 106.222 56.3696V47.1848C106.222 42.1122 101.645 38 95.9997 38Z" fill="#5448EE"/>
<path opacity="0.34" d="M79.4229 76L112.423 107.841L109.423 111.884H70.9998V77.3607L79.4229 76Z" fill="#322B4E"/>
</g>
<path d="M32.0003 38C37.6458 38 42.2225 42.1122 42.2225 47.1848V56.3696C42.2225 59.2605 40.7076 61.9828 38.1336 63.7174L37.4195 64.1986L46.2629 66.8472C51.4806 68.4099 55 72.7973 55 77.739L48.5 103C13.5 103 9 93.5862 9 93.5862V77.739C9 72.7973 12.5194 68.4099 17.7371 66.8472L26.5808 64.1985L25.8669 63.7174C23.2929 61.9828 21.778 59.2605 21.778 56.3696V47.1848C21.778 42.1122 26.3547 38 32.0003 38Z" fill="url(#paint0_linear_7_1989)"/>
<mask id="mask1_7_1989" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="38" y="54" width="52" height="50">
<path d="M38.3857 73.7909C38.3857 67.5992 42.3053 62.1022 48.1163 60.1442L64.0011 54.792L79.8859 60.1442C85.6969 62.1022 89.6165 67.5992 89.6165 73.7909V95.0005C89.6165 99.4188 86.0348 103.001 81.6165 103.001H46.3857C41.9675 103.001 38.3857 99.4188 38.3857 95.0005V73.7909Z" fill="#B0AEB8"/>
<path d="M38.3857 73.7909C38.3857 67.5992 42.3053 62.1022 48.1163 60.1442L64.0011 54.792L79.8859 60.1442C85.6969 62.1022 89.6165 67.5992 89.6165 73.7909V95.0005C89.6165 99.4188 86.0348 103.001 81.6165 103.001H46.3857C41.9675 103.001 38.3857 99.4188 38.3857 95.0005V73.7909Z" fill="#B0AEB8"/>
</mask>
<g mask="url(#mask1_7_1989)">
<path d="M38.3857 73.7909C38.3857 67.5992 42.3053 62.1022 48.1163 60.1442L64.0011 54.792L79.8859 60.1442C85.6969 62.1022 89.6165 67.5992 89.6165 73.7909V108.055H38.3857V73.7909Z" fill="#645F76"/>
<path d="M38.3857 73.7909C38.3857 67.5992 42.3053 62.1022 48.1163 60.1442L64.0011 54.792L79.8859 60.1442C85.6969 62.1022 89.6165 67.5992 89.6165 73.7909V108.055H38.3857V73.7909Z" fill="#645F76"/>
<path opacity="0.34" d="M90 86.7889L57 54.9479L60 50.9046H98.4231V85.4282L90 86.7889Z" fill="#322B4E"/>
</g>
<path d="M52.6162 35.3846C52.6162 29.0971 57.7133 24 64.0008 24C70.2884 24 75.3854 29.0971 75.3854 35.3846V47.1141C75.3854 50.6768 73.7177 54.034 70.8786 56.1863L69.1592 57.4899C66.109 59.8023 61.8926 59.8023 58.8425 57.4899L57.123 56.1863C54.284 54.034 52.6162 50.6768 52.6162 47.1141V35.3846Z" fill="#645F76"/>
<defs>
<linearGradient id="paint0_linear_7_1989" x1="-49" y1="-16.2414" x2="19.0934" y2="125.345" gradientUnits="userSpaceOnUse">
<stop stop-color="#E4E2FC" stop-opacity="0"/>
<stop offset="1" stop-color="#B7B2F8"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -25,7 +25,7 @@ const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL';
const noOp = () => () => new Promise(f => f(undefined)); const noOp = () => () => new Promise(f => f(undefined));
const simpleEmojiReact = (status: Status, emoji: string) => const simpleEmojiReact = (status: Status, emoji: string, custom?: string) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList(); const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList();
@ -43,7 +43,7 @@ const simpleEmojiReact = (status: Status, emoji: string) =>
if (emoji === '👍') { if (emoji === '👍') {
dispatch(favourite(status)); dispatch(favourite(status));
} else { } else {
dispatch(emojiReact(status, emoji)); dispatch(emojiReact(status, emoji, custom));
} }
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
@ -70,11 +70,11 @@ const fetchEmojiReacts = (id: string, emoji: string) =>
}); });
}; };
const emojiReact = (status: Status, emoji: string) => const emojiReact = (status: Status, emoji: string, custom?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp()); if (!isLoggedIn(getState)) return dispatch(noOp());
dispatch(emojiReactRequest(status, emoji)); dispatch(emojiReactRequest(status, emoji, custom));
return api(getState) return api(getState)
.put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`) .put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
@ -120,10 +120,11 @@ const fetchEmojiReactsFail = (id: string, error: AxiosError) => ({
error, error,
}); });
const emojiReactRequest = (status: Status, emoji: string) => ({ const emojiReactRequest = (status: Status, emoji: string, custom?: string) => ({
type: EMOJI_REACT_REQUEST, type: EMOJI_REACT_REQUEST,
status, status,
emoji, emoji,
custom,
skipLoading: true, skipLoading: true,
}); });

View File

@ -1,5 +1,6 @@
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { deleteEntities } from 'soapbox/entity-store/actions';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
@ -191,7 +192,7 @@ const updateGroupFail = (error: AxiosError) => ({
}); });
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(deleteGroupRequest(id)); dispatch(deleteEntities([id], 'Group'));
return api(getState).delete(`/api/v1/groups/${id}`) return api(getState).delete(`/api/v1/groups/${id}`)
.then(() => dispatch(deleteGroupSuccess(id))) .then(() => dispatch(deleteGroupSuccess(id)))

View File

@ -1,3 +1,8 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { Group, groupSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { getSettings } from '../settings'; import { getSettings } from '../settings';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
@ -18,11 +23,11 @@ const importAccount = (account: APIEntity) =>
const importAccounts = (accounts: APIEntity[]) => const importAccounts = (accounts: APIEntity[]) =>
({ type: ACCOUNTS_IMPORT, accounts }); ({ type: ACCOUNTS_IMPORT, accounts });
const importGroup = (group: APIEntity) => const importGroup = (group: Group) =>
({ type: GROUP_IMPORT, group }); importEntities([group], Entities.GROUPS);
const importGroups = (groups: APIEntity[]) => const importGroups = (groups: Group[]) =>
({ type: GROUPS_IMPORT, groups }); importEntities(groups, Entities.GROUPS);
const importStatus = (status: APIEntity, idempotencyKey?: string) => const importStatus = (status: APIEntity, idempotencyKey?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
@ -69,17 +74,8 @@ const importFetchedGroup = (group: APIEntity) =>
importFetchedGroups([group]); importFetchedGroups([group]);
const importFetchedGroups = (groups: APIEntity[]) => { const importFetchedGroups = (groups: APIEntity[]) => {
const normalGroups: APIEntity[] = []; const entities = filteredArray(groupSchema).catch([]).parse(groups);
return importGroups(entities);
const processGroup = (group: APIEntity) => {
if (!group.id) return;
normalGroups.push(group);
};
groups.forEach(processGroup);
return importGroups(normalGroups);
}; };
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>

View File

@ -91,6 +91,7 @@ export interface IAccount {
showEdit?: boolean showEdit?: boolean
approvalStatus?: StatusApprovalStatus approvalStatus?: StatusApprovalStatus
emoji?: string emoji?: string
emojiUrl?: string
note?: string note?: string
} }
@ -116,6 +117,7 @@ const Account = ({
showEdit = false, showEdit = false,
approvalStatus, approvalStatus,
emoji, emoji,
emojiUrl,
note, note,
}: IAccount) => { }: IAccount) => {
const overflowRef = useRef<HTMLDivElement>(null); const overflowRef = useRef<HTMLDivElement>(null);
@ -193,6 +195,7 @@ const Account = ({
<Emoji <Emoji
className='absolute bottom-0 -right-1.5 h-5 w-5' className='absolute bottom-0 -right-1.5 h-5 w-5'
emoji={emoji} emoji={emoji}
src={emojiUrl}
/> />
)} )}
</LinkEl> </LinkEl>

View File

@ -1,7 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles'; import { GroupRoles } from 'soapbox/schemas/group-member';
import { Avatar } from '../ui'; import { Avatar } from '../ui';
@ -16,17 +16,15 @@ interface IGroupAvatar {
const GroupAvatar = (props: IGroupAvatar) => { const GroupAvatar = (props: IGroupAvatar) => {
const { group, size, withRing = false } = props; const { group, size, withRing = false } = props;
const { normalizeRole } = useGroupRoles(); const isOwner = group.relationship?.role === GroupRoles.OWNER;
const isAdmin = normalizeRole(group.relationship?.role as any) === 'admin';
return ( return (
<Avatar <Avatar
className={ className={
clsx('relative rounded-full', { clsx('relative rounded-full', {
'shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.white)]': isAdmin && withRing, 'shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.white)]': isOwner && withRing,
'shadow-[0_0_0_2px_theme(colors.primary.600)]': isAdmin && !withRing, 'shadow-[0_0_0_2px_theme(colors.primary.600)]': isOwner && !withRing,
'shadow-[0_0_0_2px_theme(colors.white)]': !isAdmin && withRing, 'shadow-[0_0_0_2px_theme(colors.white)]': !isOwner && withRing,
}) })
} }
src={group.avatar} src={group.avatar}

View File

@ -538,7 +538,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
allowedEmoji, allowedEmoji,
).reduce((acc, cur) => acc + cur.get('count'), 0); ).reduce((acc, cur) => acc + cur.get('count'), 0);
const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; const meEmojiReact = getReactForStatus(status, allowedEmoji);
const meEmojiName = meEmojiReact?.get('name') as keyof typeof reactMessages | undefined;
const reactMessages = { const reactMessages = {
'👍': messages.reactionLike, '👍': messages.reactionLike,
@ -550,7 +551,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
'': messages.favourite, '': messages.favourite,
}; };
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiName || ''] || messages.favourite);
const menu = _makeMenu(publicStatus); const menu = _makeMenu(publicStatus);
let reblogIcon = require('@tabler/icons/repeat.svg'); let reblogIcon = require('@tabler/icons/repeat.svg');
@ -635,7 +636,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
icon={require('@tabler/icons/heart.svg')} icon={require('@tabler/icons/heart.svg')}
filled filled
color='accent' color='accent'
active={Boolean(meEmojiReact)} active={Boolean(meEmojiName)}
count={emojiReactCount} count={emojiReactCount}
emoji={meEmojiReact} emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined} text={withLabels ? meEmojiTitle : undefined}
@ -648,7 +649,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
color='accent' color='accent'
filled filled
onClick={handleFavouriteClick} onClick={handleFavouriteClick}
active={Boolean(meEmojiReact)} active={Boolean(meEmojiName)}
count={favouriteCount} count={favouriteCount}
text={withLabels ? meEmojiTitle : undefined} text={withLabels ? meEmojiTitle : undefined}
/> />

View File

@ -4,6 +4,8 @@ import React from 'react';
import { Text, Icon, Emoji } from 'soapbox/components/ui'; import { Text, Icon, Emoji } from 'soapbox/components/ui';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { shortNumberFormat } from 'soapbox/utils/numbers';
import type { Map as ImmutableMap } from 'immutable';
const COLORS = { const COLORS = {
accent: 'accent', accent: 'accent',
success: 'success', success: 'success',
@ -31,7 +33,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
active?: boolean active?: boolean
color?: Color color?: Color
filled?: boolean filled?: boolean
emoji?: string emoji?: ImmutableMap<string, any>
text?: React.ReactNode text?: React.ReactNode
} }
@ -42,7 +44,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
if (emoji) { if (emoji) {
return ( return (
<span className='flex h-6 w-6 items-center justify-center'> <span className='flex h-6 w-6 items-center justify-center'>
<Emoji className='h-full w-full p-0.5' emoji={emoji} /> <Emoji className='h-full w-full p-0.5' emoji={emoji.get('name')} src={emoji.get('url')} />
</span> </span>
); );
} else { } else {

View File

@ -60,9 +60,9 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
} }
}; };
const handleReact = (emoji: string): void => { const handleReact = (emoji: string, custom?: string): void => {
if (ownAccount) { if (ownAccount) {
dispatch(simpleEmojiReact(status, emoji)); dispatch(simpleEmojiReact(status, emoji, custom));
} else { } else {
handleUnauthorized(); handleUnauthorized();
} }
@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
}; };
const handleClick: React.EventHandler<React.MouseEvent> = e => { const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍'; const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍';
if (isUserTouching()) { if (isUserTouching()) {
if (ownAccount) { if (ownAccount) {

View File

@ -1,13 +1,12 @@
import { Placement } from '@popperjs/core'; import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui'; import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown'; import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
import { useSoapboxConfig } from 'soapbox/hooks'; import { useClickOutside, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import type { Emoji, NativeEmoji } from 'soapbox/features/emoji'; import type { Emoji } from 'soapbox/features/emoji';
interface IEmojiButton { interface IEmojiButton {
/** Unicode emoji character. */ /** Unicode emoji character. */
@ -39,14 +38,13 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
interface IEmojiSelector { interface IEmojiSelector {
onClose?(): void onClose?(): void
/** Event handler when an emoji is clicked. */ /** Event handler when an emoji is clicked. */
onReact(emoji: string): void onReact(emoji: string, custom?: string): void
/** Element that triggers the EmojiSelector Popper */ /** Element that triggers the EmojiSelector Popper */
referenceElement: HTMLElement | null referenceElement: HTMLElement | null
placement?: Placement placement?: Placement
/** Whether the selector should be visible. */ /** Whether the selector should be visible. */
visible?: boolean visible?: boolean
/** X/Y offset of the floating picker. */ offsetOptions?: OffsetOptions
offset?: [number, number]
/** Whether to allow any emoji to be chosen. */ /** Whether to allow any emoji to be chosen. */
all?: boolean all?: boolean
} }
@ -58,43 +56,17 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
onReact, onReact,
placement = 'top', placement = 'top',
visible = false, visible = false,
offset = [-10, 0], offsetOptions,
all = true, all = true,
}): JSX.Element => { }): JSX.Element => {
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
const { customEmojiReacts } = useFeatures();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
// `useRef` won't trigger a re-render, while `useState` does. const { x, y, strategy, refs, update } = useFloating<HTMLElement>({
// https://popper.js.org/react-popper/v2/
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const handleClickOutside = (event: MouseEvent) => {
if ([referenceElement, popperElement, document.querySelector('em-emoji-picker')].some(el => el?.contains(event.target as Node))) {
return;
}
if (document.querySelector('em-emoji-picker')) {
event.preventDefault();
event.stopPropagation();
return setExpanded(false);
}
if (onClose) {
onClose();
}
};
const { styles, attributes, update } = usePopper(referenceElement, popperElement, {
placement, placement,
modifiers: [ middleware: [offset(offsetOptions), shift()],
{
name: 'offset',
options: {
offset,
},
},
],
}); });
const handleExpand: React.MouseEventHandler = () => { const handleExpand: React.MouseEventHandler = () => {
@ -102,9 +74,13 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
}; };
const handlePickEmoji = (emoji: Emoji) => { const handlePickEmoji = (emoji: Emoji) => {
onReact((emoji as NativeEmoji).native); onReact(emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined);
}; };
useEffect(() => {
refs.setReference(referenceElement);
}, [referenceElement]);
useEffect(() => () => { useEffect(() => () => {
document.body.style.overflow = ''; document.body.style.overflow = '';
}, []); }, []);
@ -113,42 +89,31 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
setExpanded(false); setExpanded(false);
}, [visible]); }, [visible]);
useEffect(() => { useClickOutside(refs, () => {
document.addEventListener('mousedown', handleClickOutside); if (onClose) {
onClose();
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [referenceElement, popperElement]);
useEffect(() => {
if (visible && update) {
update();
} }
}, [visible, update]); });
useEffect(() => {
if (expanded && update) {
update();
}
}, [expanded, update]);
return ( return (
<div <div
className={clsx('z-[101] transition-opacity duration-100', { className={clsx('z-[101] transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible, 'opacity-0 pointer-events-none': !visible,
})} })}
ref={setPopperElement} ref={refs.setFloating}
style={styles.popper} style={{
{...attributes.popper} position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
> >
{expanded ? ( {expanded ? (
<EmojiPickerDropdown <EmojiPickerDropdown
visible={expanded} visible={expanded}
setVisible={setExpanded} setVisible={setExpanded}
update={update} update={update}
withCustom={false} withCustom={customEmojiReacts}
onPickEmoji={handlePickEmoji} onPickEmoji={handlePickEmoji}
/> />
) : ( ) : (

View File

@ -10,7 +10,7 @@ interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
/** A single emoji image. */ /** A single emoji image. */
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => { const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const { emoji, alt, ...rest } = props; const { emoji, alt, src, ...rest } = props;
const codepoints = toCodePoints(removeVS16s(emoji)); const codepoints = toCodePoints(removeVS16s(emoji));
const filename = codepoints.join('-'); const filename = codepoints.join('-');
@ -20,7 +20,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
<img <img
draggable='false' draggable='false'
alt={alt || emoji} alt={alt || emoji}
src={joinPublicPath(`packs/emoji/${filename}.svg`)} src={src || joinPublicPath(`packs/emoji/${filename}.svg`)}
{...rest} {...rest}
/> />
); );

View File

@ -2,6 +2,4 @@ export enum Entities {
GROUPS = 'Groups', GROUPS = 'Groups',
GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_MEMBERSHIPS = 'GroupMemberships', GROUP_MEMBERSHIPS = 'GroupMemberships',
POPULAR_GROUPS = 'PopularGroups',
SUGGESTED_GROUPS = 'SuggestedGroups',
} }

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks'; import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks';
@ -28,6 +29,10 @@ interface EntityActionEndpoints {
delete?: string delete?: string
} }
interface EntityCallbacks<TEntity extends Entity = Entity> {
onSuccess?(entity?: TEntity): void
}
function useEntityActions<TEntity extends Entity = Entity, P = any>( function useEntityActions<TEntity extends Entity = Entity, P = any>(
path: EntityPath, path: EntityPath,
endpoints: EntityActionEndpoints, endpoints: EntityActionEndpoints,
@ -38,9 +43,13 @@ function useEntityActions<TEntity extends Entity = Entity, P = any>(
const getState = useGetState(); const getState = useGetState();
const [entityType, listKey] = path; const [entityType, listKey] = path;
function createEntity(params: P): Promise<CreateEntityResult<TEntity>> { const [isLoading, setIsLoading] = useState<boolean>(false);
function createEntity(params: P, callbacks: EntityCallbacks = {}): Promise<CreateEntityResult<TEntity>> {
if (!endpoints.post) return Promise.reject(endpoints); if (!endpoints.post) return Promise.reject(endpoints);
setIsLoading(true);
return api.post(endpoints.post, params).then((response) => { return api.post(endpoints.post, params).then((response) => {
const schema = opts.schema || z.custom<TEntity>(); const schema = opts.schema || z.custom<TEntity>();
const entity = schema.parse(response.data); const entity = schema.parse(response.data);
@ -48,6 +57,12 @@ function useEntityActions<TEntity extends Entity = Entity, P = any>(
// TODO: optimistic updating // TODO: optimistic updating
dispatch(importEntities([entity], entityType, listKey)); dispatch(importEntities([entity], entityType, listKey));
if (callbacks.onSuccess) {
callbacks.onSuccess(entity);
}
setIsLoading(false);
return { return {
response, response,
entity, entity,
@ -55,14 +70,20 @@ function useEntityActions<TEntity extends Entity = Entity, P = any>(
}); });
} }
function deleteEntity(entityId: string): Promise<DeleteEntityResult> { function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise<DeleteEntityResult> {
if (!endpoints.delete) return Promise.reject(endpoints); if (!endpoints.delete) return Promise.reject(endpoints);
// Get the entity before deleting, so we can reverse the action if the API request fails. // Get the entity before deleting, so we can reverse the action if the API request fails.
const entity = getState().entities[entityType]?.store[entityId]; const entity = getState().entities[entityType]?.store[entityId];
// Optimistically delete the entity from the _store_ but keep the lists in tact. // Optimistically delete the entity from the _store_ but keep the lists in tact.
dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
setIsLoading(true);
return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => { return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => {
if (callbacks.onSuccess) {
callbacks.onSuccess();
}
// Success - finish deleting entity from the state. // Success - finish deleting entity from the state.
dispatch(deleteEntities([entityId], entityType)); dispatch(deleteEntities([entityId], entityType));
@ -75,12 +96,15 @@ function useEntityActions<TEntity extends Entity = Entity, P = any>(
dispatch(importEntities([entity], entityType)); dispatch(importEntities([entity], entityType));
} }
throw e; throw e;
}).finally(() => {
setIsLoading(false);
}); });
} }
return { return {
createEntity: createEntity, createEntity,
deleteEntity: endpoints.delete ? deleteEntity : undefined, deleteEntity,
isLoading,
}; };
} }

View File

@ -110,7 +110,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
); );
if (token && tokenStart) { if (token && tokenStart) {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
setSuggestions({ setSuggestions({
list: results, list: results,
token, token,

View File

@ -44,7 +44,7 @@ function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
referenceElement={referenceElement} referenceElement={referenceElement}
onReact={handleSelect} onReact={handleSelect}
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
offset={[-10, 12]} offsetOptions={{ mainAxis: 12, crossAxis: -10 }}
all={false} all={false}
/> />
</Portal> </Portal>

View File

@ -14,13 +14,13 @@ interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
} }
/** Custom textarea for chats. */ /** Custom textarea for chats. */
const ChatTextarea: React.FC<IChatTextarea> = ({ const ChatTextarea: React.FC<IChatTextarea> = React.forwardRef(({
attachments, attachments,
onDeleteAttachment, onDeleteAttachment,
uploadCount = 0, uploadCount = 0,
uploadProgress = 0, uploadProgress = 0,
...rest ...rest
}) => { }, ref) => {
const isUploading = uploadCount > 0; const isUploading = uploadCount > 0;
const handleDeleteAttachment = (i: number) => { const handleDeleteAttachment = (i: number) => {
@ -64,9 +64,9 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
</HStack> </HStack>
)} )}
<Textarea theme='transparent' {...rest} /> <Textarea ref={ref} theme='transparent' {...rest} />
</div> </div>
); );
}; });
export default ChatTextarea; export default ChatTextarea;

View File

@ -11,7 +11,6 @@ import { RootState } from 'soapbox/store';
import { buildCustomEmojis } from '../../emoji'; import { buildCustomEmojis } from '../../emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
import type { State as PopperState } from '@popperjs/core';
import type { Emoji, CustomEmoji, NativeEmoji } from 'soapbox/features/emoji'; import type { Emoji, CustomEmoji, NativeEmoji } from 'soapbox/features/emoji';
let EmojiPicker: any; // load asynchronously let EmojiPicker: any; // load asynchronously
@ -49,7 +48,7 @@ export interface IEmojiPickerDropdown {
withCustom?: boolean withCustom?: boolean
visible: boolean visible: boolean
setVisible: (value: boolean) => void setVisible: (value: boolean) => void
update: (() => Promise<Partial<PopperState>>) | null update: (() => any) | null
} }
const perLine = 8; const perLine = 8;

View File

@ -1,17 +1,14 @@
import { useFloating, shift } from '@floating-ui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events'; import React, { KeyboardEvent, useState } from 'react';
import React, { KeyboardEvent, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { usePopper } from 'react-popper';
import { IconButton } from 'soapbox/components/ui'; import { IconButton } from 'soapbox/components/ui';
import { isMobile } from 'soapbox/is-mobile'; import { useClickOutside } from 'soapbox/hooks';
import EmojiPickerDropdown, { IEmojiPickerDropdown } from '../components/emoji-picker-dropdown'; import EmojiPickerDropdown, { IEmojiPickerDropdown } from '../components/emoji-picker-dropdown';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
export const messages = defineMessages({ export const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
}); });
@ -22,51 +19,28 @@ const EmojiPickerDropdownContainer = (
const intl = useIntl(); const intl = useIntl();
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [popperReference, setPopperReference] = useState<HTMLButtonElement | null>(null);
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const placement = props.condensed ? 'bottom-start' : 'top-start'; const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({
const { styles, attributes, update } = usePopper(popperReference, popperElement, { middleware: [shift()],
placement: isMobile(window.innerWidth) ? 'auto' : placement,
}); });
const handleDocClick = (e: any) => { useClickOutside(refs, () => {
if (!containerElement?.contains(e.target) && !popperElement?.contains(e.target)) {
setVisible(false); setVisible(false);
} });
};
const handleToggle = (e: MouseEvent | KeyboardEvent) => { const handleToggle = (e: MouseEvent | KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
setVisible(!visible); setVisible(!visible);
}; };
// TODO: move to class
const style: React.CSSProperties = !isMobile(window.innerWidth) ? styles.popper : {
...styles.popper, width: '100%',
};
useEffect(() => {
document.addEventListener('click', handleDocClick, false);
document.addEventListener('touchend', handleDocClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocClick, false);
// @ts-ignore
document.removeEventListener('touchend', handleDocClick, listenerOptions);
};
});
return ( return (
<div className='relative' ref={setContainerElement}> <div className='relative'>
<IconButton <IconButton
className={clsx({ className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true, 'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
})} })}
ref={setPopperReference} ref={refs.setReference}
src={require('@tabler/icons/mood-happy.svg')} src={require('@tabler/icons/mood-happy.svg')}
title={title} title={title}
aria-label={title} aria-label={title}
@ -80,11 +54,20 @@ const EmojiPickerDropdownContainer = (
{createPortal( {createPortal(
<div <div
className='z-[101]' className='z-[101]'
ref={setPopperElement} ref={refs.setFloating}
style={style} style={{
{...attributes.popper} position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
> >
<EmojiPickerDropdown visible={visible} setVisible={setVisible} update={update} {...props} /> <EmojiPickerDropdown
visible={visible}
setVisible={setVisible}
update={update}
{...props}
/>
</div>, </div>,
document.body, document.body,
)} )}

View File

@ -28,7 +28,7 @@ export interface CustomEmoji {
export interface NativeEmoji { export interface NativeEmoji {
id: string id: string
colons: string colons: string
custom?: boolean custom?: false
unified: string unified: string
native: string native: string
} }

View File

@ -1,11 +1,10 @@
import { Index } from 'flexsearch'; import { Index } from 'flexsearch-ts';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import data from './data'; import data from './data';
import type { Emoji } from './index'; import type { Emoji } from './index';
// import type { Emoji as EmojiMart, CustomEmoji } from 'emoji-mart';
// @ts-ignore
const index = new Index({ const index = new Index({
tokenize: 'full', tokenize: 'full',
optimize: true, optimize: true,
@ -37,29 +36,39 @@ export const addCustomToPool = (customEmojis: any[]) => {
}; };
// we can share an index by prefixing custom emojis with 'c' and native with 'n' // we can share an index by prefixing custom emojis with 'c' and native with 'n'
const search = (str: string, { maxResults = 5, custom }: searchOptions = {}, custom_emojis?: any): Emoji[] => { const search = (
str: string, { maxResults = 5 }: searchOptions = {},
custom_emojis?: ImmutableList<ImmutableMap<string, string>>,
): Emoji[] => {
return index.search(str, maxResults) return index.search(str, maxResults)
.flatMap((id: string) => { .flatMap((id) => {
if (id[0] === 'c') { if (typeof id !== 'string') return;
const { shortcode, static_url } = custom_emojis.get((id as string).slice(1)).toJS();
if (id[0] === 'c' && custom_emojis) {
const index = Number(id.slice(1));
const custom = custom_emojis.get(index);
if (custom) {
return { return {
id: shortcode, id: custom.get('shortcode', ''),
colons: ':' + shortcode + ':', colons: ':' + custom.get('shortcode', '') + ':',
custom: true, custom: true,
imageUrl: static_url, imageUrl: custom.get('static_url', ''),
}; };
} }
}
const { skins } = data.emojis[(id as string).slice(1)]; const skins = data.emojis[id.slice(1)]?.skins;
if (skins) {
return { return {
id: (id as string).slice(1), id: id.slice(1),
colons: ':' + id.slice(1) + ':', colons: ':' + id.slice(1) + ':',
unified: skins[0].unified, unified: skins[0].unified,
native: skins[0].native, native: skins[0].native,
}; };
}); }
}).filter(Boolean) as Emoji[];
}; };
export default search; export default search;

View File

@ -98,7 +98,7 @@ describe('<GroupActionButton />', () => {
relationship: buildGroupRelationship({ relationship: buildGroupRelationship({
requested: false, requested: false,
member: true, member: true,
role: 'admin', role: 'owner',
}), }),
}); });
}); });

View File

@ -3,49 +3,84 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { Button } from 'soapbox/components/ui'; import { Button } from 'soapbox/components/ui';
import { deleteEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/queries/groups'; import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/hooks/api';
import { Group } from 'soapbox/types/entities'; import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast';
import type { Group } from 'soapbox/types/entities';
interface IGroupActionButton { interface IGroupActionButton {
group: Group group: Group
} }
const messages = defineMessages({ const messages = defineMessages({
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' }, 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?' }, 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' }, joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' },
joinSuccess: { id: 'group.join.success', defaultMessage: 'Group joined successfully!' },
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
}); });
const GroupActionButton = ({ group }: IGroupActionButton) => { const GroupActionButton = ({ group }: IGroupActionButton) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const joinGroup = useJoinGroup(); const joinGroup = useJoinGroup(group);
const leaveGroup = useLeaveGroup(); const leaveGroup = useLeaveGroup(group);
const cancelRequest = useCancelMembershipRequest(); const cancelRequest = useCancelMembershipRequest(group);
const isRequested = group.relationship?.requested; const isRequested = group.relationship?.requested;
const isNonMember = !group.relationship?.member && !isRequested; const isNonMember = !group.relationship?.member && !isRequested;
const isAdmin = group.relationship?.role === 'admin'; const isOwner = group.relationship?.role === GroupRoles.OWNER;
const isBlocked = group.relationship?.blocked_by; const isBlocked = group.relationship?.blocked_by;
const onJoinGroup = () => joinGroup.mutate(group); const onJoinGroup = () => joinGroup.mutate({}, {
onSuccess() {
toast.success(
group.locked
? intl.formatMessage(messages.joinRequestSuccess)
: intl.formatMessage(messages.joinSuccess),
);
},
});
const onLeaveGroup = () => const onLeaveGroup = () =>
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.confirmationHeading), heading: intl.formatMessage(messages.confirmationHeading),
message: intl.formatMessage(messages.confirmationMessage), message: intl.formatMessage(messages.confirmationMessage),
confirm: intl.formatMessage(messages.confirmationConfirm), confirm: intl.formatMessage(messages.confirmationConfirm),
onConfirm: () => leaveGroup.mutate(group), onConfirm: () => leaveGroup.mutate({}, {
onSuccess() {
toast.success(intl.formatMessage(messages.leaveSuccess));
},
}),
})); }));
const onCancelRequest = () => cancelRequest.mutate(group); const onCancelRequest = () => cancelRequest.mutate({}, {
onSuccess() {
dispatch(deleteEntities([group.id], Entities.GROUP_RELATIONSHIPS));
},
});
if (isBlocked) { if (isBlocked) {
return null; return null;
} }
if (isOwner) {
return (
<Button
theme='secondary'
to={`/groups/${group.id}/manage`}
>
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
</Button>
);
}
if (isNonMember) { if (isNonMember) {
return ( return (
<Button <Button
@ -72,17 +107,6 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
); );
} }
if (isAdmin) {
return (
<Button
theme='secondary'
to={`/groups/${group.id}/manage`}
>
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
</Button>
);
}
return ( return (
<Button <Button
theme='secondary' theme='secondary'

View File

@ -2,7 +2,7 @@ import clsx from 'clsx';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups'; import { groupKick } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu'; import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu';
@ -10,31 +10,29 @@ import { HStack } from 'soapbox/components/ui';
import { deleteEntities } from 'soapbox/entity-store/actions'; import { deleteEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks'; import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks';
import { useBlockGroupMember } from 'soapbox/hooks/api/groups/useBlockGroupMember'; import { useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/hooks/api';
import { BaseGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles'; import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu'; import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types/entities'; import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types/entities';
const messages = defineMessages({ const messages = defineMessages({
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' }, blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' },
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' }, blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' },
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' }, blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' },
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'You have successfully blocked @{name} from the group' }, blocked: { id: 'group.group_mod_block.success', defaultMessage: '@{name} is banned' },
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' }, demotedToUser: { id: 'group.demote.user.success', defaultMessage: '@{name} is now a member' },
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Ban from group' }, groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Ban from group' },
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' }, groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Remove {role} role' },
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' }, groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' }, groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Assign {role} role' },
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' }, kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' }, kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' }, kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' }, promoteConfirm: { id: 'group.promote.admin.confirmation.title', defaultMessage: 'Assign Admin Role' },
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' }, promoteConfirmMessage: { id: 'group.promote.admin.confirmation.message', defaultMessage: 'Are you sure you want to assign the admin role to @{name}?' },
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' }, promotedToAdmin: { id: 'group.promote.admin.success', defaultMessage: '@{name} is now an admin' },
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
}); });
interface IGroupMemberListItem { interface IGroupMemberListItem {
@ -49,19 +47,20 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
const features = useFeatures(); const features = useFeatures();
const intl = useIntl(); const intl = useIntl();
const { normalizeRole } = useGroupRoles();
const blockGroupMember = useBlockGroupMember(group, member); const blockGroupMember = useBlockGroupMember(group, member);
const promoteGroupMember = usePromoteGroupMember(group, member);
const demoteGroupMember = useDemoteGroupMember(group, member);
const account = useAccount(member.account.id) as AccountEntity; const account = useAccount(member.account.id) as AccountEntity;
// Current user role // Current user role
const isCurrentUserAdmin = normalizeRole(group.relationship?.role as any) === BaseGroupRoles.ADMIN; const isCurrentUserOwner = group.relationship?.role === GroupRoles.OWNER;
const isCurrentUserModerator = normalizeRole(group.relationship?.role as any) === BaseGroupRoles.MODERATOR; const isCurrentUserAdmin = group.relationship?.role === GroupRoles.ADMIN;
// Member role // Member role
const isMemberAdmin = normalizeRole(member.role as any) === BaseGroupRoles.ADMIN; const isMemberOwner = member.role === GroupRoles.OWNER;
const isMemberModerator = normalizeRole(member.role as any) === BaseGroupRoles.MODERATOR; const isMemberAdmin = member.role === GroupRoles.ADMIN;
const isMemberUser = normalizeRole(member.role as any) === BaseGroupRoles.USER; const isMemberUser = member.role === GroupRoles.USER;
const handleKickFromGroup = () => { const handleKickFromGroup = () => {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
@ -78,39 +77,41 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
heading: intl.formatMessage(messages.blockFromGroupHeading), heading: intl.formatMessage(messages.blockFromGroupHeading),
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }), message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
confirm: intl.formatMessage(messages.blockConfirm), confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => blockGroupMember({ account_ids: [member.account.id] }).then(() => { onConfirm: () => {
blockGroupMember({ account_ids: [member.account.id] }, {
onSuccess() {
dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS)); dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS));
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })); toast.success(intl.formatMessage(messages.blocked, { name: account.acct }));
}), },
});
},
})); }));
}; };
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => { const handleAdminAssignment = () => {
if (warning) { dispatch(openModal('CONFIRM', {
return dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.promoteConfirm),
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }), message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
confirm: intl.formatMessage(messages.promoteConfirm), confirm: intl.formatMessage(messages.promoteConfirm),
onConfirm: () => dispatch(groupPromoteAccount(group.id, account.id, role)).then(() => confirmationTheme: 'primary',
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })), onConfirm: () => {
), promoteGroupMember({ role: GroupRoles.ADMIN, account_ids: [account.id] }, {
})); onSuccess() {
} else { toast.success(
return dispatch(groupPromoteAccount(group.id, account.id, role)).then(() => intl.formatMessage(messages.promotedToAdmin, { name: account.acct }),
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
); );
} },
});
},
}));
}; };
const handlePromoteToGroupAdmin = () => onPromote('admin', true); const handleUserAssignment = () => {
demoteGroupMember({ role: GroupRoles.USER, account_ids: [account.id] }, {
const handlePromoteToGroupMod = () => { onSuccess() {
onPromote('moderator', group.relationship!.role === 'moderator'); toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct }));
}; },
});
const handleDemote = () => {
dispatch(groupDemoteAccount(group.id, account.id, 'user')).then(() =>
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
).catch(() => {});
}; };
const menu: IMenu = useMemo(() => { const menu: IMenu = useMemo(() => {
@ -120,9 +121,26 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
return items; return items;
} }
if (isCurrentUserOwner) {
if (isMemberUser) {
items.push({
text: intl.formatMessage(messages.groupModPromoteMod, { role: GroupRoles.ADMIN }),
icon: require('@tabler/icons/briefcase.svg'),
action: handleAdminAssignment,
});
} else if (isMemberAdmin) {
items.push({
text: intl.formatMessage(messages.groupModDemote, { role: GroupRoles.ADMIN, name: account.username }),
icon: require('@tabler/icons/briefcase.svg'),
action: handleUserAssignment,
destructive: true,
});
}
}
if ( if (
(isCurrentUserAdmin || isCurrentUserModerator) && (isCurrentUserOwner || isCurrentUserAdmin) &&
(isMemberModerator || isMemberUser) && (isMemberAdmin || isMemberUser) &&
member.role !== group.relationship.role member.role !== group.relationship.role
) { ) {
if (features.groupsKick) { if (features.groupsKick) {
@ -141,29 +159,6 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
}); });
} }
if (isCurrentUserAdmin && !isMemberAdmin && account.acct === account.username) {
items.push(null);
if (isMemberModerator) {
items.push({
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
icon: require('@tabler/icons/arrow-up-circle.svg'),
action: handlePromoteToGroupAdmin,
});
items.push({
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
icon: require('@tabler/icons/arrow-down-circle.svg'),
action: handleDemote,
});
} else if (isMemberUser) {
items.push({
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
icon: require('@tabler/icons/arrow-up-circle.svg'),
action: handlePromoteToGroupMod,
});
}
}
return items; return items;
}, [group, account]); }, [group, account]);
@ -174,12 +169,12 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
</div> </div>
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
{(isMemberAdmin || isMemberModerator) ? ( {(isMemberOwner || isMemberAdmin) ? (
<span <span
className={ className={
clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', { clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', {
'bg-primary-200 text-primary-500': isMemberAdmin, 'bg-primary-200 text-primary-500': isMemberOwner,
'bg-gray-200 text-gray-900': isMemberModerator, 'bg-gray-200 text-gray-900': isMemberAdmin,
}) })
} }
> >

View File

@ -1,12 +1,12 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchGroup, fetchGroupBlocks, groupUnblock } from 'soapbox/actions/groups'; import { fetchGroupBlocks, groupUnblock } from 'soapbox/actions/groups';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui'; import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useGroup } from 'soapbox/hooks';
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import ColumnForbidden from '../ui/components/column-forbidden'; import ColumnForbidden from '../ui/components/column-forbidden';
@ -62,14 +62,12 @@ const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const id = params?.id || ''; const id = params?.id;
const getGroup = useCallback(makeGetGroup(), []); const { group } = useGroup(id);
const group = useAppSelector(state => getGroup(state, id));
const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items); const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items);
useEffect(() => { useEffect(() => {
if (!group) dispatch(fetchGroup(id));
dispatch(fetchGroupBlocks(id)); dispatch(fetchGroupBlocks(id));
}, [id]); }, [id]);
@ -81,7 +79,7 @@ const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
); );
} }
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) { if (!group.relationship.role || !['owner', 'admin', 'moderator'].includes(group.relationship.role)) {
return (<ColumnForbidden />); return (<ColumnForbidden />);
} }

View File

@ -2,8 +2,8 @@ import React, { useMemo } from 'react';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers'; import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
import { useGroup } from 'soapbox/queries/groups'; import { useGroup } from 'soapbox/queries/groups';
import { GroupRoles } from 'soapbox/schemas/group-member';
import PlaceholderAccount from '../placeholder/components/placeholder-account'; import PlaceholderAccount from '../placeholder/components/placeholder-account';
@ -16,22 +16,20 @@ interface IGroupMembers {
} }
const GroupMembers: React.FC<IGroupMembers> = (props) => { const GroupMembers: React.FC<IGroupMembers> = (props) => {
const { roles: { admin, moderator, user } } = useGroupRoles();
const groupId = props.params.id; const groupId = props.params.id;
const { group, isFetching: isFetchingGroup } = useGroup(groupId); const { group, isFetching: isFetchingGroup } = useGroup(groupId);
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, admin); const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER);
const { groupMembers: moderators, isFetching: isFetchingModerators } = useGroupMembers(groupId, moderator); const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN);
const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, user); const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, GroupRoles.USER);
const isLoading = isFetchingGroup || isFetchingAdmins || isFetchingModerators || isFetchingUsers; const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers;
const members = useMemo(() => [ const members = useMemo(() => [
...owners,
...admins, ...admins,
...moderators,
...users, ...users,
], [admins, moderators, users]); ], [owners, admins, users]);
return ( return (
<> <>

View File

@ -1,12 +1,12 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { authorizeGroupMembershipRequest, fetchGroup, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups'; import { authorizeGroupMembershipRequest, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui'; import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useGroup } from 'soapbox/hooks';
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import ColumnForbidden from '../ui/components/column-forbidden'; import ColumnForbidden from '../ui/components/column-forbidden';
@ -77,14 +77,12 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const id = params?.id || ''; const id = params?.id;
const getGroup = useCallback(makeGetGroup(), []); const { group } = useGroup(id);
const group = useAppSelector(state => getGroup(state, id));
const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items); const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items);
useEffect(() => { useEffect(() => {
if (!group) dispatch(fetchGroup(id));
dispatch(fetchGroupMembershipRequests(id)); dispatch(fetchGroupMembershipRequests(id));
}, [id]); }, [id]);
@ -96,7 +94,7 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
); );
} }
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) { if (!group.relationship.role || !['owner', 'admin', 'moderator'].includes(group.relationship.role)) {
return (<ColumnForbidden />); return (<ColumnForbidden />);
} }

View File

@ -1,13 +1,15 @@
import React, { useCallback, useEffect } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { deleteGroup, editGroup, fetchGroup } from 'soapbox/actions/groups'; import { editGroup } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { CardBody, Column, Spinner } from 'soapbox/components/ui'; import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useGroup, useGroupsPath } from 'soapbox/hooks';
import { makeGetGroup } from 'soapbox/selectors'; import { useDeleteGroup } from 'soapbox/hooks/api';
import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast';
import ColumnForbidden from '../ui/components/column-forbidden'; import ColumnForbidden from '../ui/components/column-forbidden';
@ -17,11 +19,14 @@ const messages = defineMessages({
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' }, heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' }, editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' },
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' }, pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' },
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Blocked members' }, blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Banned members' },
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' }, deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' },
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' }, 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.' }, 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.' },
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
other: { id: 'settings.other', defaultMessage: 'Other options' },
deleteSuccess: { id: 'group.delete.success', defaultMessage: 'Group successfully deleted' },
}); });
interface IManageGroup { interface IManageGroup {
@ -29,18 +34,17 @@ interface IManageGroup {
} }
const ManageGroup: React.FC<IManageGroup> = ({ params }) => { const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
const history = useHistory(); const { id } = params;
const intl = useIntl(); const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const groupsPath = useGroupsPath();
const id = params?.id || ''; const { group } = useGroup(id);
const getGroup = useCallback(makeGetGroup(), []); const deleteGroup = useDeleteGroup();
const group = useAppSelector(state => getGroup(state, id));
useEffect(() => { const isOwner = group?.relationship?.role === GroupRoles.OWNER;
if (!group) dispatch(fetchGroup(id));
}, [id]);
if (!group || !group.relationship) { if (!group || !group.relationship) {
return ( return (
@ -50,7 +54,7 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
); );
} }
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) { if (!group.relationship.role || !['owner', 'admin', 'moderator'].includes(group.relationship.role)) {
return (<ColumnForbidden />); return (<ColumnForbidden />);
} }
@ -63,7 +67,14 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
heading: intl.formatMessage(messages.deleteHeading), heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteMessage), message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm), confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteGroup(id)), onConfirm: () => {
deleteGroup.mutate(group.id, {
onSuccess() {
toast.success(intl.formatMessage(messages.deleteSuccess));
history.push(groupsPath);
},
});
},
})); }));
const navigateToPending = () => history.push(`/groups/${id}/manage/requests`); const navigateToPending = () => history.push(`/groups/${id}/manage/requests`);
@ -72,21 +83,39 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
return ( return (
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}> <Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}>
<CardBody className='space-y-4'> <CardBody className='space-y-4'>
{group.relationship.role === 'admin' && ( {isOwner && (
<>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.editGroup)} />
</CardHeader>
<List> <List>
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}> <ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}>
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} /> <span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
</ListItem> </ListItem>
</List> </List>
</>
)} )}
<CardHeader>
<CardTitle title={intl.formatMessage(messages.members)} />
</CardHeader>
<List> <List>
<ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} /> <ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} />
<ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} /> <ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} />
</List> </List>
{group.relationship.role === 'admin' && (
{isOwner && (
<>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.other)} />
</CardHeader>
<List> <List>
<ListItem label={intl.formatMessage(messages.deleteGroup)} onClick={onDeleteGroup} /> <ListItem label={<Text theme='danger'>{intl.formatMessage(messages.deleteGroup)}</Text>} onClick={onDeleteGroup} />
</List> </List>
</>
)} )}
</CardBody> </CardBody>
</Column> </Column>

View File

@ -6,7 +6,7 @@ import GroupAvatar from 'soapbox/components/groups/group-avatar';
import { Button, HStack, Stack, Text } from 'soapbox/components/ui'; import { Button, HStack, Stack, Text } from 'soapbox/components/ui';
import GroupMemberCount from 'soapbox/features/group/components/group-member-count'; import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
import GroupPrivacy from 'soapbox/features/group/components/group-privacy'; import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import { useJoinGroup } from 'soapbox/queries/groups'; import { useJoinGroup } from 'soapbox/hooks/api';
import { Group as GroupEntity } from 'soapbox/types/entities'; import { Group as GroupEntity } from 'soapbox/types/entities';
interface IGroup { interface IGroup {
@ -17,7 +17,7 @@ interface IGroup {
const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => { const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
const { group, width = 'auto' } = props; const { group, width = 'auto' } = props;
const joinGroup = useJoinGroup(); const joinGroup = useJoinGroup(group);
const onJoinGroup = () => joinGroup.mutate(group); const onJoinGroup = () => joinGroup.mutate(group);

View File

@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import GroupAvatar from 'soapbox/components/groups/group-avatar'; import GroupAvatar from 'soapbox/components/groups/group-avatar';
import { Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import { Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import { useJoinGroup } from 'soapbox/queries/groups'; import { useJoinGroup } from 'soapbox/hooks/api';
import { Group as GroupEntity } from 'soapbox/types/entities'; import { Group as GroupEntity } from 'soapbox/types/entities';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { shortNumberFormat } from 'soapbox/utils/numbers';
@ -16,7 +16,7 @@ interface IGroup {
const GroupListItem = (props: IGroup) => { const GroupListItem = (props: IGroup) => {
const { group, withJoinAction = true } = props; const { group, withJoinAction = true } = props;
const joinGroup = useJoinGroup(); const joinGroup = useJoinGroup(group);
const onJoinGroup = () => joinGroup.mutate(group); const onJoinGroup = () => joinGroup.mutate(group);

View File

@ -269,6 +269,7 @@ const Notification: React.FC<INotificaton> = (props) => {
return ( return (
<Emoji <Emoji
emoji={notification.emoji} emoji={notification.emoji}
src={notification.emoji_url || undefined}
className='h-4 w-4 flex-none' className='h-4 w-4 flex-none'
/> />
); );

View File

@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
import { fetchMfa } from 'soapbox/actions/mfa'; import { fetchMfa } from 'soapbox/actions/mfa';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { Card, CardBody, CardHeader, CardTitle, Column } from 'soapbox/components/ui'; import { Card, CardBody, CardHeader, CardTitle, Column, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import Preferences from '../preferences'; import Preferences from '../preferences';
@ -155,7 +155,7 @@ const Settings = () => {
))} ))}
{features.security && ( {features.security && (
<ListItem label={intl.formatMessage(messages.deleteAccount)} onClick={navigateToDeleteAccount} /> <ListItem label={<Text theme='danger'>{intl.formatMessage(messages.deleteAccount)}</Text>} onClick={navigateToDeleteAccount} />
)} )}
</List> </List>
</CardBody> </CardBody>

View File

@ -154,6 +154,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
key={i} key={i}
className='h-4.5 w-4.5 flex-none' className='h-4.5 w-4.5 flex-none'
emoji={e.get('name')} emoji={e.get('name')}
src={e.get('url')}
/> />
); );
})} })}

View File

@ -114,7 +114,11 @@ export const getDescendantsIds = createSelector([
}); });
type DisplayMedia = 'default' | 'hide_all' | 'show_all'; type DisplayMedia = 'default' | 'hide_all' | 'show_all';
type RouteParams = { statusId: string };
type RouteParams = {
statusId: string
groupId?: string
};
interface IThread { interface IThread {
params: RouteParams params: RouteParams
@ -515,6 +519,10 @@ const Thread: React.FC<IThread> = (props) => {
children.push(...renderChildren(descendantsIds).toArray()); children.push(...renderChildren(descendantsIds).toArray());
} }
if (status.group && typeof status.group === 'object' && !props.params.groupId) {
return <Redirect to={`/groups/${status.group.id}/posts/${props.params.statusId}`} />;
}
return ( return (
<Column label={intl.formatMessage(titleMessage, { username })} transparent> <Column label={intl.formatMessage(titleMessage, { username })} transparent>
<PullToRefresh onRefresh={handleRefresh}> <PullToRefresh onRefresh={handleRefresh}>

View File

@ -18,11 +18,6 @@ const PrivacyStep = () => {
return ( return (
<> <>
<Stack className='mx-auto max-w-sm' space={2}> <Stack className='mx-auto max-w-sm' space={2}>
<img
className='mx-auto w-32'
src={require('assets/images/group.svg')}
alt=''
/>
<Text size='3xl' weight='bold' align='center'> <Text size='3xl' weight='bold' align='center'>
<FormattedMessage id='manage_group.get_started' defaultMessage='Lets get started!' /> <FormattedMessage id='manage_group.get_started' defaultMessage='Lets get started!' />
</Text> </Text>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions'; import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions';
@ -17,6 +17,12 @@ const messages = defineMessages({
all: { id: 'reactions.all', defaultMessage: 'All' }, all: { id: 'reactions.all', defaultMessage: 'All' },
}); });
interface IAccountWithReaction {
id: string
reaction: string
reactionUrl?: string
}
interface IReactionsModal { interface IReactionsModal {
onClose: (string: string) => void onClose: (string: string) => void
statusId: string statusId: string
@ -54,7 +60,7 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
reactions!.forEach(reaction => items.push( reactions!.forEach(reaction => items.push(
{ {
text: <div className='flex items-center gap-1'> text: <div className='flex items-center gap-1'>
<Emoji className='h-4 w-4' emoji={reaction.name} /> <Emoji className='h-4 w-4' emoji={reaction.name} src={reaction.url || undefined} />
{reaction.count} {reaction.count}
</div>, </div>,
action: () => setReaction(reaction.name), action: () => setReaction(reaction.name),
@ -65,17 +71,25 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
return <Tabs items={items} activeItem={reaction || 'all'} />; return <Tabs items={items} activeItem={reaction || 'all'} />;
}; };
const accounts = useMemo((): ImmutableList<IAccountWithReaction> | undefined => {
if (!reactions) return;
if (reaction) {
const reactionRecord = reactions.find(({ name }) => name === reaction);
if (reactionRecord) return reactionRecord.accounts.map(account => ({ id: account, reaction: reaction, reactionUrl: reactionRecord.url || undefined })).toList();
} else {
return reactions.map(({ accounts, name, url }) => accounts.map(account => ({ id: account, reaction: name, reactionUrl: url }))).flatten() as ImmutableList<IAccountWithReaction>;
}
}, [reactions, reaction]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, []); }, []);
const accounts = reactions && (reaction
? reactions.find(({ name }) => name === reaction)?.accounts.map(account => ({ id: account, reaction: reaction }))
: reactions.map(({ accounts, name }) => accounts.map(account => ({ id: account, reaction: name }))).flatten()) as ImmutableList<{ id: string, reaction: string }>;
let body; let body;
if (!accounts) { if (!accounts || !reactions) {
body = <Spinner />; body = <Spinner />;
} else { } else {
const emptyMessage = <FormattedMessage id='status.reactions.empty' defaultMessage='No one has reacted to this post yet. When someone does, they will show up here.' />; const emptyMessage = <FormattedMessage id='status.reactions.empty' defaultMessage='No one has reacted to this post yet. When someone does, they will show up here.' />;
@ -91,7 +105,7 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
itemClassName='pb-3' itemClassName='pb-3'
> >
{accounts.map((account) => {accounts.map((account) =>
<AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} emoji={account.reaction} />, <AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} emoji={account.reaction} emojiUrl={account.reactionUrl} />,
)} )}
</ScrollableList> </ScrollableList>
</>); </>);

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Widget } from 'soapbox/components/ui';
import GroupListItem from 'soapbox/features/groups/components/discover/group-list-item';
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
import { useGroups } from 'soapbox/hooks';
const MyGroupsPanel = () => {
const { groups, isFetching, isFetched, isError } = useGroups();
const isEmpty = (isFetched && groups.length === 0) || isError;
if (isEmpty) {
return null;
}
return (
<Widget
title='My Groups'
>
{isFetching ? (
new Array(3).fill(0).map((_, idx) => (
<PlaceholderGroupSearch key={idx} />
))
) : (
groups.slice(0, 3).map((group) => (
<GroupListItem group={group} withJoinAction={false} key={group.id} />
))
)}
</Widget>
);
};
export default MyGroupsPanel;

View File

@ -4,7 +4,7 @@ import React from 'react';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import StatusContent from 'soapbox/components/status-content'; import StatusContent from 'soapbox/components/status-content';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import { Card, HStack } from 'soapbox/components/ui'; import { Card, HStack, Stack } from 'soapbox/components/ui';
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card'; import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
import PlaceholderMediaGallery from 'soapbox/features/placeholder/components/placeholder-media-gallery'; import PlaceholderMediaGallery from 'soapbox/features/placeholder/components/placeholder-media-gallery';
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container'; import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
@ -78,6 +78,7 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
<div className='status__content-wrapper'> <div className='status__content-wrapper'>
<StatusReplyMentions status={status} /> <StatusReplyMentions status={status} />
<Stack space={4}>
<StatusContent <StatusContent
status={status} status={status}
collapsable collapsable
@ -88,6 +89,7 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
{status.poll && <PollPreview pollId={status.poll as string} />} {status.poll && <PollPreview pollId={status.poll as string} />}
{status.quote && <QuotedStatus statusId={status.quote as string} />} {status.quote && <QuotedStatus statusId={status.quote as string} />}
</Stack>
</div> </div>
{/* TODO */} {/* TODO */}

View File

@ -299,6 +299,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} 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/blocks' exact page={DefaultPage} component={GroupBlockedMembers} content={children} />}
{features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={DefaultPage} component={GroupMembershipRequests} content={children} />} {features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={DefaultPage} component={GroupMembershipRequests} content={children} />}
{features.groups && <WrappedRoute path='/groups/:groupId/posts/:statusId' exact page={StatusPage} component={Status} 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} />

View File

@ -590,6 +590,10 @@ export function NewGroupPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel'); return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
} }
export function MyGroupsPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/panels/my-groups-panel');
}
export function SuggestedGroupsPanel() { export function SuggestedGroupsPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel'); return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel');
} }

View File

@ -4,7 +4,7 @@ import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group, GroupMember } from 'soapbox/schemas'; import type { Group, GroupMember } from 'soapbox/schemas';
function useBlockGroupMember(group: Group, groupMember: GroupMember) { function useBlockGroupMember(group: Group, groupMember: GroupMember) {
const { createEntity } = useEntityActions( const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id], [Entities.GROUP_MEMBERSHIPS, groupMember.id],
{ post: `/api/v1/groups/${group.id}/blocks` }, { post: `/api/v1/groups/${group.id}/blocks` },
); );

View File

@ -0,0 +1,21 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { useOwnAccount } from 'soapbox/hooks';
import type { Group, GroupRelationship } from 'soapbox/schemas';
function useCancelMembershipRequest(group: Group) {
const me = useOwnAccount();
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject` },
);
return {
mutate: createEntity,
isLoading,
};
}
export { useCancelMembershipRequest };

View File

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group } from 'soapbox/schemas';
function useDeleteGroup() {
const { deleteEntity, isLoading } = useEntityActions<Group>(
[Entities.GROUPS],
{ delete: '/api/v1/groups/:id' },
);
return {
mutate: deleteEntity,
isLoading,
};
}
export { useDeleteGroup };

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { groupMemberSchema } from 'soapbox/schemas';
import type { Group, GroupMember } from 'soapbox/schemas';
function useDemoteGroupMember(group: Group, groupMember: GroupMember) {
const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
{ post: `/api/v1/groups/${group.id}/demote` },
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]) },
);
return createEntity;
}
export { useDemoteGroupMember };

View File

@ -0,0 +1,20 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { groupRelationshipSchema } from 'soapbox/schemas';
import type { Group, GroupRelationship } from 'soapbox/schemas';
function useJoinGroup(group: Group) {
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/join` },
{ schema: groupRelationshipSchema },
);
return {
mutate: createEntity,
isLoading,
};
}
export { useJoinGroup };

View File

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { Group, GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
function useLeaveGroup(group: Group) {
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/leave` },
{ schema: groupRelationshipSchema },
);
return {
mutate: createEntity,
isLoading,
};
}
export { useLeaveGroup };

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { groupMemberSchema } from 'soapbox/schemas';
import type { Group, GroupMember } from 'soapbox/schemas';
function usePromoteGroupMember(group: Group, groupMember: GroupMember) {
const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
{ post: `/api/v1/groups/${group.id}/promote` },
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]) },
);
return createEntity;
}
export { usePromoteGroupMember };

View File

@ -0,0 +1,10 @@
/**
* Groups
*/
export { useBlockGroupMember } from './groups/useBlockGroupMember';
export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest';
export { useDeleteGroup } from './groups/useDeleteGroup';
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
export { useJoinGroup } from './groups/useJoinGroup';
export { useLeaveGroup } from './groups/useLeaveGroup';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';

View File

@ -9,7 +9,7 @@ function usePopularGroups() {
const features = useFeatures(); const features = useFeatures();
const { entities, ...result } = useEntities<Group>( const { entities, ...result } = useEntities<Group>(
[Entities.POPULAR_GROUPS, ''], [Entities.GROUPS, 'popular'],
'/api/mock/groups', // '/api/v1/truth/trends/groups' '/api/mock/groups', // '/api/v1/truth/trends/groups'
{ {
schema: groupSchema, schema: groupSchema,

View File

@ -9,7 +9,7 @@ function useSuggestedGroups() {
const features = useFeatures(); const features = useFeatures();
const { entities, ...result } = useEntities<Group>( const { entities, ...result } = useEntities<Group>(
[Entities.SUGGESTED_GROUPS, ''], [Entities.GROUPS, 'suggested'],
'/api/mock/groups', // '/api/v1/truth/suggestions/groups' '/api/mock/groups', // '/api/v1/truth/suggestions/groups'
{ {
schema: groupSchema, schema: groupSchema,

View File

@ -1,51 +0,0 @@
import { TRUTHSOCIAL } from 'soapbox/utils/features';
import { useBackend } from './useBackend';
enum TruthSocialGroupRoles {
ADMIN = 'owner',
MODERATOR = 'admin',
USER = 'user'
}
enum BaseGroupRoles {
ADMIN = 'admin',
MODERATOR = 'moderator',
USER = 'user'
}
const roleMap = {
[TruthSocialGroupRoles.ADMIN]: BaseGroupRoles.ADMIN,
[TruthSocialGroupRoles.MODERATOR]: BaseGroupRoles.MODERATOR,
[TruthSocialGroupRoles.USER]: BaseGroupRoles.USER,
};
/**
* Returns the correct role name depending on the used backend.
*
* @returns Object
*/
const useGroupRoles = () => {
const version = useBackend();
const isTruthSocial = version.software === TRUTHSOCIAL;
const selectedRoles = isTruthSocial ? TruthSocialGroupRoles : BaseGroupRoles;
const normalizeRole = (role: TruthSocialGroupRoles) => {
if (isTruthSocial) {
return roleMap[role];
}
return role;
};
return {
normalizeRole,
roles: {
admin: selectedRoles.ADMIN,
moderator: selectedRoles.MODERATOR,
user: selectedRoles.USER,
},
};
};
export { useGroupRoles, TruthSocialGroupRoles, BaseGroupRoles };

View File

@ -5,11 +5,15 @@ import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
import { groupSchema, Group } from 'soapbox/schemas/group'; import { groupSchema, Group } from 'soapbox/schemas/group';
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship'; import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
import { useFeatures } from './useFeatures';
function useGroups() { function useGroups() {
const features = useFeatures();
const { entities, ...result } = useEntities<Group>( const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, ''], [Entities.GROUPS, ''],
'/api/v1/groups', '/api/v1/groups',
{ schema: groupSchema }, { enabled: features.groups, schema: groupSchema },
); );
const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); const { relationships } = useGroupRelationships(entities.map(entity => entity.id));

View File

@ -468,8 +468,8 @@
"confirmations.block.confirm": "Block", "confirmations.block.confirm": "Block",
"confirmations.block.heading": "Block @{name}", "confirmations.block.heading": "Block @{name}",
"confirmations.block.message": "Are you sure you want to block {name}?", "confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.block_from_group.confirm": "Block", "confirmations.block_from_group.confirm": "Ban User",
"confirmations.block_from_group.heading": "Block group member", "confirmations.block_from_group.heading": "Ban From Group",
"confirmations.block_from_group.message": "Are you sure you want to ban @{name} from the group?", "confirmations.block_from_group.message": "Are you sure you want to ban @{name} from the group?",
"confirmations.cancel.confirm": "Discard", "confirmations.cancel.confirm": "Discard",
"confirmations.cancel.heading": "Discard post", "confirmations.cancel.heading": "Discard post",
@ -507,8 +507,6 @@
"confirmations.mute.confirm": "Mute", "confirmations.mute.confirm": "Mute",
"confirmations.mute.heading": "Mute @{name}", "confirmations.mute.heading": "Mute @{name}",
"confirmations.mute.message": "Are you sure you want to 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.confirm": "Delete & redraft",
"confirmations.redraft.heading": "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.", "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.",
@ -766,18 +764,16 @@
"gdpr.title": "{siteTitle} uses cookies", "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}).", "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
"group.cancel_request": "Cancel Request", "group.cancel_request": "Cancel Request",
"group.delete.success": "Group successfully deleted",
"group.demote.user.success": "@{name} is now a member",
"group.group_mod_authorize": "Accept", "group.group_mod_authorize": "Accept",
"group.group_mod_authorize.success": "Accepted @{name} to group", "group.group_mod_authorize.success": "Accepted @{name} to group",
"group.group_mod_block": "Ban from group", "group.group_mod_block": "Ban from group",
"group.group_mod_block.success": "You have successfully blocked @{name} from the group", "group.group_mod_block.success": "@{name} is banned",
"group.group_mod_demote": "Demote @{name}", "group.group_mod_demote": "Remove {role} role",
"group.group_mod_demote.success": "Demoted @{name} to group user",
"group.group_mod_kick": "Kick @{name} from group", "group.group_mod_kick": "Kick @{name} from group",
"group.group_mod_kick.success": "Kicked @{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_mod": "Assign {role} role",
"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": "Reject",
"group.group_mod_reject.success": "Rejected @{name} from group", "group.group_mod_reject.success": "Rejected @{name} from group",
"group.group_mod_unblock": "Unblock", "group.group_mod_unblock": "Unblock",
@ -796,6 +792,9 @@
"group.privacy.public": "Public", "group.privacy.public": "Public",
"group.privacy.public.full": "Public Group", "group.privacy.public.full": "Public Group",
"group.privacy.public.info": "Discoverable. Anyone can join.", "group.privacy.public.info": "Discoverable. Anyone can join.",
"group.promote.admin.confirmation.message": "Are you sure you want to assign the admin role to @{name}?",
"group.promote.admin.confirmation.title": "Assign Admin Role",
"group.promote.admin.success": "@{name} is now an admin",
"group.role.admin": "Admin", "group.role.admin": "Admin",
"group.role.moderator": "Moderator", "group.role.moderator": "Moderator",
"group.tabs.all": "All", "group.tabs.all": "All",
@ -927,7 +926,7 @@
"login_external.errors.instance_fail": "The instance returned an error.", "login_external.errors.instance_fail": "The instance returned an error.",
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?", "login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
"login_form.header": "Sign In", "login_form.header": "Sign In",
"manage_group.blocked_members": "Blocked members", "manage_group.blocked_members": "Banned members",
"manage_group.confirmation.copy": "Copy link", "manage_group.confirmation.copy": "Copy link",
"manage_group.confirmation.info_1": "As the owner of this group, you can assign staff, delete posts and much more.", "manage_group.confirmation.info_1": "As the owner of this group, you can assign staff, delete posts and much more.",
"manage_group.confirmation.info_2": "Post the group's first post and get the conversation started.", "manage_group.confirmation.info_2": "Post the group's first post and get the conversation started.",

View File

@ -17,6 +17,7 @@ export const NotificationRecord = ImmutableRecord({
chat_message: null as ImmutableMap<string, any> | string | null, // pleroma:chat_mention chat_message: null as ImmutableMap<string, any> | string | null, // pleroma:chat_mention
created_at: new Date(), created_at: new Date(),
emoji: null as string | null, // pleroma:emoji_reaction emoji: null as string | null, // pleroma:emoji_reaction
emoji_url: null as string | null, // pleroma:emoji_reaction
id: '', id: '',
status: null as EmbeddedEntity<Status>, status: null as EmbeddedEntity<Status>,
target: null as EmbeddedEntity<Account>, // move target: null as EmbeddedEntity<Account>, // move

View File

@ -10,6 +10,7 @@ import {
CtaBanner, CtaBanner,
GroupMediaPanel, GroupMediaPanel,
SignUpPanel, SignUpPanel,
SuggestedGroupsPanel,
} from 'soapbox/features/ui/util/async-components'; } from 'soapbox/features/ui/util/async-components';
import { useGroup, useOwnAccount } from 'soapbox/hooks'; import { useGroup, useOwnAccount } from 'soapbox/hooks';
import { Group } from 'soapbox/schemas'; import { Group } from 'soapbox/schemas';
@ -127,6 +128,9 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
<BundleContainer fetchComponent={GroupMediaPanel}> <BundleContainer fetchComponent={GroupMediaPanel}>
{Component => <Component group={group} />} {Component => <Component group={group} />}
</BundleContainer> </BundleContainer>
<BundleContainer fetchComponent={SuggestedGroupsPanel}>
{Component => <Component />}
</BundleContainer>
<LinkFooter key='link-footer' /> <LinkFooter key='link-footer' />
</Layout.Aside> </Layout.Aside>
</> </>

View File

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Column, Layout } from 'soapbox/components/ui'; import { Column, Layout } from 'soapbox/components/ui';
import LinkFooter from 'soapbox/features/ui/components/link-footer'; import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { NewGroupPanel } from 'soapbox/features/ui/util/async-components'; import { MyGroupsPanel, NewGroupPanel, SuggestedGroupsPanel } from 'soapbox/features/ui/util/async-components';
interface IGroupsPage { interface IGroupsPage {
children: React.ReactNode children: React.ReactNode
@ -22,10 +23,28 @@ const GroupsPage: React.FC<IGroupsPage> = ({ children }) => (
<Layout.Aside> <Layout.Aside>
<BundleContainer fetchComponent={NewGroupPanel}> <BundleContainer fetchComponent={NewGroupPanel}>
{Component => <Component key='new-group-panel' />} {Component => <Component />}
</BundleContainer> </BundleContainer>
<Routes>
<Route
path='/groups'
element={(
<BundleContainer fetchComponent={SuggestedGroupsPanel}>
{Component => <Component />}
</BundleContainer>
)}
/>
<Route
path='/groups/discover'
element={(
<BundleContainer fetchComponent={MyGroupsPanel}>
{Component => <Component />}
</BundleContainer>
)}
/>
</Routes>
<LinkFooter key='link-footer' /> <LinkFooter />
</Layout.Aside> </Layout.Aside>
</> </>
); );

View File

@ -1,22 +1,12 @@
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios'; import { AxiosRequestConfig } from 'axios';
import { defineMessages, useIntl } from 'react-intl';
import { getNextLink } from 'soapbox/api'; import { getNextLink } from 'soapbox/api';
import { useApi, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useApi, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers'; import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
import toast from 'soapbox/toast';
import { Group, GroupRelationship } from 'soapbox/types/entities'; import { Group, GroupRelationship } from 'soapbox/types/entities';
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries'; import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
import { queryClient } from './client';
const messages = defineMessages({
joinSuccess: { id: 'group.join.success', defaultMessage: 'Group joined successfully!' },
joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' },
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
});
const GroupKeys = { const GroupKeys = {
group: (id: string) => ['groups', 'group', id] as const, group: (id: string) => ['groups', 'group', id] as const,
myGroups: (userId: string) => ['groups', userId] as const, myGroups: (userId: string) => ['groups', userId] as const,
@ -168,50 +158,8 @@ const useGroup = (id: string) => {
}; };
}; };
const useJoinGroup = () => {
const api = useApi();
const intl = useIntl();
return useMutation((group: Group) => api.post<GroupRelationship>(`/api/v1/groups/${group.id}/join`), {
onSuccess(_response, group) {
queryClient.invalidateQueries(['groups']);
toast.success(
group.locked
? intl.formatMessage(messages.joinRequestSuccess)
: intl.formatMessage(messages.joinSuccess),
);
},
});
};
const useLeaveGroup = () => {
const api = useApi();
const intl = useIntl();
return useMutation((group: Group) => api.post<GroupRelationship>(`/api/v1/groups/${group.id}/leave`), {
onSuccess() {
queryClient.invalidateQueries({ queryKey: ['groups'] });
toast.success(intl.formatMessage(messages.leaveSuccess));
},
});
};
const useCancelMembershipRequest = () => {
const api = useApi();
const me = useOwnAccount();
return useMutation((group: Group) => api.post(`/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject`), {
onSuccess() {
queryClient.invalidateQueries({ queryKey: ['groups'] });
},
});
};
export { export {
useCancelMembershipRequest,
useGroup, useGroup,
useGroups, useGroups,
useJoinGroup,
useLeaveGroup,
usePendingGroups, usePendingGroups,
}; };

View File

@ -1,14 +1,14 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useApi } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks';
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
import { normalizeAccount } from 'soapbox/normalizers'; import { normalizeAccount } from 'soapbox/normalizers';
import { GroupRoles } from 'soapbox/schemas/group-member';
const GroupMemberKeys = { const GroupMemberKeys = {
members: (id: string, role: string) => ['group', id, role] as const, members: (id: string, role: string) => ['group', id, role] as const,
}; };
const useGroupMembers = (groupId: string, role: ReturnType<typeof useGroupRoles>['roles']['admin']) => { const useGroupMembers = (groupId: string, role: GroupRoles) => {
const api = useApi(); const api = useApi();
const getQuery = async () => { const getQuery = async () => {

View File

@ -242,7 +242,7 @@ export default function statuses(state = initialState, action: AnyAction): State
return state return state
.updateIn( .updateIn(
[action.status.get('id'), 'pleroma', 'emoji_reactions'], [action.status.get('id'), 'pleroma', 'emoji_reactions'],
emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji), emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji, action.custom),
); );
case UNEMOJI_REACT_REQUEST: case UNEMOJI_REACT_REQUEST:
return state return state

View File

@ -82,6 +82,7 @@ export const ReactionRecord = ImmutableRecord({
accounts: ImmutableOrderedSet<string>(), accounts: ImmutableOrderedSet<string>(),
count: 0, count: 0,
name: '', name: '',
url: null as string | null,
}); });
const ReactionListRecord = ImmutableRecord({ const ReactionListRecord = ImmutableRecord({

View File

@ -2,27 +2,18 @@ import z from 'zod';
import { accountSchema } from './account'; import { accountSchema } from './account';
enum TruthSocialGroupRoles { enum GroupRoles {
ADMIN = 'owner', OWNER = 'owner',
MODERATOR = 'admin',
USER = 'user'
}
enum BaseGroupRoles {
ADMIN = 'admin', ADMIN = 'admin',
MODERATOR = 'moderator',
USER = 'user' USER = 'user'
} }
const groupMemberSchema = z.object({ const groupMemberSchema = z.object({
id: z.string(), id: z.string(),
account: accountSchema, account: accountSchema,
role: z.union([ role: z.nativeEnum(GroupRoles),
z.nativeEnum(TruthSocialGroupRoles),
z.nativeEnum(BaseGroupRoles),
]),
}); });
type GroupMember = z.infer<typeof groupMemberSchema>; type GroupMember = z.infer<typeof groupMemberSchema>;
export { groupMemberSchema, GroupMember }; export { groupMemberSchema, GroupMember, GroupRoles };

View File

@ -7,6 +7,7 @@ import {
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import { Entities } from 'soapbox/entity-store/entities';
import { getDomain } from 'soapbox/utils/accounts'; import { getDomain } from 'soapbox/utils/accounts';
import { validId } from 'soapbox/utils/auth'; import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db'; import ConfigDB from 'soapbox/utils/config-db';
@ -14,9 +15,10 @@ import { getFeatures } from 'soapbox/utils/features';
import { shouldFilter } from 'soapbox/utils/timelines'; import { shouldFilter } from 'soapbox/utils/timelines';
import type { ContextType } from 'soapbox/normalizers/filter'; import type { ContextType } from 'soapbox/normalizers/filter';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
import type { ReducerChat } from 'soapbox/reducers/chats'; import type { ReducerChat } from 'soapbox/reducers/chats';
import type { RootState } from 'soapbox/store'; import type { RootState } from 'soapbox/store';
import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities'; import type { Filter as FilterEntity, Notification, Status, Group } from 'soapbox/types/entities';
const normalizeId = (id: any): string => typeof id === 'string' ? id : ''; const normalizeId = (id: any): string => typeof id === 'string' ? id : '';
@ -180,11 +182,11 @@ type APIStatus = { id: string, username?: string };
export const makeGetStatus = () => { export const makeGetStatus = () => {
return createSelector( return createSelector(
[ [
(state: RootState, { id }: APIStatus) => state.statuses.get(id), (state: RootState, { id }: APIStatus) => state.statuses.get(id) as Status | undefined,
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''), (state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || '') as Status | undefined,
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''), (state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || '') as ReducerAccount | undefined,
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''), (state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || '') as ReducerAccount | undefined,
(state: RootState, { id }: APIStatus) => state.groups.items.get(state.statuses.get(id)?.group || ''), (state: RootState, { id }: APIStatus) => state.entities[Entities.GROUPS]?.store[state.statuses.get(id)?.group || ''] as Group | undefined,
(_state: RootState, { username }: APIStatus) => username, (_state: RootState, { username }: APIStatus) => username,
getFilters, getFilters,
(state: RootState) => state.me, (state: RootState) => state.me,
@ -207,7 +209,7 @@ export const makeGetStatus = () => {
statusReblog = undefined; statusReblog = undefined;
} }
return statusBase.withMutations(map => { return statusBase.withMutations((map: Status) => {
map.set('reblog', statusReblog || null); map.set('reblog', statusReblog || null);
// @ts-ignore :( // @ts-ignore :(
map.set('account', accountBase || null); map.set('account', accountBase || null);

View File

@ -52,15 +52,15 @@ describe('mergeEmojiFavourites', () => {
describe('with existing 👍 reacts', () => { describe('with existing 👍 reacts', () => {
const emojiReacts = fromJS([ const emojiReacts = fromJS([
{ 'count': 20, 'me': false, 'name': '👍' }, { 'count': 20, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 15, 'me': false, 'name': '❤' }, { 'count': 15, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 7, 'me': false, 'name': '😯' }, { 'count': 7, 'me': false, 'name': '😯', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>; ]) as ImmutableList<ImmutableMap<string, any>>;
it('combines 👍 reacts with favourites', () => { it('combines 👍 reacts with favourites', () => {
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([ expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
{ 'count': 32, 'me': true, 'name': '👍' }, { 'count': 32, 'me': true, 'name': '👍', 'url': undefined },
{ 'count': 15, 'me': false, 'name': '❤' }, { 'count': 15, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 7, 'me': false, 'name': '😯' }, { 'count': 7, 'me': false, 'name': '😯', 'url': undefined },
])); ]));
}); });
}); });
@ -146,12 +146,12 @@ describe('getReactForStatus', () => {
], ],
}, },
})); }));
expect(getReactForStatus(status, ALLOWED_EMOJI)).toEqual('❤'); expect(getReactForStatus(status, ALLOWED_EMOJI)?.get('name')).toEqual('❤');
}); });
it('returns a thumbs-up for a favourite', () => { it('returns a thumbs-up for a favourite', () => {
const status = normalizeStatus(fromJS({ favourites_count: 1, favourited: true })); const status = normalizeStatus(fromJS({ favourites_count: 1, favourited: true }));
expect(getReactForStatus(status)).toEqual('👍'); expect(getReactForStatus(status)?.get('name')).toEqual('👍');
}); });
it('returns undefined when a status has no reacts (or favourites)', () => { it('returns undefined when a status has no reacts (or favourites)', () => {
@ -173,24 +173,36 @@ describe('getReactForStatus', () => {
describe('simulateEmojiReact', () => { describe('simulateEmojiReact', () => {
it('adds the emoji to the list', () => { it('adds the emoji to the list', () => {
const emojiReacts = fromJS([ const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>; ]) as ImmutableList<ImmutableMap<string, any>>;
expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([ expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 3, 'me': true, 'name': '❤' }, { 'count': 3, 'me': true, 'name': '❤', 'url': undefined },
])); ]));
}); });
it('creates the emoji if it didn\'t already exist', () => { it('creates the emoji if it didn\'t already exist', () => {
const emojiReacts = fromJS([ const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>; ]) as ImmutableList<ImmutableMap<string, any>>;
expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([ expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' }, { 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 1, 'me': true, 'name': '😯' }, { 'count': 1, 'me': true, 'name': '😯', 'url': undefined },
]));
});
it('adds a custom emoji to the list', () => {
const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
]) as ImmutableList<ImmutableMap<string, any>>;
expect(simulateEmojiReact(emojiReacts, 'soapbox', 'https://gleasonator.com/emoji/Gleasonator/soapbox.png')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
{ 'count': 1, 'me': true, 'name': 'soapbox', 'url': 'https://gleasonator.com/emoji/Gleasonator/soapbox.png' },
])); ]));
}); });
}); });
@ -218,4 +230,16 @@ describe('simulateUnEmojiReact', () => {
{ 'count': 2, 'me': false, 'name': '❤' }, { 'count': 2, 'me': false, 'name': '❤' },
])); ]));
}); });
it ('removes custom emoji from the list', () => {
const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
{ 'count': 1, 'me': true, 'name': 'soapbox', 'url': 'https://gleasonator.com/emoji/Gleasonator/soapbox.png' },
]) as ImmutableList<ImmutableMap<string, any>>;
expect(simulateUnEmojiReact(emojiReacts, 'soapbox')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
]));
});
}); });

View File

@ -74,19 +74,19 @@ export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReact>, favouritesCo
allowedEmoji, allowedEmoji,
)); ));
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): string | undefined => { export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): EmojiReact | undefined => {
const result = reduceEmoji( const result = reduceEmoji(
status.pleroma.get('emoji_reactions', ImmutableList()), status.pleroma.get('emoji_reactions', ImmutableList()),
status.favourites_count || 0, status.favourites_count || 0,
status.favourited, status.favourited,
allowedEmoji, allowedEmoji,
).filter(e => e.get('me') === true) ).filter(e => e.get('me') === true)
.getIn([0, 'name']); .get(0);
return typeof result === 'string' ? result : undefined; return typeof result?.get('name') === 'string' ? result : undefined;
}; };
export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji: string) => { export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji: string, url?: string) => {
const idx = emojiReacts.findIndex(e => e.get('name') === emoji); const idx = emojiReacts.findIndex(e => e.get('name') === emoji);
const emojiReact = emojiReacts.get(idx); const emojiReact = emojiReacts.get(idx);
@ -94,12 +94,14 @@ export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji
return emojiReacts.set(idx, emojiReact.merge({ return emojiReacts.set(idx, emojiReact.merge({
count: emojiReact.get('count') + 1, count: emojiReact.get('count') + 1,
me: true, me: true,
url,
})); }));
} else { } else {
return emojiReacts.push(ImmutableMap({ return emojiReacts.push(ImmutableMap({
count: 1, count: 1,
me: true, me: true,
name: emoji, name: emoji,
url,
})); }));
} }
}; };

View File

@ -324,6 +324,15 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === TAKAHE, v.software === TAKAHE,
]), ]),
/**
* Ability to add non-standard reactions to a status.
*/
customEmojiReacts: any([
features.includes('pleroma_custom_emoji_reactions'),
features.includes('custom_emoji_reactions'),
v.software === PLEROMA && gte(v.version, '2.5.50'),
]),
/** /**
* Legacy DMs timeline where messages are displayed chronologically without groupings. * Legacy DMs timeline where messages are displayed chronologically without groupings.
* @see GET /api/v1/timelines/direct * @see GET /api/v1/timelines/direct
@ -526,6 +535,11 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
groupsPending: v.software === TRUTHSOCIAL, groupsPending: v.software === TRUTHSOCIAL,
/**
* Can promote members to Admins.
*/
groupsPromoteToAdmin: v.software !== TRUTHSOCIAL,
/** /**
* Can hide follows/followers lists and counts. * Can hide follows/followers lists and counts.
* @see PATCH /api/v1/accounts/update_credentials * @see PATCH /api/v1/accounts/update_credentials

View File

@ -82,7 +82,6 @@
"@tanstack/react-query": "^4.0.10", "@tanstack/react-query": "^4.0.10",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/escape-html": "^1.0.1", "@types/escape-html": "^1.0.1",
"@types/flexsearch": "^0.7.3",
"@types/http-link-header": "^1.0.3", "@types/http-link-header": "^1.0.3",
"@types/jest": "^29.0.0", "@types/jest": "^29.0.0",
"@types/leaflet": "^1.8.0", "@types/leaflet": "^1.8.0",
@ -127,7 +126,7 @@
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"exif-js": "^2.3.0", "exif-js": "^2.3.0",
"flexsearch": "^0.7.31", "flexsearch-ts": "^0.7.31",
"fork-ts-checker-webpack-plugin": "^8.0.0", "fork-ts-checker-webpack-plugin": "^8.0.0",
"graphemesplit": "^2.4.4", "graphemesplit": "^2.4.4",
"html-webpack-harddisk-plugin": "^2.0.0", "html-webpack-harddisk-plugin": "^2.0.0",

View File

@ -4288,11 +4288,6 @@
resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.29.tgz#a48795ecadf957f6c0d10e0c34af86c098fa5bee" resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.29.tgz#a48795ecadf957f6c0d10e0c34af86c098fa5bee"
integrity sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ== integrity sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==
"@types/flexsearch@^0.7.3":
version "0.7.3"
resolved "https://registry.yarnpkg.com/@types/flexsearch/-/flexsearch-0.7.3.tgz#ee79b1618035c82284278e05652e91116765b634"
integrity sha512-HXwADeHEP4exXkCIwy2n1+i0f1ilP1ETQOH5KDOugjkTFZPntWo0Gr8stZOaebkxsdx+k0X/K6obU/+it07ocg==
"@types/fs-extra@^9.0.1": "@types/fs-extra@^9.0.1":
version "9.0.13" version "9.0.13"
resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45"
@ -9172,10 +9167,10 @@ flatted@^3.1.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561"
integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA== integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
flexsearch@^0.7.31: flexsearch-ts@^0.7.31:
version "0.7.31" version "0.7.31"
resolved "https://registry.yarnpkg.com/flexsearch/-/flexsearch-0.7.31.tgz#065d4110b95083110b9b6c762a71a77cc52e4702" resolved "https://registry.yarnpkg.com/flexsearch-ts/-/flexsearch-ts-0.7.31.tgz#0353f51789ad8e7660c3df157534dcf2d346a20f"
integrity sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA== integrity sha512-Z3geBbHiPw/JALe/thvxTd1LAgDcUNvQuHWGjhO4lG7gOR5IVVPsyS8tRt/qmme9HgXj3zdtHC4yJ3anGW1Xmw==
flush-write-stream@^1.0.0: flush-write-stream@^1.0.0:
version "1.1.1" version "1.1.1"