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

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

View file

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

View file

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

Before

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -25,7 +25,7 @@ const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL';
const noOp = () => () => new Promise(f => f(undefined));
const 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,
});

View file

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

View file

@ -1,3 +1,8 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { Group, groupSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { getSettings } from '../settings';
import 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) =>

View file

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

View file

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

View file

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

View file

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

View file

@ -60,9 +60,9 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
}
};
const handleReact = (emoji: string): void => {
const handleReact = (emoji: string, custom?: string): void => {
if (ownAccount) {
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) {

View file

@ -1,13 +1,12 @@
import { Placement } from '@popperjs/core';
import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
import clsx from 'clsx';
import 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}
/>
) : (

View file

@ -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}
/>
);

View file

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

View file

@ -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,
};
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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)) {
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,
)}

View file

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

View file

@ -1,11 +1,10 @@
import { Index } from 'flexsearch';
import { Index } from 'flexsearch-ts';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import data from './data';
import 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;
if (id[0] === 'c' && custom_emojis) {
const index = Number(id.slice(1));
const custom = custom_emojis.get(index);
if (custom) {
return {
id: shortcode,
colons: ':' + shortcode + ':',
id: custom.get('shortcode', ''),
colons: ':' + custom.get('shortcode', '') + ':',
custom: true,
imageUrl: static_url,
imageUrl: custom.get('static_url', ''),
};
}
}
const { skins } = data.emojis[(id as string).slice(1)];
const skins = data.emojis[id.slice(1)]?.skins;
if (skins) {
return {
id: (id as string).slice(1),
id: id.slice(1),
colons: ':' + id.slice(1) + ':',
unified: skins[0].unified,
native: skins[0].native,
};
});
}
}).filter(Boolean) as Emoji[];
};
export default search;

View file

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

View file

@ -3,49 +3,84 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { 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'

View file

@ -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(() => {
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', {
const handleAdminAssignment = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.promoteConfirm),
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 })),
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,
})
}
>

View file

@ -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 />);
}

View file

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

View file

@ -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 />);
}

View file

@ -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' && (
{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' && (
{isOwner && (
<>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.other)} />
</CardHeader>
<List>
<ListItem label={intl.formatMessage(messages.deleteGroup)} onClick={onDeleteGroup} />
<ListItem label={<Text theme='danger'>{intl.formatMessage(messages.deleteGroup)}</Text>} onClick={onDeleteGroup} />
</List>
</>
)}
</CardBody>
</Column>

View file

@ -6,7 +6,7 @@ import GroupAvatar from 'soapbox/components/groups/group-avatar';
import { Button, HStack, Stack, Text } from 'soapbox/components/ui';
import 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);

View file

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

View file

@ -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'
/>
);

View file

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

View file

@ -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')}
/>
);
})}

View file

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

View file

@ -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='Lets get started!' />
</Text>

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import React from 'react';
import Account from 'soapbox/components/account';
import 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,6 +78,7 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
<div className='status__content-wrapper'>
<StatusReplyMentions status={status} />
<Stack space={4}>
<StatusContent
status={status}
collapsable
@ -88,6 +89,7 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
{status.poll && <PollPreview pollId={status.poll as string} />}
{status.quote && <QuotedStatus statusId={status.quote as string} />}
</Stack>
</div>
{/* TODO */}

View file

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

View file

@ -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');
}

View file

@ -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` },
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,11 +5,15 @@ import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
import { groupSchema, Group } from 'soapbox/schemas/group';
import { 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));

View file

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

View file

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

View file

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

View file

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

View file

@ -1,22 +1,12 @@
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { 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,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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': '❤' },
]));
});
});

View file

@ -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,
}));
}
};

View file

@ -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
@ -526,6 +535,11 @@ const getInstanceFeatures = (instance: Instance) => {
*/
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

View file

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

View file

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