Merge remote-tracking branch 'soapbox/develop' into lexical
This commit is contained in:
commit
dadaadcdde
67 changed files with 695 additions and 555 deletions
|
@ -8,13 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
- Posts: Support posts filtering on recent Mastodon versions
|
||||
- Reactions: Support custom emoji reactions
|
||||
- Compatbility: Support Mastodon v2 timeline filters.
|
||||
|
||||
### Changed
|
||||
- Posts: truncate Nostr pubkeys in reply mentions.
|
||||
- Posts: upgraded emoji picker component.
|
||||
|
||||
### Fixed
|
||||
- Posts: fixed emojis being cut off in reactions modal.
|
||||
- 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
|
||||
|
||||
|
|
|
@ -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 |
|
@ -25,7 +25,7 @@ const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL';
|
|||
|
||||
const noOp = () => () => new Promise(f => f(undefined));
|
||||
|
||||
const simpleEmojiReact = (status: Status, emoji: string) =>
|
||||
const simpleEmojiReact = (status: Status, emoji: string, custom?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
const emojiReacts: ImmutableList<ImmutableMap<string, any>> = status.pleroma.get('emoji_reactions') || ImmutableList();
|
||||
|
||||
|
@ -43,7 +43,7 @@ const simpleEmojiReact = (status: Status, emoji: string) =>
|
|||
if (emoji === '👍') {
|
||||
dispatch(favourite(status));
|
||||
} else {
|
||||
dispatch(emojiReact(status, emoji));
|
||||
dispatch(emojiReact(status, emoji, custom));
|
||||
}
|
||||
}).catch(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) => {
|
||||
if (!isLoggedIn(getState)) return dispatch(noOp());
|
||||
|
||||
dispatch(emojiReactRequest(status, emoji));
|
||||
dispatch(emojiReactRequest(status, emoji, custom));
|
||||
|
||||
return api(getState)
|
||||
.put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
|
||||
|
@ -120,10 +120,11 @@ const fetchEmojiReactsFail = (id: string, error: AxiosError) => ({
|
|||
error,
|
||||
});
|
||||
|
||||
const emojiReactRequest = (status: Status, emoji: string) => ({
|
||||
const emojiReactRequest = (status: Status, emoji: string, custom?: string) => ({
|
||||
type: EMOJI_REACT_REQUEST,
|
||||
status,
|
||||
emoji,
|
||||
custom,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { deleteEntities } from 'soapbox/entity-store/actions';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
@ -191,7 +192,7 @@ const updateGroupFail = (error: AxiosError) => ({
|
|||
});
|
||||
|
||||
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(deleteGroupRequest(id));
|
||||
dispatch(deleteEntities([id], 'Group'));
|
||||
|
||||
return api(getState).delete(`/api/v1/groups/${id}`)
|
||||
.then(() => dispatch(deleteGroupSuccess(id)))
|
||||
|
|
|
@ -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 type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
@ -18,11 +23,11 @@ const importAccount = (account: APIEntity) =>
|
|||
const importAccounts = (accounts: APIEntity[]) =>
|
||||
({ type: ACCOUNTS_IMPORT, accounts });
|
||||
|
||||
const importGroup = (group: APIEntity) =>
|
||||
({ type: GROUP_IMPORT, group });
|
||||
const importGroup = (group: Group) =>
|
||||
importEntities([group], Entities.GROUPS);
|
||||
|
||||
const importGroups = (groups: APIEntity[]) =>
|
||||
({ type: GROUPS_IMPORT, groups });
|
||||
const importGroups = (groups: Group[]) =>
|
||||
importEntities(groups, Entities.GROUPS);
|
||||
|
||||
const importStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
|
@ -69,17 +74,8 @@ const importFetchedGroup = (group: APIEntity) =>
|
|||
importFetchedGroups([group]);
|
||||
|
||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||
const normalGroups: APIEntity[] = [];
|
||||
|
||||
const processGroup = (group: APIEntity) => {
|
||||
if (!group.id) return;
|
||||
|
||||
normalGroups.push(group);
|
||||
};
|
||||
|
||||
groups.forEach(processGroup);
|
||||
|
||||
return importGroups(normalGroups);
|
||||
const entities = filteredArray(groupSchema).catch([]).parse(groups);
|
||||
return importGroups(entities);
|
||||
};
|
||||
|
||||
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
|
|
|
@ -91,6 +91,7 @@ export interface IAccount {
|
|||
showEdit?: boolean
|
||||
approvalStatus?: StatusApprovalStatus
|
||||
emoji?: string
|
||||
emojiUrl?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
|
@ -116,6 +117,7 @@ const Account = ({
|
|||
showEdit = false,
|
||||
approvalStatus,
|
||||
emoji,
|
||||
emojiUrl,
|
||||
note,
|
||||
}: IAccount) => {
|
||||
const overflowRef = useRef<HTMLDivElement>(null);
|
||||
|
@ -193,6 +195,7 @@ const Account = ({
|
|||
<Emoji
|
||||
className='absolute bottom-0 -right-1.5 h-5 w-5'
|
||||
emoji={emoji}
|
||||
src={emojiUrl}
|
||||
/>
|
||||
)}
|
||||
</LinkEl>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { Avatar } from '../ui';
|
||||
|
||||
|
@ -16,17 +16,15 @@ interface IGroupAvatar {
|
|||
const GroupAvatar = (props: IGroupAvatar) => {
|
||||
const { group, size, withRing = false } = props;
|
||||
|
||||
const { normalizeRole } = useGroupRoles();
|
||||
|
||||
const isAdmin = normalizeRole(group.relationship?.role as any) === 'admin';
|
||||
const isOwner = group.relationship?.role === GroupRoles.OWNER;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className={
|
||||
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)]': isAdmin && !withRing,
|
||||
'shadow-[0_0_0_2px_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)]': isOwner && !withRing,
|
||||
'shadow-[0_0_0_2px_theme(colors.white)]': !isOwner && withRing,
|
||||
})
|
||||
}
|
||||
src={group.avatar}
|
||||
|
|
|
@ -538,7 +538,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
allowedEmoji,
|
||||
).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 = {
|
||||
'👍': messages.reactionLike,
|
||||
|
@ -550,7 +551,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
'': messages.favourite,
|
||||
};
|
||||
|
||||
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite);
|
||||
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiName || ''] || messages.favourite);
|
||||
|
||||
const menu = _makeMenu(publicStatus);
|
||||
let reblogIcon = require('@tabler/icons/repeat.svg');
|
||||
|
@ -635,7 +636,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
icon={require('@tabler/icons/heart.svg')}
|
||||
filled
|
||||
color='accent'
|
||||
active={Boolean(meEmojiReact)}
|
||||
active={Boolean(meEmojiName)}
|
||||
count={emojiReactCount}
|
||||
emoji={meEmojiReact}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
|
@ -648,7 +649,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
color='accent'
|
||||
filled
|
||||
onClick={handleFavouriteClick}
|
||||
active={Boolean(meEmojiReact)}
|
||||
active={Boolean(meEmojiName)}
|
||||
count={favouriteCount}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
/>
|
||||
|
|
|
@ -4,6 +4,8 @@ import React from 'react';
|
|||
import { Text, Icon, Emoji } from 'soapbox/components/ui';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
const COLORS = {
|
||||
accent: 'accent',
|
||||
success: 'success',
|
||||
|
@ -31,7 +33,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
|
|||
active?: boolean
|
||||
color?: Color
|
||||
filled?: boolean
|
||||
emoji?: string
|
||||
emoji?: ImmutableMap<string, any>
|
||||
text?: React.ReactNode
|
||||
}
|
||||
|
||||
|
@ -42,7 +44,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
|||
if (emoji) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -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) {
|
||||
dispatch(simpleEmojiReact(status, emoji));
|
||||
dispatch(simpleEmojiReact(status, emoji, custom));
|
||||
} else {
|
||||
handleUnauthorized();
|
||||
}
|
||||
|
@ -71,7 +71,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
|
|||
};
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍';
|
||||
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍';
|
||||
|
||||
if (isUserTouching()) {
|
||||
if (ownAccount) {
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { Placement } from '@popperjs/core';
|
||||
import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
import { Emoji as EmojiComponent, HStack, IconButton } from 'soapbox/components/ui';
|
||||
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 {
|
||||
/** Unicode emoji character. */
|
||||
|
@ -39,14 +38,13 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
|
|||
interface IEmojiSelector {
|
||||
onClose?(): void
|
||||
/** Event handler when an emoji is clicked. */
|
||||
onReact(emoji: string): void
|
||||
onReact(emoji: string, custom?: string): void
|
||||
/** Element that triggers the EmojiSelector Popper */
|
||||
referenceElement: HTMLElement | null
|
||||
placement?: Placement
|
||||
/** Whether the selector should be visible. */
|
||||
visible?: boolean
|
||||
/** X/Y offset of the floating picker. */
|
||||
offset?: [number, number]
|
||||
offsetOptions?: OffsetOptions
|
||||
/** Whether to allow any emoji to be chosen. */
|
||||
all?: boolean
|
||||
}
|
||||
|
@ -58,43 +56,17 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
onReact,
|
||||
placement = 'top',
|
||||
visible = false,
|
||||
offset = [-10, 0],
|
||||
offsetOptions,
|
||||
all = true,
|
||||
}): JSX.Element => {
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const { customEmojiReacts } = useFeatures();
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// `useRef` won't trigger a re-render, while `useState` does.
|
||||
// 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, {
|
||||
const { x, y, strategy, refs, update } = useFloating<HTMLElement>({
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset,
|
||||
},
|
||||
},
|
||||
],
|
||||
middleware: [offset(offsetOptions), shift()],
|
||||
});
|
||||
|
||||
const handleExpand: React.MouseEventHandler = () => {
|
||||
|
@ -102,9 +74,13 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
};
|
||||
|
||||
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(() => () => {
|
||||
document.body.style.overflow = '';
|
||||
}, []);
|
||||
|
@ -113,42 +89,31 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
setExpanded(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [referenceElement, popperElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && update) {
|
||||
update();
|
||||
useClickOutside(refs, () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [visible, update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && update) {
|
||||
update();
|
||||
}
|
||||
}, [expanded, update]);
|
||||
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('z-[101] transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
width: 'max-content',
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<EmojiPickerDropdown
|
||||
visible={expanded}
|
||||
setVisible={setExpanded}
|
||||
update={update}
|
||||
withCustom={false}
|
||||
withCustom={customEmojiReacts}
|
||||
onPickEmoji={handlePickEmoji}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
@ -10,7 +10,7 @@ interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|||
|
||||
/** A single emoji image. */
|
||||
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 filename = codepoints.join('-');
|
||||
|
||||
|
@ -20,7 +20,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
|
|||
<img
|
||||
draggable='false'
|
||||
alt={alt || emoji}
|
||||
src={joinPublicPath(`packs/emoji/${filename}.svg`)}
|
||||
src={src || joinPublicPath(`packs/emoji/${filename}.svg`)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -2,6 +2,4 @@ export enum Entities {
|
|||
GROUPS = 'Groups',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||
POPULAR_GROUPS = 'PopularGroups',
|
||||
SUGGESTED_GROUPS = 'SuggestedGroups',
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks';
|
||||
|
@ -28,6 +29,10 @@ interface EntityActionEndpoints {
|
|||
delete?: string
|
||||
}
|
||||
|
||||
interface EntityCallbacks<TEntity extends Entity = Entity> {
|
||||
onSuccess?(entity?: TEntity): void
|
||||
}
|
||||
|
||||
function useEntityActions<TEntity extends Entity = Entity, P = any>(
|
||||
path: EntityPath,
|
||||
endpoints: EntityActionEndpoints,
|
||||
|
@ -38,9 +43,13 @@ function useEntityActions<TEntity extends Entity = Entity, P = any>(
|
|||
const getState = useGetState();
|
||||
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);
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
return api.post(endpoints.post, params).then((response) => {
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entity = schema.parse(response.data);
|
||||
|
@ -48,6 +57,12 @@ function useEntityActions<TEntity extends Entity = Entity, P = any>(
|
|||
// TODO: optimistic updating
|
||||
dispatch(importEntities([entity], entityType, listKey));
|
||||
|
||||
if (callbacks.onSuccess) {
|
||||
callbacks.onSuccess(entity);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
return {
|
||||
response,
|
||||
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);
|
||||
// Get the entity before deleting, so we can reverse the action if the API request fails.
|
||||
const entity = getState().entities[entityType]?.store[entityId];
|
||||
// Optimistically delete the entity from the _store_ but keep the lists in tact.
|
||||
dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => {
|
||||
if (callbacks.onSuccess) {
|
||||
callbacks.onSuccess();
|
||||
}
|
||||
|
||||
// Success - finish deleting entity from the state.
|
||||
dispatch(deleteEntities([entityId], entityType));
|
||||
|
||||
|
@ -75,12 +96,15 @@ function useEntityActions<TEntity extends Entity = Entity, P = any>(
|
|||
dispatch(importEntities([entity], entityType));
|
||||
}
|
||||
throw e;
|
||||
}).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
createEntity: createEntity,
|
||||
deleteEntity: endpoints.delete ? deleteEntity : undefined,
|
||||
createEntity,
|
||||
deleteEntity,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -110,7 +110,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
);
|
||||
|
||||
if (token && tokenStart) {
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
|
||||
setSuggestions({
|
||||
list: results,
|
||||
token,
|
||||
|
|
|
@ -44,7 +44,7 @@ function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
|
|||
referenceElement={referenceElement}
|
||||
onReact={handleSelect}
|
||||
onClose={() => setIsOpen(false)}
|
||||
offset={[-10, 12]}
|
||||
offsetOptions={{ mainAxis: 12, crossAxis: -10 }}
|
||||
all={false}
|
||||
/>
|
||||
</Portal>
|
||||
|
|
|
@ -14,13 +14,13 @@ interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
|||
}
|
||||
|
||||
/** Custom textarea for chats. */
|
||||
const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||
const ChatTextarea: React.FC<IChatTextarea> = React.forwardRef(({
|
||||
attachments,
|
||||
onDeleteAttachment,
|
||||
uploadCount = 0,
|
||||
uploadProgress = 0,
|
||||
...rest
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const isUploading = uploadCount > 0;
|
||||
|
||||
const handleDeleteAttachment = (i: number) => {
|
||||
|
@ -64,9 +64,9 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
|
|||
</HStack>
|
||||
)}
|
||||
|
||||
<Textarea theme='transparent' {...rest} />
|
||||
<Textarea ref={ref} theme='transparent' {...rest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default ChatTextarea;
|
||||
|
|
|
@ -11,7 +11,6 @@ import { RootState } from 'soapbox/store';
|
|||
import { buildCustomEmojis } from '../../emoji';
|
||||
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';
|
||||
|
||||
let EmojiPicker: any; // load asynchronously
|
||||
|
@ -49,7 +48,7 @@ export interface IEmojiPickerDropdown {
|
|||
withCustom?: boolean
|
||||
visible: boolean
|
||||
setVisible: (value: boolean) => void
|
||||
update: (() => Promise<Partial<PopperState>>) | null
|
||||
update: (() => any) | null
|
||||
}
|
||||
|
||||
const perLine = 8;
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
import { useFloating, shift } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { KeyboardEvent, useEffect, useState } from 'react';
|
||||
import React, { KeyboardEvent, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
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';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
export const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
});
|
||||
|
@ -22,51 +19,28 @@ const EmojiPickerDropdownContainer = (
|
|||
const intl = useIntl();
|
||||
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 placement = props.condensed ? 'bottom-start' : 'top-start';
|
||||
const { styles, attributes, update } = usePopper(popperReference, popperElement, {
|
||||
placement: isMobile(window.innerWidth) ? 'auto' : placement,
|
||||
const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({
|
||||
middleware: [shift()],
|
||||
});
|
||||
|
||||
const handleDocClick = (e: any) => {
|
||||
if (!containerElement?.contains(e.target) && !popperElement?.contains(e.target)) {
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
useClickOutside(refs, () => {
|
||||
setVisible(false);
|
||||
});
|
||||
|
||||
const handleToggle = (e: MouseEvent | KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
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 (
|
||||
<div className='relative' ref={setContainerElement}>
|
||||
<div className='relative'>
|
||||
<IconButton
|
||||
className={clsx({
|
||||
'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')}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
|
@ -80,11 +54,20 @@ const EmojiPickerDropdownContainer = (
|
|||
{createPortal(
|
||||
<div
|
||||
className='z-[101]'
|
||||
ref={setPopperElement}
|
||||
style={style}
|
||||
{...attributes.popper}
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
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>,
|
||||
document.body,
|
||||
)}
|
||||
|
|
|
@ -28,7 +28,7 @@ export interface CustomEmoji {
|
|||
export interface NativeEmoji {
|
||||
id: string
|
||||
colons: string
|
||||
custom?: boolean
|
||||
custom?: false
|
||||
unified: string
|
||||
native: string
|
||||
}
|
||||
|
|
|
@ -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 type { Emoji } from './index';
|
||||
// import type { Emoji as EmojiMart, CustomEmoji } from 'emoji-mart';
|
||||
|
||||
// @ts-ignore
|
||||
const index = new Index({
|
||||
tokenize: 'full',
|
||||
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'
|
||||
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)
|
||||
.flatMap((id: string) => {
|
||||
if (id[0] === 'c') {
|
||||
const { shortcode, static_url } = custom_emojis.get((id as string).slice(1)).toJS();
|
||||
.flatMap((id) => {
|
||||
if (typeof id !== 'string') return;
|
||||
|
||||
return {
|
||||
id: shortcode,
|
||||
colons: ':' + shortcode + ':',
|
||||
custom: true,
|
||||
imageUrl: static_url,
|
||||
};
|
||||
if (id[0] === 'c' && custom_emojis) {
|
||||
const index = Number(id.slice(1));
|
||||
const custom = custom_emojis.get(index);
|
||||
|
||||
if (custom) {
|
||||
return {
|
||||
id: custom.get('shortcode', ''),
|
||||
colons: ':' + custom.get('shortcode', '') + ':',
|
||||
custom: true,
|
||||
imageUrl: custom.get('static_url', ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { skins } = data.emojis[(id as string).slice(1)];
|
||||
const skins = data.emojis[id.slice(1)]?.skins;
|
||||
|
||||
return {
|
||||
id: (id as string).slice(1),
|
||||
colons: ':' + id.slice(1) + ':',
|
||||
unified: skins[0].unified,
|
||||
native: skins[0].native,
|
||||
};
|
||||
});
|
||||
if (skins) {
|
||||
return {
|
||||
id: id.slice(1),
|
||||
colons: ':' + id.slice(1) + ':',
|
||||
unified: skins[0].unified,
|
||||
native: skins[0].native,
|
||||
};
|
||||
}
|
||||
}).filter(Boolean) as Emoji[];
|
||||
};
|
||||
|
||||
export default search;
|
||||
|
|
|
@ -98,7 +98,7 @@ describe('<GroupActionButton />', () => {
|
|||
relationship: buildGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: 'admin',
|
||||
role: 'owner',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,49 +3,84 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
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 { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/queries/groups';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/hooks/api';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import type { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupActionButton {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
|
||||
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
|
||||
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
|
||||
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
|
||||
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 dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const joinGroup = useJoinGroup();
|
||||
const leaveGroup = useLeaveGroup();
|
||||
const cancelRequest = useCancelMembershipRequest();
|
||||
const joinGroup = useJoinGroup(group);
|
||||
const leaveGroup = useLeaveGroup(group);
|
||||
const cancelRequest = useCancelMembershipRequest(group);
|
||||
|
||||
const isRequested = group.relationship?.requested;
|
||||
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 onJoinGroup = () => joinGroup.mutate(group);
|
||||
const onJoinGroup = () => joinGroup.mutate({}, {
|
||||
onSuccess() {
|
||||
toast.success(
|
||||
group.locked
|
||||
? intl.formatMessage(messages.joinRequestSuccess)
|
||||
: intl.formatMessage(messages.joinSuccess),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
message: intl.formatMessage(messages.confirmationMessage),
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
to={`/groups/${group.id}/manage`}
|
||||
>
|
||||
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNonMember) {
|
||||
return (
|
||||
<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 (
|
||||
<Button
|
||||
theme='secondary'
|
||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|||
import React, { useMemo } from 'react';
|
||||
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 Account from 'soapbox/components/account';
|
||||
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 { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
import { useBlockGroupMember } from 'soapbox/hooks/api/groups/useBlockGroupMember';
|
||||
import { BaseGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||
import { useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/hooks/api';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
|
||||
import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types/entities';
|
||||
|
||||
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' },
|
||||
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' },
|
||||
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||
blocked: { id: 'group.group_mod_block.success', defaultMessage: '@{name} is banned' },
|
||||
demotedToUser: { id: 'group.demote.user.success', defaultMessage: '@{name} is now a member' },
|
||||
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' },
|
||||
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
||||
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Assign {role} role' },
|
||||
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?' },
|
||||
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
||||
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
||||
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
||||
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
||||
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
||||
promoteConfirm: { id: 'group.promote.admin.confirmation.title', defaultMessage: 'Assign Admin Role' },
|
||||
promoteConfirmMessage: { id: 'group.promote.admin.confirmation.message', defaultMessage: 'Are you sure you want to assign the admin role to @{name}?' },
|
||||
promotedToAdmin: { id: 'group.promote.admin.success', defaultMessage: '@{name} is now an admin' },
|
||||
});
|
||||
|
||||
interface IGroupMemberListItem {
|
||||
|
@ -49,19 +47,20 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
|||
const features = useFeatures();
|
||||
const intl = useIntl();
|
||||
|
||||
const { normalizeRole } = useGroupRoles();
|
||||
const blockGroupMember = useBlockGroupMember(group, member);
|
||||
const promoteGroupMember = usePromoteGroupMember(group, member);
|
||||
const demoteGroupMember = useDemoteGroupMember(group, member);
|
||||
|
||||
const account = useAccount(member.account.id) as AccountEntity;
|
||||
|
||||
// Current user role
|
||||
const isCurrentUserAdmin = normalizeRole(group.relationship?.role as any) === BaseGroupRoles.ADMIN;
|
||||
const isCurrentUserModerator = normalizeRole(group.relationship?.role as any) === BaseGroupRoles.MODERATOR;
|
||||
const isCurrentUserOwner = group.relationship?.role === GroupRoles.OWNER;
|
||||
const isCurrentUserAdmin = group.relationship?.role === GroupRoles.ADMIN;
|
||||
|
||||
// Member role
|
||||
const isMemberAdmin = normalizeRole(member.role as any) === BaseGroupRoles.ADMIN;
|
||||
const isMemberModerator = normalizeRole(member.role as any) === BaseGroupRoles.MODERATOR;
|
||||
const isMemberUser = normalizeRole(member.role as any) === BaseGroupRoles.USER;
|
||||
const isMemberOwner = member.role === GroupRoles.OWNER;
|
||||
const isMemberAdmin = member.role === GroupRoles.ADMIN;
|
||||
const isMemberUser = member.role === GroupRoles.USER;
|
||||
|
||||
const handleKickFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
|
@ -78,39 +77,41 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
|||
heading: intl.formatMessage(messages.blockFromGroupHeading),
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => blockGroupMember({ account_ids: [member.account.id] }).then(() => {
|
||||
dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS));
|
||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct }));
|
||||
}),
|
||||
onConfirm: () => {
|
||||
blockGroupMember({ account_ids: [member.account.id] }, {
|
||||
onSuccess() {
|
||||
dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS));
|
||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct }));
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
||||
if (warning) {
|
||||
return dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||
onConfirm: () => dispatch(groupPromoteAccount(group.id, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
return dispatch(groupPromoteAccount(group.id, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
);
|
||||
}
|
||||
const handleAdminAssignment = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.promoteConfirm),
|
||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||
confirmationTheme: 'primary',
|
||||
onConfirm: () => {
|
||||
promoteGroupMember({ role: GroupRoles.ADMIN, account_ids: [account.id] }, {
|
||||
onSuccess() {
|
||||
toast.success(
|
||||
intl.formatMessage(messages.promotedToAdmin, { name: account.acct }),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePromoteToGroupAdmin = () => onPromote('admin', true);
|
||||
|
||||
const handlePromoteToGroupMod = () => {
|
||||
onPromote('moderator', group.relationship!.role === 'moderator');
|
||||
};
|
||||
|
||||
const handleDemote = () => {
|
||||
dispatch(groupDemoteAccount(group.id, account.id, 'user')).then(() =>
|
||||
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
||||
).catch(() => {});
|
||||
const handleUserAssignment = () => {
|
||||
demoteGroupMember({ role: GroupRoles.USER, account_ids: [account.id] }, {
|
||||
onSuccess() {
|
||||
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct }));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const menu: IMenu = useMemo(() => {
|
||||
|
@ -120,9 +121,26 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
|||
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 (
|
||||
(isCurrentUserAdmin || isCurrentUserModerator) &&
|
||||
(isMemberModerator || isMemberUser) &&
|
||||
(isCurrentUserOwner || isCurrentUserAdmin) &&
|
||||
(isMemberAdmin || isMemberUser) &&
|
||||
member.role !== group.relationship.role
|
||||
) {
|
||||
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;
|
||||
}, [group, account]);
|
||||
|
||||
|
@ -174,12 +169,12 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
|||
</div>
|
||||
|
||||
<HStack alignItems='center' space={2}>
|
||||
{(isMemberAdmin || isMemberModerator) ? (
|
||||
{(isMemberOwner || isMemberAdmin) ? (
|
||||
<span
|
||||
className={
|
||||
clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', {
|
||||
'bg-primary-200 text-primary-500': isMemberAdmin,
|
||||
'bg-gray-200 text-gray-900': isMemberModerator,
|
||||
'bg-primary-200 text-primary-500': isMemberOwner,
|
||||
'bg-gray-200 text-gray-900': isMemberAdmin,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
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 ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||
import { useAppDispatch, useAppSelector, useGroup } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
@ -62,14 +62,12 @@ const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
|
|||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
const id = params?.id;
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const { group } = useGroup(id);
|
||||
const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
dispatch(fetchGroupBlocks(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 />);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ import React, { useMemo } from 'react';
|
|||
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
|
||||
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||
import { useGroup } from 'soapbox/queries/groups';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||
|
||||
|
@ -16,22 +16,20 @@ interface IGroupMembers {
|
|||
}
|
||||
|
||||
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||
const { roles: { admin, moderator, user } } = useGroupRoles();
|
||||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const { group, isFetching: isFetchingGroup } = useGroup(groupId);
|
||||
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, admin);
|
||||
const { groupMembers: moderators, isFetching: isFetchingModerators } = useGroupMembers(groupId, moderator);
|
||||
const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, user);
|
||||
const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER);
|
||||
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN);
|
||||
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(() => [
|
||||
...owners,
|
||||
...admins,
|
||||
...moderators,
|
||||
...users,
|
||||
], [admins, moderators, users]);
|
||||
], [owners, admins, users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
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 ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||
import { useAppDispatch, useAppSelector, useGroup } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
@ -77,14 +77,12 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
|
|||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = params?.id || '';
|
||||
const id = params?.id;
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const { group } = useGroup(id);
|
||||
const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
dispatch(fetchGroupMembershipRequests(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 />);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
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 List, { ListItem } from 'soapbox/components/list';
|
||||
import { CardBody, Column, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useGroup, useGroupsPath } from 'soapbox/hooks';
|
||||
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';
|
||||
|
||||
|
@ -17,11 +19,14 @@ const messages = defineMessages({
|
|||
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
|
||||
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' },
|
||||
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' },
|
||||
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Blocked members' },
|
||||
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Banned members' },
|
||||
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' },
|
||||
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' },
|
||||
deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' },
|
||||
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 {
|
||||
|
@ -29,18 +34,17 @@ interface IManageGroup {
|
|||
}
|
||||
|
||||
const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
||||
const history = useHistory();
|
||||
const { id } = params;
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const groupsPath = useGroupsPath();
|
||||
|
||||
const id = params?.id || '';
|
||||
const { group } = useGroup(id);
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const deleteGroup = useDeleteGroup();
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) dispatch(fetchGroup(id));
|
||||
}, [id]);
|
||||
const isOwner = group?.relationship?.role === GroupRoles.OWNER;
|
||||
|
||||
if (!group || !group.relationship) {
|
||||
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 />);
|
||||
}
|
||||
|
||||
|
@ -63,7 +67,14 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
|||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
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`);
|
||||
|
@ -72,21 +83,39 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
|||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}>
|
||||
<CardBody className='space-y-4'>
|
||||
{group.relationship.role === 'admin' && (
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}>
|
||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
{isOwner && (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.editGroup)} />
|
||||
</CardHeader>
|
||||
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}>
|
||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.members)} />
|
||||
</CardHeader>
|
||||
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} />
|
||||
<ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} />
|
||||
</List>
|
||||
{group.relationship.role === 'admin' && (
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.deleteGroup)} onClick={onDeleteGroup} />
|
||||
</List>
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.other)} />
|
||||
</CardHeader>
|
||||
|
||||
<List>
|
||||
<ListItem label={<Text theme='danger'>{intl.formatMessage(messages.deleteGroup)}</Text>} onClick={onDeleteGroup} />
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Column>
|
||||
|
|
|
@ -6,7 +6,7 @@ import GroupAvatar from 'soapbox/components/groups/group-avatar';
|
|||
import { Button, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||
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';
|
||||
|
||||
interface IGroup {
|
||||
|
@ -17,7 +17,7 @@ interface IGroup {
|
|||
const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||
const { group, width = 'auto' } = props;
|
||||
|
||||
const joinGroup = useJoinGroup();
|
||||
const joinGroup = useJoinGroup(group);
|
||||
|
||||
const onJoinGroup = () => joinGroup.mutate(group);
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import GroupAvatar from 'soapbox/components/groups/group-avatar';
|
||||
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 { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
|
@ -16,7 +16,7 @@ interface IGroup {
|
|||
const GroupListItem = (props: IGroup) => {
|
||||
const { group, withJoinAction = true } = props;
|
||||
|
||||
const joinGroup = useJoinGroup();
|
||||
const joinGroup = useJoinGroup(group);
|
||||
|
||||
const onJoinGroup = () => joinGroup.mutate(group);
|
||||
|
||||
|
|
|
@ -269,6 +269,7 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
return (
|
||||
<Emoji
|
||||
emoji={notification.emoji}
|
||||
src={notification.emoji_url || undefined}
|
||||
className='h-4 w-4 flex-none'
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
|
|||
|
||||
import { fetchMfa } from 'soapbox/actions/mfa';
|
||||
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 Preferences from '../preferences';
|
||||
|
@ -155,7 +155,7 @@ const Settings = () => {
|
|||
))}
|
||||
|
||||
{features.security && (
|
||||
<ListItem label={intl.formatMessage(messages.deleteAccount)} onClick={navigateToDeleteAccount} />
|
||||
<ListItem label={<Text theme='danger'>{intl.formatMessage(messages.deleteAccount)}</Text>} onClick={navigateToDeleteAccount} />
|
||||
)}
|
||||
</List>
|
||||
</CardBody>
|
||||
|
|
|
@ -154,6 +154,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
|
|||
key={i}
|
||||
className='h-4.5 w-4.5 flex-none'
|
||||
emoji={e.get('name')}
|
||||
src={e.get('url')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -114,7 +114,11 @@ export const getDescendantsIds = createSelector([
|
|||
});
|
||||
|
||||
type DisplayMedia = 'default' | 'hide_all' | 'show_all';
|
||||
type RouteParams = { statusId: string };
|
||||
|
||||
type RouteParams = {
|
||||
statusId: string
|
||||
groupId?: string
|
||||
};
|
||||
|
||||
interface IThread {
|
||||
params: RouteParams
|
||||
|
@ -515,6 +519,10 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
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 (
|
||||
<Column label={intl.formatMessage(titleMessage, { username })} transparent>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
|
|
|
@ -18,11 +18,6 @@ const PrivacyStep = () => {
|
|||
return (
|
||||
<>
|
||||
<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'>
|
||||
<FormattedMessage id='manage_group.get_started' defaultMessage='Let’s get started!' />
|
||||
</Text>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx';
|
||||
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 { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions';
|
||||
|
@ -17,6 +17,12 @@ const messages = defineMessages({
|
|||
all: { id: 'reactions.all', defaultMessage: 'All' },
|
||||
});
|
||||
|
||||
interface IAccountWithReaction {
|
||||
id: string
|
||||
reaction: string
|
||||
reactionUrl?: string
|
||||
}
|
||||
|
||||
interface IReactionsModal {
|
||||
onClose: (string: string) => void
|
||||
statusId: string
|
||||
|
@ -54,7 +60,7 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
|
|||
reactions!.forEach(reaction => items.push(
|
||||
{
|
||||
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}
|
||||
</div>,
|
||||
action: () => setReaction(reaction.name),
|
||||
|
@ -65,17 +71,25 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
|
|||
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(() => {
|
||||
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;
|
||||
|
||||
if (!accounts) {
|
||||
if (!accounts || !reactions) {
|
||||
body = <Spinner />;
|
||||
} 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.' />;
|
||||
|
@ -91,7 +105,7 @@ const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, reaction
|
|||
itemClassName='pb-3'
|
||||
>
|
||||
{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>
|
||||
</>);
|
||||
|
|
|
@ -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;
|
|
@ -4,7 +4,7 @@ import React from 'react';
|
|||
import Account from 'soapbox/components/account';
|
||||
import StatusContent from 'soapbox/components/status-content';
|
||||
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 PlaceholderMediaGallery from 'soapbox/features/placeholder/components/placeholder-media-gallery';
|
||||
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
|
||||
|
@ -78,16 +78,18 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
|
|||
<div className='status__content-wrapper'>
|
||||
<StatusReplyMentions status={status} />
|
||||
|
||||
<StatusContent
|
||||
status={status}
|
||||
collapsable
|
||||
/>
|
||||
<Stack space={4}>
|
||||
<StatusContent
|
||||
status={status}
|
||||
collapsable
|
||||
/>
|
||||
|
||||
<PendingStatusMedia status={status} />
|
||||
<PendingStatusMedia status={status} />
|
||||
|
||||
{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>
|
||||
|
||||
{/* TODO */}
|
||||
|
|
|
@ -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/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/:groupId/posts/:statusId' exact page={StatusPage} component={Status} content={children} />}
|
||||
|
||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
||||
|
|
|
@ -590,6 +590,10 @@ export function NewGroupPanel() {
|
|||
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() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel');
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useEntityActions } from 'soapbox/entity-store/hooks';
|
|||
import type { Group, GroupMember } from 'soapbox/schemas';
|
||||
|
||||
function useBlockGroupMember(group: Group, groupMember: GroupMember) {
|
||||
const { createEntity } = useEntityActions(
|
||||
const { createEntity } = useEntityActions<GroupMember>(
|
||||
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
|
||||
{ post: `/api/v1/groups/${group.id}/blocks` },
|
||||
);
|
||||
|
|
21
app/soapbox/hooks/api/groups/useCancelMembershipRequest.ts
Normal file
21
app/soapbox/hooks/api/groups/useCancelMembershipRequest.ts
Normal 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 };
|
18
app/soapbox/hooks/api/groups/useDeleteGroup.ts
Normal file
18
app/soapbox/hooks/api/groups/useDeleteGroup.ts
Normal 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 };
|
19
app/soapbox/hooks/api/groups/useDemoteGroupMember.ts
Normal file
19
app/soapbox/hooks/api/groups/useDemoteGroupMember.ts
Normal 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 };
|
20
app/soapbox/hooks/api/groups/useJoinGroup.ts
Normal file
20
app/soapbox/hooks/api/groups/useJoinGroup.ts
Normal 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 };
|
18
app/soapbox/hooks/api/groups/useLeaveGroup.ts
Normal file
18
app/soapbox/hooks/api/groups/useLeaveGroup.ts
Normal 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 };
|
19
app/soapbox/hooks/api/groups/usePromoteGroupMember.ts
Normal file
19
app/soapbox/hooks/api/groups/usePromoteGroupMember.ts
Normal 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 };
|
10
app/soapbox/hooks/api/index.ts
Normal file
10
app/soapbox/hooks/api/index.ts
Normal 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';
|
|
@ -9,7 +9,7 @@ function usePopularGroups() {
|
|||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.POPULAR_GROUPS, ''],
|
||||
[Entities.GROUPS, 'popular'],
|
||||
'/api/mock/groups', // '/api/v1/truth/trends/groups'
|
||||
{
|
||||
schema: groupSchema,
|
||||
|
|
|
@ -9,7 +9,7 @@ function useSuggestedGroups() {
|
|||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.SUGGESTED_GROUPS, ''],
|
||||
[Entities.GROUPS, 'suggested'],
|
||||
'/api/mock/groups', // '/api/v1/truth/suggestions/groups'
|
||||
{
|
||||
schema: groupSchema,
|
||||
|
|
|
@ -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 };
|
|
@ -5,11 +5,15 @@ import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
|||
import { groupSchema, Group } from 'soapbox/schemas/group';
|
||||
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
|
||||
|
||||
import { useFeatures } from './useFeatures';
|
||||
|
||||
function useGroups() {
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, ''],
|
||||
'/api/v1/groups',
|
||||
{ schema: groupSchema },
|
||||
{ enabled: features.groups, schema: groupSchema },
|
||||
);
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
|
|
|
@ -468,8 +468,8 @@
|
|||
"confirmations.block.confirm": "Block",
|
||||
"confirmations.block.heading": "Block @{name}",
|
||||
"confirmations.block.message": "Are you sure you want to block {name}?",
|
||||
"confirmations.block_from_group.confirm": "Block",
|
||||
"confirmations.block_from_group.heading": "Block group member",
|
||||
"confirmations.block_from_group.confirm": "Ban User",
|
||||
"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.cancel.confirm": "Discard",
|
||||
"confirmations.cancel.heading": "Discard post",
|
||||
|
@ -507,8 +507,6 @@
|
|||
"confirmations.mute.confirm": "Mute",
|
||||
"confirmations.mute.heading": "Mute @{name}",
|
||||
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
||||
"confirmations.promote_in_group.confirm": "Promote",
|
||||
"confirmations.promote_in_group.message": "Are you sure you want to promote @{name}? You will not be able to demote them.",
|
||||
"confirmations.redraft.confirm": "Delete & redraft",
|
||||
"confirmations.redraft.heading": "Delete & redraft",
|
||||
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.",
|
||||
|
@ -766,18 +764,16 @@
|
|||
"gdpr.title": "{siteTitle} uses cookies",
|
||||
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
|
||||
"group.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.success": "Accepted @{name} to group",
|
||||
"group.group_mod_block": "Ban from group",
|
||||
"group.group_mod_block.success": "You have successfully blocked @{name} from the group",
|
||||
"group.group_mod_demote": "Demote @{name}",
|
||||
"group.group_mod_demote.success": "Demoted @{name} to group user",
|
||||
"group.group_mod_block.success": "@{name} is banned",
|
||||
"group.group_mod_demote": "Remove {role} role",
|
||||
"group.group_mod_kick": "Kick @{name} from group",
|
||||
"group.group_mod_kick.success": "Kicked @{name} from group",
|
||||
"group.group_mod_promote_admin": "Promote @{name} to group administrator",
|
||||
"group.group_mod_promote_admin.success": "Promoted @{name} to group administrator",
|
||||
"group.group_mod_promote_mod": "Promote @{name} to group moderator",
|
||||
"group.group_mod_promote_mod.success": "Promoted @{name} to group moderator",
|
||||
"group.group_mod_promote_mod": "Assign {role} role",
|
||||
"group.group_mod_reject": "Reject",
|
||||
"group.group_mod_reject.success": "Rejected @{name} from group",
|
||||
"group.group_mod_unblock": "Unblock",
|
||||
|
@ -796,6 +792,9 @@
|
|||
"group.privacy.public": "Public",
|
||||
"group.privacy.public.full": "Public Group",
|
||||
"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.moderator": "Moderator",
|
||||
"group.tabs.all": "All",
|
||||
|
@ -927,7 +926,7 @@
|
|||
"login_external.errors.instance_fail": "The instance returned an error.",
|
||||
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
|
||||
"login_form.header": "Sign In",
|
||||
"manage_group.blocked_members": "Blocked members",
|
||||
"manage_group.blocked_members": "Banned members",
|
||||
"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_2": "Post the group's first post and get the conversation started.",
|
||||
|
|
|
@ -17,6 +17,7 @@ export const NotificationRecord = ImmutableRecord({
|
|||
chat_message: null as ImmutableMap<string, any> | string | null, // pleroma:chat_mention
|
||||
created_at: new Date(),
|
||||
emoji: null as string | null, // pleroma:emoji_reaction
|
||||
emoji_url: null as string | null, // pleroma:emoji_reaction
|
||||
id: '',
|
||||
status: null as EmbeddedEntity<Status>,
|
||||
target: null as EmbeddedEntity<Account>, // move
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
CtaBanner,
|
||||
GroupMediaPanel,
|
||||
SignUpPanel,
|
||||
SuggestedGroupsPanel,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { useGroup, useOwnAccount } from 'soapbox/hooks';
|
||||
import { Group } from 'soapbox/schemas';
|
||||
|
@ -127,6 +128,9 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
|||
<BundleContainer fetchComponent={GroupMediaPanel}>
|
||||
{Component => <Component group={group} />}
|
||||
</BundleContainer>
|
||||
<BundleContainer fetchComponent={SuggestedGroupsPanel}>
|
||||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</>
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { Column, Layout } from 'soapbox/components/ui';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import { NewGroupPanel } from 'soapbox/features/ui/util/async-components';
|
||||
import { MyGroupsPanel, NewGroupPanel, SuggestedGroupsPanel } from 'soapbox/features/ui/util/async-components';
|
||||
|
||||
interface IGroupsPage {
|
||||
children: React.ReactNode
|
||||
|
@ -22,10 +23,28 @@ const GroupsPage: React.FC<IGroupsPage> = ({ children }) => (
|
|||
|
||||
<Layout.Aside>
|
||||
<BundleContainer fetchComponent={NewGroupPanel}>
|
||||
{Component => <Component key='new-group-panel' />}
|
||||
{Component => <Component />}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,22 +1,12 @@
|
|||
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { getNextLink } from 'soapbox/api';
|
||||
import { useApi, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||
import toast from 'soapbox/toast';
|
||||
import { Group, GroupRelationship } from 'soapbox/types/entities';
|
||||
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 = {
|
||||
group: (id: string) => ['groups', 'group', id] 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 {
|
||||
useCancelMembershipRequest,
|
||||
useGroup,
|
||||
useGroups,
|
||||
useJoinGroup,
|
||||
useLeaveGroup,
|
||||
usePendingGroups,
|
||||
};
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
const GroupMemberKeys = {
|
||||
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 getQuery = async () => {
|
||||
|
|
|
@ -242,7 +242,7 @@ export default function statuses(state = initialState, action: AnyAction): State
|
|||
return state
|
||||
.updateIn(
|
||||
[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:
|
||||
return state
|
||||
|
|
|
@ -82,6 +82,7 @@ export const ReactionRecord = ImmutableRecord({
|
|||
accounts: ImmutableOrderedSet<string>(),
|
||||
count: 0,
|
||||
name: '',
|
||||
url: null as string | null,
|
||||
});
|
||||
|
||||
const ReactionListRecord = ImmutableRecord({
|
||||
|
|
|
@ -2,27 +2,18 @@ import z from 'zod';
|
|||
|
||||
import { accountSchema } from './account';
|
||||
|
||||
enum TruthSocialGroupRoles {
|
||||
ADMIN = 'owner',
|
||||
MODERATOR = 'admin',
|
||||
USER = 'user'
|
||||
}
|
||||
|
||||
enum BaseGroupRoles {
|
||||
enum GroupRoles {
|
||||
OWNER = 'owner',
|
||||
ADMIN = 'admin',
|
||||
MODERATOR = 'moderator',
|
||||
USER = 'user'
|
||||
}
|
||||
|
||||
const groupMemberSchema = z.object({
|
||||
id: z.string(),
|
||||
account: accountSchema,
|
||||
role: z.union([
|
||||
z.nativeEnum(TruthSocialGroupRoles),
|
||||
z.nativeEnum(BaseGroupRoles),
|
||||
]),
|
||||
role: z.nativeEnum(GroupRoles),
|
||||
});
|
||||
|
||||
type GroupMember = z.infer<typeof groupMemberSchema>;
|
||||
|
||||
export { groupMemberSchema, GroupMember };
|
||||
export { groupMemberSchema, GroupMember, GroupRoles };
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { createSelector } from 'reselect';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { getDomain } from 'soapbox/utils/accounts';
|
||||
import { validId } from 'soapbox/utils/auth';
|
||||
import ConfigDB from 'soapbox/utils/config-db';
|
||||
|
@ -14,9 +15,10 @@ import { getFeatures } from 'soapbox/utils/features';
|
|||
import { shouldFilter } from 'soapbox/utils/timelines';
|
||||
|
||||
import type { ContextType } from 'soapbox/normalizers/filter';
|
||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||
import type { ReducerChat } from 'soapbox/reducers/chats';
|
||||
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 : '';
|
||||
|
||||
|
@ -180,11 +182,11 @@ type APIStatus = { id: string, username?: string };
|
|||
export const makeGetStatus = () => {
|
||||
return createSelector(
|
||||
[
|
||||
(state: RootState, { id }: APIStatus) => state.statuses.get(id),
|
||||
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.groups.items.get(state.statuses.get(id)?.group || ''),
|
||||
(state: RootState, { id }: APIStatus) => state.statuses.get(id) as Status | undefined,
|
||||
(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 || '') as ReducerAccount | undefined,
|
||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || '') as ReducerAccount | undefined,
|
||||
(state: RootState, { id }: APIStatus) => state.entities[Entities.GROUPS]?.store[state.statuses.get(id)?.group || ''] as Group | undefined,
|
||||
(_state: RootState, { username }: APIStatus) => username,
|
||||
getFilters,
|
||||
(state: RootState) => state.me,
|
||||
|
@ -207,7 +209,7 @@ export const makeGetStatus = () => {
|
|||
statusReblog = undefined;
|
||||
}
|
||||
|
||||
return statusBase.withMutations(map => {
|
||||
return statusBase.withMutations((map: Status) => {
|
||||
map.set('reblog', statusReblog || null);
|
||||
// @ts-ignore :(
|
||||
map.set('account', accountBase || null);
|
||||
|
|
|
@ -52,15 +52,15 @@ describe('mergeEmojiFavourites', () => {
|
|||
|
||||
describe('with existing 👍 reacts', () => {
|
||||
const emojiReacts = fromJS([
|
||||
{ 'count': 20, 'me': false, 'name': '👍' },
|
||||
{ 'count': 15, 'me': false, 'name': '❤' },
|
||||
{ 'count': 7, 'me': false, 'name': '😯' },
|
||||
{ 'count': 20, 'me': false, 'name': '👍', 'url': undefined },
|
||||
{ 'count': 15, 'me': false, 'name': '❤', 'url': undefined },
|
||||
{ 'count': 7, 'me': false, 'name': '😯', 'url': undefined },
|
||||
]) as ImmutableList<ImmutableMap<string, any>>;
|
||||
it('combines 👍 reacts with favourites', () => {
|
||||
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
|
||||
{ 'count': 32, 'me': true, 'name': '👍' },
|
||||
{ 'count': 15, 'me': false, 'name': '❤' },
|
||||
{ 'count': 7, 'me': false, 'name': '😯' },
|
||||
{ 'count': 32, 'me': true, 'name': '👍', 'url': undefined },
|
||||
{ 'count': 15, 'me': false, 'name': '❤', 'url': undefined },
|
||||
{ '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', () => {
|
||||
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)', () => {
|
||||
|
@ -173,24 +173,36 @@ describe('getReactForStatus', () => {
|
|||
describe('simulateEmojiReact', () => {
|
||||
it('adds the emoji to the list', () => {
|
||||
const emojiReacts = fromJS([
|
||||
{ 'count': 2, 'me': false, 'name': '👍' },
|
||||
{ 'count': 2, 'me': false, 'name': '❤' },
|
||||
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
|
||||
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
|
||||
]) as ImmutableList<ImmutableMap<string, any>>;
|
||||
expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([
|
||||
{ 'count': 2, 'me': false, 'name': '👍' },
|
||||
{ 'count': 3, 'me': true, 'name': '❤' },
|
||||
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
|
||||
{ 'count': 3, 'me': true, 'name': '❤', 'url': undefined },
|
||||
]));
|
||||
});
|
||||
|
||||
it('creates the emoji if it didn\'t already exist', () => {
|
||||
const emojiReacts = fromJS([
|
||||
{ 'count': 2, 'me': false, 'name': '👍' },
|
||||
{ 'count': 2, 'me': false, 'name': '❤' },
|
||||
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
|
||||
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
|
||||
]) as ImmutableList<ImmutableMap<string, any>>;
|
||||
expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([
|
||||
{ 'count': 2, 'me': false, 'name': '👍' },
|
||||
{ 'count': 2, 'me': false, 'name': '❤' },
|
||||
{ 'count': 1, 'me': true, 'name': '😯' },
|
||||
{ 'count': 2, 'me': false, 'name': '👍', 'url': undefined },
|
||||
{ 'count': 2, 'me': false, 'name': '❤', 'url': undefined },
|
||||
{ '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': '❤' },
|
||||
]));
|
||||
});
|
||||
|
||||
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': '❤' },
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -74,19 +74,19 @@ export const reduceEmoji = (emojiReacts: ImmutableList<EmojiReact>, favouritesCo
|
|||
allowedEmoji,
|
||||
));
|
||||
|
||||
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): string | undefined => {
|
||||
export const getReactForStatus = (status: any, allowedEmoji = ALLOWED_EMOJI): EmojiReact | undefined => {
|
||||
const result = reduceEmoji(
|
||||
status.pleroma.get('emoji_reactions', ImmutableList()),
|
||||
status.favourites_count || 0,
|
||||
status.favourited,
|
||||
allowedEmoji,
|
||||
).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 emojiReact = emojiReacts.get(idx);
|
||||
|
||||
|
@ -94,12 +94,14 @@ export const simulateEmojiReact = (emojiReacts: ImmutableList<EmojiReact>, emoji
|
|||
return emojiReacts.set(idx, emojiReact.merge({
|
||||
count: emojiReact.get('count') + 1,
|
||||
me: true,
|
||||
url,
|
||||
}));
|
||||
} else {
|
||||
return emojiReacts.push(ImmutableMap({
|
||||
count: 1,
|
||||
me: true,
|
||||
name: emoji,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
|
|
@ -324,6 +324,15 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
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.
|
||||
* @see GET /api/v1/timelines/direct
|
||||
|
@ -523,9 +532,14 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
|
||||
/**
|
||||
* Can query pending Group requests.
|
||||
*/
|
||||
*/
|
||||
groupsPending: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can promote members to Admins.
|
||||
*/
|
||||
groupsPromoteToAdmin: v.software !== TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can hide follows/followers lists and counts.
|
||||
* @see PATCH /api/v1/accounts/update_credentials
|
||||
|
|
|
@ -82,7 +82,6 @@
|
|||
"@tanstack/react-query": "^4.0.10",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/escape-html": "^1.0.1",
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/http-link-header": "^1.0.3",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/leaflet": "^1.8.0",
|
||||
|
@ -127,7 +126,7 @@
|
|||
"emoji-mart": "^5.5.2",
|
||||
"escape-html": "^1.0.3",
|
||||
"exif-js": "^2.3.0",
|
||||
"flexsearch": "^0.7.31",
|
||||
"flexsearch-ts": "^0.7.31",
|
||||
"fork-ts-checker-webpack-plugin": "^8.0.0",
|
||||
"graphemesplit": "^2.4.4",
|
||||
"html-webpack-harddisk-plugin": "^2.0.0",
|
||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -4288,11 +4288,6 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.29.tgz#a48795ecadf957f6c0d10e0c34af86c098fa5bee"
|
||||
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":
|
||||
version "9.0.13"
|
||||
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"
|
||||
integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
|
||||
|
||||
flexsearch@^0.7.31:
|
||||
flexsearch-ts@^0.7.31:
|
||||
version "0.7.31"
|
||||
resolved "https://registry.yarnpkg.com/flexsearch/-/flexsearch-0.7.31.tgz#065d4110b95083110b9b6c762a71a77cc52e4702"
|
||||
integrity sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA==
|
||||
resolved "https://registry.yarnpkg.com/flexsearch-ts/-/flexsearch-ts-0.7.31.tgz#0353f51789ad8e7660c3df157534dcf2d346a20f"
|
||||
integrity sha512-Z3geBbHiPw/JALe/thvxTd1LAgDcUNvQuHWGjhO4lG7gOR5IVVPsyS8tRt/qmme9HgXj3zdtHC4yJ3anGW1Xmw==
|
||||
|
||||
flush-write-stream@^1.0.0:
|
||||
version "1.1.1"
|
||||
|
|
Loading…
Reference in a new issue