kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge remote-tracking branch 'soapbox/develop' into filters-v2
commit
1d64f934d9
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Posts: Support posts filtering on recent Mastodon versions
|
||||
|
||||
### Changed
|
||||
- Posts: truncate Nostr pubkeys in reply mentions.
|
||||
|
||||
### Fixed
|
||||
- Posts: fixed emojis being cut off in reactions modal.
|
||||
|
|
|
@ -4,7 +4,8 @@ import throttle from 'lodash/throttle';
|
|||
import { defineMessages, IntlShape } from 'react-intl';
|
||||
|
||||
import api from 'soapbox/api';
|
||||
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
|
||||
import { isNativeEmoji } from 'soapbox/features/emoji';
|
||||
import emojiSearch from 'soapbox/features/emoji/search';
|
||||
import { tagHistory } from 'soapbox/settings';
|
||||
import toast from 'soapbox/toast';
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
|
@ -19,8 +20,8 @@ import { openModal, closeModal } from './modals';
|
|||
import { getSettings } from './settings';
|
||||
import { createStatus } from './statuses';
|
||||
|
||||
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
|
||||
import type { History } from 'soapbox/types/history';
|
||||
|
@ -516,7 +517,9 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId,
|
|||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
|
||||
const state = getState();
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }, state.custom_emojis);
|
||||
|
||||
dispatch(readyComposeSuggestionsEmojis(composeId, token, results));
|
||||
};
|
||||
|
||||
|
@ -561,7 +564,7 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
|
|||
let completion, startPosition;
|
||||
|
||||
if (typeof suggestion === 'object' && suggestion.id) {
|
||||
completion = suggestion.native || suggestion.colons;
|
||||
completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons;
|
||||
startPosition = position - 1;
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { saveSettings } from './settings';
|
||||
|
||||
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
import type { AppDispatch } from 'soapbox/store';
|
||||
|
||||
const EMOJI_USE = 'EMOJI_USE';
|
||||
|
|
|
@ -569,7 +569,7 @@ const rejectEventParticipationRequestFail = (id: string, accountId: string, erro
|
|||
});
|
||||
|
||||
const fetchEventIcs = (id: string) =>
|
||||
(dispatch: any, getState: () => RootState) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) =>
|
||||
api(getState).get(`/api/v1/pleroma/events/${id}/ics`);
|
||||
|
||||
const cancelEventCompose = () => ({
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light';
|
||||
import unicodeMapping from 'soapbox/features/emoji/mapping';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import AnimatedNumber from 'soapbox/components/animated-number';
|
||||
import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light';
|
||||
import unicodeMapping from 'soapbox/features/emoji/mapping';
|
||||
|
||||
import Emoji from './emoji';
|
||||
|
||||
|
|
|
@ -2,14 +2,13 @@ import clsx from 'clsx';
|
|||
import React from 'react';
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
import EmojiPickerDropdown from 'soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown';
|
||||
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
|
||||
import Reaction from './reaction';
|
||||
|
||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import type { Emoji, NativeEmoji } from 'soapbox/features/emoji';
|
||||
import type { AnnouncementReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IReactionsBar {
|
||||
|
@ -24,7 +23,7 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addR
|
|||
const reduceMotion = useSettings().get('reduceMotion');
|
||||
|
||||
const handleEmojiPick = (data: Emoji) => {
|
||||
addReaction(announcementId, data.native.replace(/:/g, ''));
|
||||
addReaction(announcementId, (data as NativeEmoji).native.replace(/:/g, ''));
|
||||
};
|
||||
|
||||
const willEnter = () => ({ scale: reduceMotion ? 1 : 0 });
|
||||
|
@ -55,7 +54,7 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addR
|
|||
/>
|
||||
))}
|
||||
|
||||
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} button={<Icon className='h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-white' src={require('@tabler/icons/plus.svg')} />} />}
|
||||
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />}
|
||||
</div>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
|
|
|
@ -1,38 +1,30 @@
|
|||
import React from 'react';
|
||||
|
||||
import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light';
|
||||
import { isCustomEmoji } from 'soapbox/features/emoji';
|
||||
import unicodeMapping from 'soapbox/features/emoji/mapping';
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
export type Emoji = {
|
||||
id: string
|
||||
custom: boolean
|
||||
imageUrl: string
|
||||
native: string
|
||||
colons: string
|
||||
}
|
||||
|
||||
type UnicodeMapping = {
|
||||
filename: string
|
||||
}
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
interface IAutosuggestEmoji {
|
||||
emoji: Emoji
|
||||
}
|
||||
|
||||
const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
|
||||
let url;
|
||||
let url, alt;
|
||||
|
||||
if (emoji.custom) {
|
||||
if (isCustomEmoji(emoji)) {
|
||||
url = emoji.imageUrl;
|
||||
alt = emoji.colons;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const mapping: UnicodeMapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
url = joinPublicPath(`packs/emoji/${mapping.filename}.svg`);
|
||||
url = joinPublicPath(`packs/emoji/${mapping.unified}.svg`);
|
||||
alt = emoji.native;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -40,7 +32,7 @@ const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
|
|||
<img
|
||||
className='emojione'
|
||||
src={url}
|
||||
alt={emoji.native || emoji.colons}
|
||||
alt={alt}
|
||||
/>
|
||||
|
||||
{emoji.colons}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { List as ImmutableList } from 'immutable';
|
|||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Input, Portal } from 'soapbox/components/ui';
|
||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||
|
@ -12,6 +12,7 @@ import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
|||
|
||||
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
|
||||
import type { InputThemes } from 'soapbox/components/ui/input/input';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
export type AutoSuggestion = string | Emoji;
|
||||
|
||||
|
|
|
@ -4,14 +4,14 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import { Portal } from 'soapbox/components/ui';
|
||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||
import { isRtl } from 'soapbox/rtl';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
||||
import AutosuggestAccount from '../features/compose/components/autosuggest-account';
|
||||
import { isRtl } from '../rtl';
|
||||
|
||||
import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
|
||||
import AutosuggestEmoji from './autosuggest-emoji';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
interface IAutosuggesteTextarea {
|
||||
id?: string
|
||||
|
|
|
@ -17,43 +17,53 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
|||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
|
||||
<div className='relative -m-[1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
|
||||
{group.header && <img className='h-full w-full rounded-t-lg object-cover sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
<Stack className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'>
|
||||
{/* Group Cover Image */}
|
||||
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||
{group.header && (
|
||||
<img
|
||||
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
||||
src={group.header} alt={intl.formatMessage(messages.groupHeader)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Group Avatar */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
|
||||
{/* Group Info */}
|
||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<Text theme='inherit'><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></Text>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<Text theme='inherit'><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<Text theme='inherit'><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<Text theme='inherit'><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
|
|||
import Account from 'soapbox/components/account';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import ProfileStats from 'soapbox/features/ui/components/profile-stats';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useGroupsPath, useFeatures } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
|
||||
|
||||
import { Divider, HStack, Icon, IconButton, Text } from './ui';
|
||||
|
@ -90,6 +90,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
|
||||
const settings = useAppSelector((state) => getSettings(state));
|
||||
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
|
||||
const groupsPath = useGroupsPath();
|
||||
|
||||
const closeButtonRef = React.useRef(null);
|
||||
|
||||
|
@ -210,7 +211,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
|
||||
{features.groups && (
|
||||
<SidebarLink
|
||||
to='/groups'
|
||||
to={groupsPath}
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={intl.formatMessage(messages.groups)}
|
||||
onClick={onClose}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
import { Stack } from 'soapbox/components/ui';
|
||||
import { useStatContext } from 'soapbox/contexts/stat-context';
|
||||
import ComposeButton from 'soapbox/features/ui/components/compose-button';
|
||||
import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||
import { useAppSelector, useGroupsPath, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||
|
||||
import DropdownMenu, { Menu } from './dropdown-menu';
|
||||
import SidebarNavigationLink from './sidebar-navigation-link';
|
||||
|
@ -25,6 +25,8 @@ const SidebarNavigation = () => {
|
|||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const account = useOwnAccount();
|
||||
const groupsPath = useGroupsPath();
|
||||
|
||||
const notificationCount = useAppSelector((state) => state.notifications.unread);
|
||||
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
|
||||
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||
|
@ -135,7 +137,7 @@ const SidebarNavigation = () => {
|
|||
|
||||
{features.groups && (
|
||||
<SidebarNavigationLink
|
||||
to='/groups'
|
||||
to={groupsPath}
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
|
||||
/>
|
||||
|
|
|
@ -112,6 +112,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
|
|||
referenceElement={referenceElement}
|
||||
onReact={handleReact}
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||
import HoverStatusWrapper from 'soapbox/components/hover-status-wrapper';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { isPubkey } from 'soapbox/utils/nostr';
|
||||
|
||||
import type { Account, Status } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -56,7 +57,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
|
|||
className='reply-mentions__account'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{account.username}
|
||||
@{isPubkey(account.username) ? account.username.slice(0, 8) : account.username}
|
||||
</Link>
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useDimensions } from 'soapbox/hooks';
|
||||
|
||||
import HStack from '../hstack/hstack';
|
||||
import Icon from '../icon/icon';
|
||||
|
||||
interface ICarousel {
|
||||
children: any
|
||||
/** Optional height to force on controls */
|
||||
controlsHeight?: number
|
||||
/** How many items in the carousel */
|
||||
itemCount: number
|
||||
/** The minimum width per item */
|
||||
itemWidth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Carousel
|
||||
*/
|
||||
const Carousel: React.FC<ICarousel> = (props): JSX.Element => {
|
||||
const { children, controlsHeight, itemCount, itemWidth } = props;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_ref, setContainerRef, { width: containerWidth }] = useDimensions();
|
||||
|
||||
const [pageSize, setPageSize] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
|
||||
const numberOfPages = Math.ceil(itemCount / pageSize);
|
||||
const width = containerWidth / (Math.floor(containerWidth / itemWidth));
|
||||
|
||||
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
|
||||
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
|
||||
|
||||
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
|
||||
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
|
||||
|
||||
const renderChildren = () => {
|
||||
if (typeof children === 'function') {
|
||||
return children({ width: width || 'auto' });
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (containerWidth) {
|
||||
setPageSize(Math.round(containerWidth / width));
|
||||
}
|
||||
}, [containerWidth, width]);
|
||||
|
||||
return (
|
||||
<HStack alignItems='stretch'>
|
||||
<div
|
||||
className='z-10 flex w-5 items-center justify-center self-stretch rounded-l-xl bg-white dark:bg-primary-900'
|
||||
style={{
|
||||
height: controlsHeight || 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-testid='prev-page'
|
||||
onClick={handlePrevPage}
|
||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||
disabled={!hasPrevPage}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/chevron-left.svg')}
|
||||
className='h-5 w-5 text-black dark:text-white'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='relative w-full overflow-hidden'>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
style={{
|
||||
transform: `translateX(-${(currentPage - 1) * 100}%)`,
|
||||
}}
|
||||
className='transition-all duration-500 ease-out'
|
||||
ref={setContainerRef}
|
||||
>
|
||||
{renderChildren()}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='z-10 flex w-5 items-center justify-center self-stretch rounded-r-xl bg-white dark:bg-primary-900'
|
||||
style={{
|
||||
height: controlsHeight || 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-testid='next-page'
|
||||
onClick={handleNextPage}
|
||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/chevron-right.svg')}
|
||||
className='h-5 w-5 text-black dark:text-white'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Carousel;
|
|
@ -3,10 +3,12 @@ import clsx from 'clsx';
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
import { Emoji, HStack, IconButton } from 'soapbox/components/ui';
|
||||
import { Picker } from 'soapbox/features/emoji/emoji-picker';
|
||||
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 type { Emoji, NativeEmoji } from 'soapbox/features/emoji';
|
||||
|
||||
interface IEmojiButton {
|
||||
/** Unicode emoji character. */
|
||||
emoji: string
|
||||
|
@ -29,7 +31,7 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
|
|||
|
||||
return (
|
||||
<button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
|
||||
<Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
|
||||
<EmojiComponent className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
@ -68,10 +70,16 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (referenceElement?.contains(event.target as Node) || popperElement?.contains(event.target as Node)) {
|
||||
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();
|
||||
}
|
||||
|
@ -93,6 +101,14 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
setExpanded(true);
|
||||
};
|
||||
|
||||
const handlePickEmoji = (emoji: Emoji) => {
|
||||
onReact((emoji as NativeEmoji).native);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
document.body.style.overflow = '';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(false);
|
||||
}, [visible]);
|
||||
|
@ -103,7 +119,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [referenceElement]);
|
||||
}, [referenceElement, popperElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && update) {
|
||||
|
@ -117,6 +133,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
}
|
||||
}, [expanded, update]);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('z-[101] transition-opacity duration-100', {
|
||||
|
@ -127,10 +144,12 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({
|
|||
{...attributes.popper}
|
||||
>
|
||||
{expanded ? (
|
||||
<Picker
|
||||
set='twitter'
|
||||
backgroundImageFn={() => require('emoji-datasource/img/twitter/sheets/32.png')}
|
||||
onClick={(emoji: any) => onReact(emoji.native)}
|
||||
<EmojiPickerDropdown
|
||||
visible={expanded}
|
||||
setVisible={setExpanded}
|
||||
update={update}
|
||||
withCustom={false}
|
||||
onPickEmoji={handlePickEmoji}
|
||||
/>
|
||||
) : (
|
||||
<HStack
|
||||
|
|
|
@ -2,6 +2,7 @@ export { default as Accordion } from './accordion/accordion';
|
|||
export { default as Avatar } from './avatar/avatar';
|
||||
export { default as Banner } from './banner/banner';
|
||||
export { default as Button } from './button/button';
|
||||
export { default as Carousel } from './carousel/carousel';
|
||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||
export { default as Checkbox } from './checkbox/checkbox';
|
||||
export { Column, ColumnHeader } from './column/column';
|
||||
|
|
|
@ -103,7 +103,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia }) => {
|
|||
} else if (attachment.type === 'audio') {
|
||||
const remoteURL = attachment.remote_url || '';
|
||||
const fileExtensionLastIndex = remoteURL.lastIndexOf('.');
|
||||
const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase();
|
||||
const fileExtension = remoteURL.slice(fileExtensionLastIndex + 1).toUpperCase();
|
||||
thumbnail = (
|
||||
<div className='media-gallery__item-thumbnail'>
|
||||
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span>
|
||||
|
|
|
@ -6,13 +6,15 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useChatContext } from 'soapbox/contexts/chat-context';
|
||||
import UploadButton from 'soapbox/features/compose/components/upload-button';
|
||||
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
|
||||
import emojiSearch from 'soapbox/features/emoji/search';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { Attachment } from 'soapbox/types/entities';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
||||
import ChatTextarea from './chat-textarea';
|
||||
|
||||
import type { Emoji, NativeEmoji } from 'soapbox/features/emoji';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
|
||||
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
|
||||
|
@ -31,7 +33,7 @@ const initialSuggestionState = {
|
|||
};
|
||||
|
||||
interface Suggestion {
|
||||
list: { native: string, colons: string }[]
|
||||
list: Emoji[]
|
||||
tokenStart: number
|
||||
token: string
|
||||
}
|
||||
|
@ -45,7 +47,7 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
|
|||
resetContentKey: number | null
|
||||
attachments?: Attachment[]
|
||||
onDeleteAttachment?: (i: number) => void
|
||||
isUploading?: boolean
|
||||
uploadCount?: number
|
||||
uploadProgress?: number
|
||||
}
|
||||
|
||||
|
@ -63,7 +65,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
onPaste,
|
||||
attachments = [],
|
||||
onDeleteAttachment,
|
||||
isUploading,
|
||||
uploadCount = 0,
|
||||
uploadProgress,
|
||||
}, ref) => {
|
||||
const intl = useIntl();
|
||||
|
@ -80,6 +82,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
|
||||
const isSuggestionsAvailable = suggestions.list.length > 0;
|
||||
|
||||
const isUploading = uploadCount > 0;
|
||||
const hasAttachment = attachments.length > 0;
|
||||
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
||||
const isSubmitDisabled = disabled || isUploading || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
||||
|
@ -198,7 +201,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
disabled={disabled}
|
||||
attachments={attachments}
|
||||
onDeleteAttachment={onDeleteAttachment}
|
||||
isUploading={isUploading}
|
||||
uploadCount={uploadCount}
|
||||
uploadProgress={uploadProgress}
|
||||
/>
|
||||
{isSuggestionsAvailable ? (
|
||||
|
@ -209,7 +212,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
|||
key={emojiSuggestion.colons}
|
||||
value={renderSuggestionValue(emojiSuggestion)}
|
||||
>
|
||||
<span>{emojiSuggestion.native}</span>
|
||||
<span>{(emojiSuggestion as NativeEmoji).native}</span>
|
||||
<span className='ml-1'>
|
||||
{emojiSuggestion.colons}
|
||||
</span>
|
||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|||
import React from 'react';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { EmojiReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IChatMessageReaction {
|
||||
|
@ -42,4 +42,4 @@ const ChatMessageReaction = (props: IChatMessageReaction) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default ChatMessageReaction;
|
||||
export default ChatMessageReaction;
|
||||
|
|
|
@ -9,7 +9,7 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
import { initReport } from 'soapbox/actions/reports';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
@ -390,4 +390,4 @@ const ChatMessage = (props: IChatMessage) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
export default ChatMessage;
|
||||
|
|
|
@ -9,7 +9,7 @@ import ChatUpload from './chat-upload';
|
|||
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
||||
attachments?: Attachment[]
|
||||
onDeleteAttachment?: (i: number) => void
|
||||
isUploading?: boolean
|
||||
uploadCount?: number
|
||||
uploadProgress?: number
|
||||
}
|
||||
|
||||
|
@ -17,10 +17,12 @@ interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
|||
const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||
attachments,
|
||||
onDeleteAttachment,
|
||||
isUploading = false,
|
||||
uploadCount = 0,
|
||||
uploadProgress = 0,
|
||||
...rest
|
||||
}) => {
|
||||
const isUploading = uploadCount > 0;
|
||||
|
||||
const handleDeleteAttachment = (i: number) => {
|
||||
return () => {
|
||||
if (onDeleteAttachment) {
|
||||
|
@ -54,11 +56,11 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{isUploading && (
|
||||
{Array.from(Array(uploadCount)).map(() => (
|
||||
<div className='ml-2 mt-2 flex'>
|
||||
<ChatPendingUpload progress={uploadProgress} />
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
|
|
|
@ -57,7 +57,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadCount, setUploadCount] = useState(0);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen());
|
||||
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
|
||||
|
@ -86,7 +86,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
}
|
||||
setContent('');
|
||||
setAttachments([]);
|
||||
setIsUploading(false);
|
||||
setUploadCount(0);
|
||||
setUploadProgress(0);
|
||||
setResetFileKey(fileKeyGen());
|
||||
setResetContentKey(fileKeyGen());
|
||||
|
@ -151,17 +151,21 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadCount(files.length);
|
||||
|
||||
const data = new FormData();
|
||||
data.append('file', files[0]);
|
||||
|
||||
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
|
||||
setAttachments([...attachments, normalizeAttachment(response.data)]);
|
||||
setIsUploading(false);
|
||||
}).catch(() => {
|
||||
setIsUploading(false);
|
||||
const promises = Array.from(files).map(async(file) => {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
const response = await dispatch(uploadMedia(data, onUploadProgress));
|
||||
return normalizeAttachment(response.data);
|
||||
});
|
||||
|
||||
return Promise.all(promises)
|
||||
.then((newAttachments) => {
|
||||
setAttachments([...attachments, ...newAttachments]);
|
||||
setUploadCount(0);
|
||||
})
|
||||
.catch(() => setUploadCount(0));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -189,7 +193,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
|||
onPaste={handlePaste}
|
||||
attachments={attachments}
|
||||
onDeleteAttachment={handleRemoveFile}
|
||||
isUploading={isUploading}
|
||||
uploadCount={uploadCount}
|
||||
uploadProgress={uploadProgress}
|
||||
/>
|
||||
</Stack>
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
||||
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
||||
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useFeatures, useInstance, usePrevious } from 'soapbox/hooks';
|
||||
import { isMobile } from 'soapbox/is-mobile';
|
||||
|
||||
|
@ -26,7 +27,6 @@ import UploadButtonContainer from '../containers/upload-button-container';
|
|||
import WarningContainer from '../containers/warning-container';
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
import EmojiPickerDropdown from './emoji-picker/emoji-picker-dropdown';
|
||||
import MarkdownButton from './markdown-button';
|
||||
import PollButton from './poll-button';
|
||||
import PollForm from './polls/poll-form';
|
||||
|
@ -40,7 +40,7 @@ import UploadForm from './upload-form';
|
|||
import VisualCharacterCounter from './visual-character-counter';
|
||||
import Warning from './warning';
|
||||
|
||||
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
||||
|
||||
|
@ -116,7 +116,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
// FIXME: Make this less brittle
|
||||
getClickableArea(),
|
||||
document.querySelector('.privacy-dropdown__dropdown'),
|
||||
document.querySelector('.emoji-picker-dropdown__menu'),
|
||||
document.querySelector('em-emoji-picker'),
|
||||
document.getElementById('modal-overlay'),
|
||||
].some(element => element?.contains(e.target as any));
|
||||
};
|
||||
|
@ -179,7 +179,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
const handleEmojiPick = (data: Emoji) => {
|
||||
const position = autosuggestTextareaRef.current!.textarea!.selectionStart;
|
||||
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
|
||||
const needsSpace = !!data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
|
||||
|
||||
dispatch(insertEmojiCompose(id, position, data, needsSpace));
|
||||
};
|
||||
|
@ -226,7 +226,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
const renderButtons = useCallback(() => (
|
||||
<HStack alignItems='center' space={2}>
|
||||
{features.media && <UploadButtonContainer composeId={id} />}
|
||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
|
||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} condensed={shouldCondense} />
|
||||
{features.polls && <PollButton composeId={id} />}
|
||||
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
|
||||
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
||||
|
|
|
@ -1,209 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
// @ts-ignore
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { useEmoji } from 'soapbox/actions/emojis';
|
||||
import { getSettings, changeSetting } from 'soapbox/actions/settings';
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import { EmojiPicker as EmojiPickerAsync } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import EmojiPickerMenu from './emoji-picker-menu';
|
||||
|
||||
import type { Emoji as EmojiType } from 'soapbox/components/autosuggest-emoji';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
let EmojiPicker: any, Emoji: any; // load asynchronously
|
||||
|
||||
const perLine = 8;
|
||||
const lines = 2;
|
||||
|
||||
const DEFAULTS = [
|
||||
'+1',
|
||||
'grinning',
|
||||
'kissing_heart',
|
||||
'heart_eyes',
|
||||
'laughing',
|
||||
'stuck_out_tongue_winking_eye',
|
||||
'sweat_smile',
|
||||
'joy',
|
||||
'yum',
|
||||
'disappointed',
|
||||
'thinking_face',
|
||||
'weary',
|
||||
'sob',
|
||||
'sunglasses',
|
||||
'heart',
|
||||
'ok_hand',
|
||||
];
|
||||
|
||||
const getFrequentlyUsedEmojis = createSelector([
|
||||
(state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()),
|
||||
], emojiCounters => {
|
||||
let emojis = emojiCounters
|
||||
.keySeq()
|
||||
.sort((a: number, b: number) => emojiCounters.get(a) - emojiCounters.get(b))
|
||||
.reverse()
|
||||
.slice(0, perLine * lines)
|
||||
.toArray();
|
||||
|
||||
if (emojis.length < DEFAULTS.length) {
|
||||
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
|
||||
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
|
||||
}
|
||||
|
||||
return emojis;
|
||||
});
|
||||
|
||||
const getCustomEmojis = createSelector([
|
||||
(state: RootState) => state.custom_emojis as ImmutableList<ImmutableMap<string, string>>,
|
||||
], emojis => emojis.filter((e) => e.get('visible_in_picker')).sort((a, b) => {
|
||||
const aShort = a.get('shortcode')!.toLowerCase();
|
||||
const bShort = b.get('shortcode')!.toLowerCase();
|
||||
|
||||
if (aShort < bShort) {
|
||||
return -1;
|
||||
} else if (aShort > bShort) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}) as ImmutableList<ImmutableMap<string, string>>);
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
|
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
|
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
|
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
});
|
||||
|
||||
interface IEmojiPickerDropdown {
|
||||
onPickEmoji: (data: EmojiType) => void
|
||||
button?: JSX.Element
|
||||
}
|
||||
|
||||
const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({ onPickEmoji, button }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const customEmojis = useAppSelector((state) => getCustomEmojis(state));
|
||||
const skinTone = useAppSelector((state) => getSettings(state).get('skinTone') as number);
|
||||
const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state));
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [placement, setPlacement] = useState<'bottom' | 'top'>();
|
||||
|
||||
const target = useRef(null);
|
||||
|
||||
const onSkinTone = (skinTone: number) => {
|
||||
dispatch(changeSetting(['skinTone'], skinTone));
|
||||
};
|
||||
|
||||
const handlePickEmoji = (emoji: EmojiType) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
dispatch(useEmoji(emoji));
|
||||
|
||||
if (onPickEmoji) {
|
||||
onPickEmoji(emoji);
|
||||
}
|
||||
};
|
||||
|
||||
const onShowDropdown: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setActive(true);
|
||||
|
||||
if (!EmojiPicker) {
|
||||
setLoading(true);
|
||||
|
||||
EmojiPickerAsync().then(EmojiMart => {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
Emoji = EmojiMart.Emoji;
|
||||
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const { top } = (e.target as any).getBoundingClientRect();
|
||||
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
|
||||
};
|
||||
|
||||
const onHideDropdown = () => {
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
const onToggle: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
|
||||
if (!loading && (!(e as React.KeyboardEvent).key || (e as React.KeyboardEvent).key === 'Enter')) {
|
||||
if (active) {
|
||||
onHideDropdown();
|
||||
} else {
|
||||
onShowDropdown(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
if (e.key === 'Escape') {
|
||||
onHideDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
|
||||
return (
|
||||
<div className='relative' onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
ref={target}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-expanded={active}
|
||||
role='button'
|
||||
onClick={onToggle}
|
||||
onKeyDown={onToggle}
|
||||
tabIndex={0}
|
||||
>
|
||||
{button || <IconButton
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
'pulse-loading': active && loading,
|
||||
})}
|
||||
title='😀'
|
||||
src={require('@tabler/icons/mood-happy.svg')}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Overlay show={active} placement={placement} target={target.current}>
|
||||
<EmojiPickerMenu
|
||||
customEmojis={customEmojis}
|
||||
loading={loading}
|
||||
onClose={onHideDropdown}
|
||||
onPick={handlePickEmoji}
|
||||
onSkinTone={onSkinTone}
|
||||
skinTone={skinTone}
|
||||
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { EmojiPicker, Emoji };
|
||||
|
||||
export default EmojiPickerDropdown;
|
|
@ -1,171 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { buildCustomEmojis, categoriesFromEmojis } from '../../../emoji/emoji';
|
||||
|
||||
import { EmojiPicker } from './emoji-picker-dropdown';
|
||||
import ModifierPicker from './modifier-picker';
|
||||
|
||||
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
|
||||
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
|
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
|
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
|
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
});
|
||||
|
||||
interface IEmojiPickerMenu {
|
||||
customEmojis: ImmutableList<ImmutableMap<string, string>>
|
||||
loading?: boolean
|
||||
onClose: () => void
|
||||
onPick: (emoji: Emoji) => void
|
||||
onSkinTone: (skinTone: number) => void
|
||||
skinTone?: number
|
||||
frequentlyUsedEmojis?: Array<string>
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const EmojiPickerMenu: React.FC<IEmojiPickerMenu> = ({
|
||||
customEmojis,
|
||||
loading = true,
|
||||
onClose,
|
||||
onPick,
|
||||
onSkinTone,
|
||||
skinTone,
|
||||
frequentlyUsedEmojis = [],
|
||||
style = {},
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [modifierOpen, setModifierOpen] = useState(false);
|
||||
|
||||
const categoriesSort = [
|
||||
'recent',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
];
|
||||
|
||||
categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(customEmojis) as Set<string>).sort());
|
||||
|
||||
const handleDocumentClick = useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (node.current && !node.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getI18n = () => {
|
||||
return {
|
||||
search: intl.formatMessage(messages.emoji_search),
|
||||
notfound: intl.formatMessage(messages.emoji_not_found),
|
||||
categories: {
|
||||
search: intl.formatMessage(messages.search_results),
|
||||
recent: intl.formatMessage(messages.recent),
|
||||
people: intl.formatMessage(messages.people),
|
||||
nature: intl.formatMessage(messages.nature),
|
||||
foods: intl.formatMessage(messages.food),
|
||||
activity: intl.formatMessage(messages.activity),
|
||||
places: intl.formatMessage(messages.travel),
|
||||
objects: intl.formatMessage(messages.objects),
|
||||
symbols: intl.formatMessage(messages.symbols),
|
||||
flags: intl.formatMessage(messages.flags),
|
||||
custom: intl.formatMessage(messages.custom),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleClick = (emoji: any) => {
|
||||
if (!emoji.native) {
|
||||
emoji.native = emoji.colons;
|
||||
}
|
||||
|
||||
onClose();
|
||||
onPick(emoji);
|
||||
};
|
||||
|
||||
const handleModifierOpen = () => {
|
||||
setModifierOpen(true);
|
||||
};
|
||||
|
||||
const handleModifierClose = () => {
|
||||
setModifierOpen(false);
|
||||
};
|
||||
|
||||
const handleModifierChange = (modifier: number) => {
|
||||
onSkinTone(modifier);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ width: 299 }} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
|
||||
return (
|
||||
<div className={clsx('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={node}>
|
||||
<EmojiPicker
|
||||
perLine={8}
|
||||
emojiSize={22}
|
||||
sheetSize={32}
|
||||
custom={buildCustomEmojis(customEmojis)}
|
||||
color=''
|
||||
emoji=''
|
||||
set='twitter'
|
||||
title={title}
|
||||
i18n={getI18n()}
|
||||
onClick={handleClick}
|
||||
include={categoriesSort}
|
||||
recent={frequentlyUsedEmojis}
|
||||
skin={skinTone}
|
||||
showPreview={false}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
autoFocus
|
||||
emojiTooltip
|
||||
/>
|
||||
|
||||
<ModifierPicker
|
||||
active={modifierOpen}
|
||||
modifier={skinTone}
|
||||
onOpen={handleModifierOpen}
|
||||
onClose={handleModifierClose}
|
||||
onChange={handleModifierChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPickerMenu;
|
|
@ -1,73 +0,0 @@
|
|||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { Emoji } from './emoji-picker-dropdown';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
|
||||
interface IModifierPickerMenu {
|
||||
active: boolean
|
||||
onSelect: (modifier: number) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ModifierPickerMenu: React.FC<IModifierPickerMenu> = ({ active, onSelect, onClose }) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = e => {
|
||||
onSelect(+e.currentTarget.getAttribute('data-index')! * 1);
|
||||
};
|
||||
|
||||
const handleDocumentClick = useCallback(((e: MouseEvent | TouchEvent) => {
|
||||
if (node.current && !node.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}), []);
|
||||
|
||||
const attachListeners = () => {
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
};
|
||||
|
||||
const removeListeners = () => {
|
||||
document.removeEventListener('click', handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeListeners();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) attachListeners();
|
||||
else removeListeners();
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={node}>
|
||||
<button onClick={handleClick} data-index={1}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={2}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={3}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={4}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={5}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={6}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModifierPickerMenu;
|
|
@ -1,38 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Emoji } from './emoji-picker-dropdown';
|
||||
import ModifierPickerMenu from './modifier-picker-menu';
|
||||
|
||||
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
|
||||
interface IModifierPicker {
|
||||
active: boolean
|
||||
modifier?: number
|
||||
onOpen: () => void
|
||||
onClose: () => void
|
||||
onChange: (skinTone: number) => void
|
||||
}
|
||||
|
||||
const ModifierPicker: React.FC<IModifierPicker> = ({ active, modifier, onOpen, onClose, onChange }) => {
|
||||
const handleClick = () => {
|
||||
if (active) {
|
||||
onClose();
|
||||
} else {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (modifier: number) => {
|
||||
onChange(modifier);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers'>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={handleClick} backgroundImageFn={backgroundImageFn} />
|
||||
<ModifierPickerMenu active={active} onSelect={handleSelect} onClose={onClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModifierPicker;
|
|
@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
import { useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
import { isPubkey } from 'soapbox/utils/nostr';
|
||||
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -52,9 +53,14 @@ const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
|
|||
);
|
||||
}
|
||||
|
||||
const accounts = to.slice(0, 2).map((acct: string) => (
|
||||
<span className='reply-mentions__account'>@{acct.split('@')[0]}</span>
|
||||
)).toArray();
|
||||
const accounts = to.slice(0, 2).map((acct: string) => {
|
||||
const username = acct.split('@')[0];
|
||||
return (
|
||||
<span className='reply-mentions__account'>
|
||||
@{isPubkey(username) ? username.slice(0, 8) : username}
|
||||
</span>
|
||||
);
|
||||
}).toArray();
|
||||
|
||||
if (to.size > 2) {
|
||||
accounts.push(
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
// @ts-ignore
|
||||
import { emojiIndex } from 'emoji-mart';
|
||||
import { List, Map } from 'immutable';
|
||||
import pick from 'lodash/pick';
|
||||
|
||||
import { search } from '../emoji-mart-search-light';
|
||||
import search, { addCustomToPool } from '../search';
|
||||
|
||||
const trimEmojis = (emoji: any) => pick(emoji, ['id', 'unified', 'native', 'custom']);
|
||||
|
||||
|
@ -16,116 +15,83 @@ describe('emoji_index', () => {
|
|||
},
|
||||
];
|
||||
expect(search('pineapple').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('pineapple').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('orders search results correctly', () => {
|
||||
const expected = [
|
||||
{
|
||||
id: 'apple',
|
||||
unified: '1f34e',
|
||||
native: '🍎',
|
||||
},
|
||||
{
|
||||
id: 'pineapple',
|
||||
unified: '1f34d',
|
||||
native: '🍍',
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
unified: '1f34e',
|
||||
native: '🍎',
|
||||
},
|
||||
{
|
||||
id: 'green_apple',
|
||||
unified: '1f34f',
|
||||
native: '🍏',
|
||||
},
|
||||
{
|
||||
id: 'iphone',
|
||||
unified: '1f4f1',
|
||||
native: '📱',
|
||||
},
|
||||
];
|
||||
expect(search('apple').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('can include/exclude categories', () => {
|
||||
expect(search('flag', { include: ['people'] } as any)).toEqual([]);
|
||||
expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('(different behavior from emoji-mart) do not erases custom emoji if not passed again', () => {
|
||||
it('handles custom emojis', () => {
|
||||
const custom = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
name: 'mastodon',
|
||||
short_names: ['mastodon'],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: ['mastodon'],
|
||||
imageUrl: 'http://example.com',
|
||||
custom: true,
|
||||
skins: { src: 'http://example.com' },
|
||||
},
|
||||
];
|
||||
search('', { custom } as any);
|
||||
emojiIndex.search('', { custom });
|
||||
|
||||
const custom_emojis = List([
|
||||
Map({ static_url: 'http://example.com', shortcode: 'mastodon' }),
|
||||
]);
|
||||
|
||||
const lightExpected = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
custom: true,
|
||||
},
|
||||
];
|
||||
expect(search('masto').map(trimEmojis)).toEqual(lightExpected);
|
||||
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual([]);
|
||||
|
||||
addCustomToPool(custom);
|
||||
expect(search('masto', {}, custom_emojis).map(trimEmojis)).toEqual(lightExpected);
|
||||
});
|
||||
|
||||
it('(different behavior from emoji-mart) erases custom emoji if another is passed', () => {
|
||||
it('updates custom emoji if another is passed', () => {
|
||||
const custom = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
name: 'mastodon',
|
||||
short_names: ['mastodon'],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: ['mastodon'],
|
||||
imageUrl: 'http://example.com',
|
||||
custom: true,
|
||||
skins: { src: 'http://example.com' },
|
||||
},
|
||||
];
|
||||
search('', { custom } as any);
|
||||
emojiIndex.search('', { custom });
|
||||
expect(search('masto', { custom: [] } as any).map(trimEmojis)).toEqual([]);
|
||||
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles custom emoji', () => {
|
||||
const custom = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
name: 'mastodon',
|
||||
short_names: ['mastodon'],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: ['mastodon'],
|
||||
imageUrl: 'http://example.com',
|
||||
custom: true,
|
||||
},
|
||||
];
|
||||
search('', { custom } as any);
|
||||
emojiIndex.search('', { custom });
|
||||
const expected = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
custom: true,
|
||||
},
|
||||
];
|
||||
expect(search('masto', { custom } as any).map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('masto', { custom }).map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
addCustomToPool(custom);
|
||||
|
||||
it('should filter only emojis we care about, exclude pineapple', () => {
|
||||
const emojisToShowFilter = (emoji: any) => emoji.unified !== '1F34D';
|
||||
expect(search('apple', { emojisToShowFilter } as any).map((obj: any) => obj.id))
|
||||
.not.toContain('pineapple');
|
||||
expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj: any) => obj.id))
|
||||
.not.toContain('pineapple');
|
||||
const custom2 = [
|
||||
{
|
||||
id: 'pleroma',
|
||||
name: 'pleroma',
|
||||
keywords: ['pleroma'],
|
||||
skins: { src: 'http://example.com' },
|
||||
},
|
||||
];
|
||||
|
||||
addCustomToPool(custom2);
|
||||
|
||||
const custom_emojis = List([
|
||||
Map({ static_url: 'http://example.com', shortcode: 'pleroma' }),
|
||||
]);
|
||||
|
||||
const expected: any = [];
|
||||
expect(search('masto', {}, custom_emojis).map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('does an emoji whose unified name is irregular', () => {
|
||||
|
@ -147,7 +113,6 @@ describe('emoji_index', () => {
|
|||
},
|
||||
];
|
||||
expect(search('polo').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('polo').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('can search for thinking_face', () => {
|
||||
|
@ -159,7 +124,6 @@ describe('emoji_index', () => {
|
|||
},
|
||||
];
|
||||
expect(search('thinking_fac').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('thinking_fac').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('can search for woman-facepalming', () => {
|
||||
|
@ -171,6 +135,5 @@ describe('emoji_index', () => {
|
|||
},
|
||||
];
|
||||
expect(search('woman-facep').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('woman-facep').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -0,0 +1,259 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React, { useEffect, useState, useLayoutEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { useEmoji } from 'soapbox/actions/emojis';
|
||||
import { changeSetting } from 'soapbox/actions/settings';
|
||||
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||
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
|
||||
|
||||
export const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_pick: { id: 'emoji_button.pick', defaultMessage: 'Pick an emoji…' },
|
||||
emoji_oh_no: { id: 'emoji_button.oh_no', defaultMessage: 'Oh no!' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
|
||||
emoji_add_custom: { id: 'emoji_button.add_custom', defaultMessage: 'Add custom emoji' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
|
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
|
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
|
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
skins_choose: { id: 'emoji_button.skins_choose', defaultMessage: 'Choose default skin tone' },
|
||||
skins_1: { id: 'emoji_button.skins_1', defaultMessage: 'Default' },
|
||||
skins_2: { id: 'emoji_button.skins_2', defaultMessage: 'Light' },
|
||||
skins_3: { id: 'emoji_button.skins_3', defaultMessage: 'Medium-Light' },
|
||||
skins_4: { id: 'emoji_button.skins_4', defaultMessage: 'Medium' },
|
||||
skins_5: { id: 'emoji_button.skins_5', defaultMessage: 'Medium-Dark' },
|
||||
skins_6: { id: 'emoji_button.skins_6', defaultMessage: 'Dark' },
|
||||
});
|
||||
|
||||
export interface IEmojiPickerDropdown {
|
||||
onPickEmoji?: (emoji: Emoji) => void
|
||||
condensed?: boolean
|
||||
withCustom?: boolean
|
||||
visible: boolean
|
||||
setVisible: (value: boolean) => void
|
||||
update: (() => Promise<Partial<PopperState>>) | null
|
||||
}
|
||||
|
||||
const perLine = 8;
|
||||
const lines = 2;
|
||||
|
||||
const DEFAULTS = [
|
||||
'+1',
|
||||
'grinning',
|
||||
'kissing_heart',
|
||||
'heart_eyes',
|
||||
'laughing',
|
||||
'stuck_out_tongue_winking_eye',
|
||||
'sweat_smile',
|
||||
'joy',
|
||||
'yum',
|
||||
'disappointed',
|
||||
'thinking_face',
|
||||
'weary',
|
||||
'sob',
|
||||
'sunglasses',
|
||||
'heart',
|
||||
'ok_hand',
|
||||
];
|
||||
|
||||
export const getFrequentlyUsedEmojis = createSelector([
|
||||
(state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()),
|
||||
], (emojiCounters: ImmutableMap<string, number>) => {
|
||||
let emojis = emojiCounters
|
||||
.keySeq()
|
||||
.sort((a, b) => emojiCounters.get(a)! - emojiCounters.get(b)!)
|
||||
.reverse()
|
||||
.slice(0, perLine * lines)
|
||||
.toArray();
|
||||
|
||||
if (emojis.length < DEFAULTS.length) {
|
||||
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
|
||||
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
|
||||
}
|
||||
|
||||
return emojis;
|
||||
});
|
||||
|
||||
const getCustomEmojis = createSelector([
|
||||
(state: RootState) => state.custom_emojis,
|
||||
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
|
||||
const aShort = a.get('shortcode')!.toLowerCase();
|
||||
const bShort = b.get('shortcode')!.toLowerCase();
|
||||
|
||||
if (aShort < bShort) {
|
||||
return -1;
|
||||
} else if (aShort > bShort) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}));
|
||||
|
||||
// Fixes render bug where popover has a delayed position update
|
||||
const RenderAfter = ({ children, update }: any) => {
|
||||
const [nextTick, setNextTick] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setNextTick(true);
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (nextTick) {
|
||||
update();
|
||||
}
|
||||
}, [nextTick, update]);
|
||||
|
||||
return nextTick ? children : null;
|
||||
};
|
||||
|
||||
const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({
|
||||
onPickEmoji, visible, setVisible, update, withCustom = true,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const settings = useSettings();
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const userTheme = settings.get('themeMode');
|
||||
const theme = (userTheme === 'dark' || userTheme === 'light') ? userTheme : 'auto';
|
||||
|
||||
const customEmojis = useAppSelector((state) => getCustomEmojis(state));
|
||||
const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state));
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handlePick = (emoji: any) => {
|
||||
setVisible(false);
|
||||
|
||||
let pickedEmoji: Emoji;
|
||||
|
||||
if (emoji.native) {
|
||||
pickedEmoji = {
|
||||
id: emoji.id,
|
||||
colons: emoji.shortcodes,
|
||||
custom: false,
|
||||
native: emoji.native,
|
||||
unified: emoji.unified,
|
||||
} as NativeEmoji;
|
||||
} else {
|
||||
pickedEmoji = {
|
||||
id: emoji.id,
|
||||
colons: emoji.shortcodes,
|
||||
custom: true,
|
||||
imageUrl: emoji.src,
|
||||
} as CustomEmoji;
|
||||
}
|
||||
|
||||
dispatch(useEmoji(pickedEmoji)); // eslint-disable-line react-hooks/rules-of-hooks
|
||||
|
||||
if (onPickEmoji) {
|
||||
onPickEmoji(pickedEmoji);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkinTone = (skinTone: string) => {
|
||||
dispatch(changeSetting(['skinTone'], skinTone));
|
||||
};
|
||||
|
||||
const getI18n = () => {
|
||||
return {
|
||||
search: intl.formatMessage(messages.emoji_search),
|
||||
pick: intl.formatMessage(messages.emoji_pick),
|
||||
search_no_results_1: intl.formatMessage(messages.emoji_oh_no),
|
||||
search_no_results_2: intl.formatMessage(messages.emoji_not_found),
|
||||
add_custom: intl.formatMessage(messages.emoji_add_custom),
|
||||
categories: {
|
||||
search: intl.formatMessage(messages.search_results),
|
||||
frequent: intl.formatMessage(messages.recent),
|
||||
people: intl.formatMessage(messages.people),
|
||||
nature: intl.formatMessage(messages.nature),
|
||||
foods: intl.formatMessage(messages.food),
|
||||
activity: intl.formatMessage(messages.activity),
|
||||
places: intl.formatMessage(messages.travel),
|
||||
objects: intl.formatMessage(messages.objects),
|
||||
symbols: intl.formatMessage(messages.symbols),
|
||||
flags: intl.formatMessage(messages.flags),
|
||||
custom: intl.formatMessage(messages.custom),
|
||||
},
|
||||
skins: {
|
||||
choose: intl.formatMessage(messages.skins_choose),
|
||||
1: intl.formatMessage(messages.skins_1),
|
||||
2: intl.formatMessage(messages.skins_2),
|
||||
3: intl.formatMessage(messages.skins_3),
|
||||
4: intl.formatMessage(messages.skins_4),
|
||||
5: intl.formatMessage(messages.skins_5),
|
||||
6: intl.formatMessage(messages.skins_6),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// fix scrolling focus issue
|
||||
if (visible) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (!EmojiPicker) {
|
||||
setLoading(true);
|
||||
|
||||
EmojiPickerAsync().then(EmojiMart => {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => () => {
|
||||
document.body.style.overflow = '';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
visible ? (
|
||||
<RenderAfter update={update}>
|
||||
{!loading && (
|
||||
<EmojiPicker
|
||||
custom={withCustom ? [{ emojis: buildCustomEmojis(customEmojis) }] : undefined}
|
||||
title={title}
|
||||
onEmojiSelect={handlePick}
|
||||
recent={frequentlyUsedEmojis}
|
||||
perLine={8}
|
||||
skin={handleSkinTone}
|
||||
emojiSize={22}
|
||||
emojiButtonSize={34}
|
||||
set='twitter'
|
||||
theme={theme}
|
||||
i18n={getI18n()}
|
||||
skinTonePosition='search'
|
||||
previewPosition='none'
|
||||
/>
|
||||
)}
|
||||
</RenderAfter>
|
||||
) : null
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPickerDropdown;
|
|
@ -0,0 +1,30 @@
|
|||
import { Picker as EmojiPicker } from 'emoji-mart';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
import data from '../data';
|
||||
|
||||
const getSpritesheetURL = (set: string) => {
|
||||
return require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
};
|
||||
|
||||
const getImageURL = (set: string, name: string) => {
|
||||
return joinPublicPath(`/packs/emoji/${name}.svg`);
|
||||
};
|
||||
|
||||
const Picker = (props: any) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const input = { ...props, data, ref, getImageURL, getSpritesheetURL };
|
||||
|
||||
new EmojiPicker(input);
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} />;
|
||||
};
|
||||
|
||||
export {
|
||||
Picker,
|
||||
};
|
|
@ -0,0 +1,96 @@
|
|||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { KeyboardEvent, useEffect, 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 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' },
|
||||
});
|
||||
|
||||
const EmojiPickerDropdownContainer = (
|
||||
props: Pick<IEmojiPickerDropdown, 'onPickEmoji' | 'condensed' | 'withCustom'>,
|
||||
) => {
|
||||
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 handleDocClick = (e: any) => {
|
||||
if (!containerElement?.contains(e.target) && !popperElement?.contains(e.target)) {
|
||||
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}>
|
||||
<IconButton
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
})}
|
||||
ref={setPopperReference}
|
||||
src={require('@tabler/icons/mood-happy.svg')}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-expanded={visible}
|
||||
role='button'
|
||||
onClick={handleToggle as any}
|
||||
onKeyDown={handleToggle as React.KeyboardEventHandler<HTMLButtonElement>}
|
||||
tabIndex={0}
|
||||
/>
|
||||
|
||||
{createPortal(
|
||||
<div
|
||||
className='z-[101]'
|
||||
ref={setPopperElement}
|
||||
style={style}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<EmojiPickerDropdown visible={visible} setVisible={setVisible} update={update} {...props} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default EmojiPickerDropdownContainer;
|
|
@ -0,0 +1,52 @@
|
|||
import data from '@emoji-mart/data/sets/14/twitter.json';
|
||||
|
||||
export interface NativeEmoji {
|
||||
unified: string
|
||||
native: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface CustomEmoji {
|
||||
src: string
|
||||
}
|
||||
|
||||
export interface Emoji<T> {
|
||||
id: string
|
||||
name: string
|
||||
keywords: string[]
|
||||
skins: T[]
|
||||
version?: number
|
||||
}
|
||||
|
||||
export interface EmojiCategory {
|
||||
id: string
|
||||
emojis: string[]
|
||||
}
|
||||
|
||||
export interface EmojiMap {
|
||||
[s: string]: Emoji<NativeEmoji>
|
||||
}
|
||||
|
||||
export interface EmojiAlias {
|
||||
[s: string]: string
|
||||
}
|
||||
|
||||
export interface EmojiSheet {
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
export interface EmojiData {
|
||||
categories: EmojiCategory[]
|
||||
emojis: EmojiMap
|
||||
aliases: EmojiAlias
|
||||
sheet: EmojiSheet
|
||||
}
|
||||
|
||||
const emojiData = data as EmojiData;
|
||||
const { categories, emojis, aliases, sheet } = emojiData;
|
||||
|
||||
export { categories, emojis, aliases, sheet };
|
||||
|
||||
export default emojiData;
|
|
@ -1,124 +0,0 @@
|
|||
// @preval
|
||||
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
|
||||
// This file contains the compressed version of the emoji data from
|
||||
// both emoji-map.json and from emoji-mart's emojiIndex and data objects.
|
||||
// It's designed to be emitted in an array format to take up less space
|
||||
// over the wire.
|
||||
|
||||
const { emojiIndex } = require('emoji-mart');
|
||||
let data = require('emoji-mart/data/all.json');
|
||||
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
|
||||
|
||||
const emojiMap = require('./emoji-map.json');
|
||||
const { unicodeToFilename } = require('./unicode-to-filename');
|
||||
const { unicodeToUnifiedName } = require('./unicode-to-unified-name');
|
||||
|
||||
if (data.compressed) {
|
||||
data = emojiMartUncompress(data);
|
||||
}
|
||||
|
||||
const emojiMartData = data;
|
||||
|
||||
const excluded = ['®', '©', '™'];
|
||||
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
|
||||
const shortcodeMap = {};
|
||||
|
||||
const shortCodesToEmojiData = {};
|
||||
const emojisWithoutShortCodes = [];
|
||||
|
||||
Object.keys(emojiIndex.emojis).forEach(key => {
|
||||
let emoji = emojiIndex.emojis[key];
|
||||
|
||||
// Emojis with skin tone modifiers are stored like this
|
||||
if (Object.prototype.hasOwnProperty.call(emoji, '1')) {
|
||||
emoji = emoji['1'];
|
||||
}
|
||||
|
||||
shortcodeMap[emoji.native] = emoji.id;
|
||||
});
|
||||
|
||||
const stripModifiers = unicode => {
|
||||
skinTones.forEach(tone => {
|
||||
unicode = unicode.replace(tone, '');
|
||||
});
|
||||
|
||||
return unicode;
|
||||
};
|
||||
|
||||
Object.keys(emojiMap).forEach(key => {
|
||||
if (excluded.includes(key)) {
|
||||
delete emojiMap[key];
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedKey = stripModifiers(key);
|
||||
let shortcode = shortcodeMap[normalizedKey];
|
||||
|
||||
if (!shortcode) {
|
||||
shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
|
||||
}
|
||||
|
||||
const filename = emojiMap[key];
|
||||
|
||||
const filenameData = [key];
|
||||
|
||||
if (unicodeToFilename(key) !== filename) {
|
||||
// filename can't be derived using unicodeToFilename
|
||||
filenameData.push(filename);
|
||||
}
|
||||
|
||||
if (typeof shortcode === 'undefined') {
|
||||
emojisWithoutShortCodes.push(filenameData);
|
||||
} else {
|
||||
if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
|
||||
shortCodesToEmojiData[shortcode] = [[]];
|
||||
}
|
||||
|
||||
shortCodesToEmojiData[shortcode][0].push(filenameData);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(emojiIndex.emojis).forEach(key => {
|
||||
let emoji = emojiIndex.emojis[key];
|
||||
|
||||
// Emojis with skin tone modifiers are stored like this
|
||||
if (Object.prototype.hasOwnProperty.call(emoji, '1')) {
|
||||
emoji = emoji['1'];
|
||||
}
|
||||
|
||||
const { native } = emoji;
|
||||
const { short_names, search, unified } = emojiMartData.emojis[key];
|
||||
|
||||
if (short_names[0] !== key) {
|
||||
throw new Error('The compresser expects the first short_code to be the ' +
|
||||
'key. It may need to be rewritten if the emoji change such that this ' +
|
||||
'is no longer the case.');
|
||||
}
|
||||
|
||||
const searchData = [
|
||||
native,
|
||||
short_names.slice(1), // first short name can be inferred from the key
|
||||
search,
|
||||
];
|
||||
|
||||
if (unicodeToUnifiedName(native) !== unified) {
|
||||
// unified name can't be derived from unicodeToUnifiedName
|
||||
searchData.push(unified);
|
||||
}
|
||||
|
||||
if (!Array.isArray(shortCodesToEmojiData[key])) {
|
||||
shortCodesToEmojiData[key] = [[]];
|
||||
}
|
||||
|
||||
shortCodesToEmojiData[key].push(searchData);
|
||||
});
|
||||
|
||||
// JSON.parse/stringify is to emulate what @preval is doing and avoid any
|
||||
// inconsistent behavior in dev mode
|
||||
module.exports = JSON.parse(JSON.stringify([
|
||||
shortCodesToEmojiData,
|
||||
emojiMartData.skins,
|
||||
emojiMartData.categories,
|
||||
emojiMartData.aliases,
|
||||
emojisWithoutShortCodes,
|
||||
]));
|
File diff suppressed because one or more lines are too long
|
@ -1,44 +0,0 @@
|
|||
// The output of this module is designed to mimic emoji-mart's
|
||||
// "data" object, such that we can use it for a light version of emoji-mart's
|
||||
// emojiIndex.search functionality.
|
||||
import emojiCompressed from './emoji-compressed';
|
||||
import { unicodeToUnifiedName } from './unicode-to-unified-name';
|
||||
|
||||
const [ shortCodesToEmojiData, skins, categories, short_names ] = emojiCompressed;
|
||||
|
||||
const emojis: Record<string, any> = {};
|
||||
|
||||
// decompress
|
||||
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
|
||||
const [
|
||||
_filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
searchData,
|
||||
] = shortCodesToEmojiData[shortCode];
|
||||
const [
|
||||
native,
|
||||
short_names,
|
||||
search,
|
||||
unified,
|
||||
] = searchData;
|
||||
|
||||
emojis[shortCode] = {
|
||||
native,
|
||||
search,
|
||||
short_names: [shortCode].concat(short_names),
|
||||
unified: unified || unicodeToUnifiedName(native),
|
||||
};
|
||||
});
|
||||
|
||||
export {
|
||||
emojis,
|
||||
skins,
|
||||
categories,
|
||||
short_names,
|
||||
};
|
||||
|
||||
export default {
|
||||
emojis,
|
||||
skins,
|
||||
categories,
|
||||
short_names,
|
||||
};
|
|
@ -1,183 +0,0 @@
|
|||
// This code is largely borrowed from:
|
||||
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
|
||||
|
||||
import data from './emoji-mart-data-light';
|
||||
import { getData, getSanitizedData, uniq, intersect } from './emoji-utils';
|
||||
|
||||
const originalPool = {};
|
||||
let index = {};
|
||||
const emojisList = {};
|
||||
const emoticonsList = {};
|
||||
let customEmojisList = [];
|
||||
|
||||
for (const emoji in data.emojis) {
|
||||
const emojiData = data.emojis[emoji];
|
||||
const { short_names, emoticons } = emojiData;
|
||||
const id = short_names[0];
|
||||
|
||||
if (emoticons) {
|
||||
emoticons.forEach(emoticon => {
|
||||
if (emoticonsList[emoticon]) {
|
||||
return;
|
||||
}
|
||||
|
||||
emoticonsList[emoticon] = id;
|
||||
});
|
||||
}
|
||||
|
||||
emojisList[id] = getSanitizedData(id);
|
||||
originalPool[id] = emojiData;
|
||||
}
|
||||
|
||||
function clearCustomEmojis(pool) {
|
||||
customEmojisList.forEach((emoji) => {
|
||||
const emojiId = emoji.id || emoji.short_names[0];
|
||||
|
||||
delete pool[emojiId];
|
||||
delete emojisList[emojiId];
|
||||
});
|
||||
}
|
||||
|
||||
export function addCustomToPool(custom, pool = originalPool) {
|
||||
if (customEmojisList.length) clearCustomEmojis(pool);
|
||||
|
||||
custom.forEach((emoji) => {
|
||||
const emojiId = emoji.id || emoji.short_names[0];
|
||||
|
||||
if (emojiId && !pool[emojiId]) {
|
||||
pool[emojiId] = getData(emoji);
|
||||
emojisList[emojiId] = getSanitizedData(emoji);
|
||||
}
|
||||
});
|
||||
|
||||
customEmojisList = custom;
|
||||
index = {};
|
||||
}
|
||||
|
||||
export function search(value, { emojisToShowFilter, maxResults, include, exclude, custom } = {}) {
|
||||
if (custom !== undefined) {
|
||||
if (customEmojisList !== custom)
|
||||
addCustomToPool(custom, originalPool);
|
||||
} else {
|
||||
custom = [];
|
||||
}
|
||||
|
||||
maxResults = maxResults || 75;
|
||||
include = include || [];
|
||||
exclude = exclude || [];
|
||||
|
||||
let results = null,
|
||||
pool = originalPool;
|
||||
|
||||
if (value.length) {
|
||||
if (value === '-' || value === '-1') {
|
||||
return [emojisList['-1']];
|
||||
}
|
||||
|
||||
let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
|
||||
allResults = [];
|
||||
|
||||
if (values.length > 2) {
|
||||
values = [values[0], values[1]];
|
||||
}
|
||||
|
||||
if (include.length || exclude.length) {
|
||||
pool = {};
|
||||
|
||||
data.categories.forEach(category => {
|
||||
const isIncluded = include && include.length ? include.includes(category.name.toLowerCase()) : true;
|
||||
const isExcluded = exclude && exclude.length ? exclude.includes(category.name.toLowerCase()) : false;
|
||||
if (!isIncluded || isExcluded) {
|
||||
return;
|
||||
}
|
||||
|
||||
category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
|
||||
});
|
||||
|
||||
if (custom.length) {
|
||||
const customIsIncluded = include && include.length ? include.includes('custom') : true;
|
||||
const customIsExcluded = exclude && exclude.length ? exclude.includes('custom') : false;
|
||||
if (customIsIncluded && !customIsExcluded) {
|
||||
addCustomToPool(custom, pool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const searchValue = (value) => {
|
||||
let aPool = pool,
|
||||
aIndex = index,
|
||||
length = 0;
|
||||
|
||||
for (let charIndex = 0; charIndex < value.length; charIndex++) {
|
||||
const char = value[charIndex];
|
||||
length++;
|
||||
|
||||
aIndex[char] = aIndex[char] || {};
|
||||
aIndex = aIndex[char];
|
||||
|
||||
if (!aIndex.results) {
|
||||
const scores = {};
|
||||
|
||||
aIndex.results = [];
|
||||
aIndex.pool = {};
|
||||
|
||||
for (const id in aPool) {
|
||||
const emoji = aPool[id],
|
||||
{ search } = emoji,
|
||||
sub = value.substr(0, length),
|
||||
subIndex = search.indexOf(sub);
|
||||
|
||||
if (subIndex !== -1) {
|
||||
let score = subIndex + 1;
|
||||
if (sub === id) score = 0;
|
||||
|
||||
aIndex.results.push(emojisList[id]);
|
||||
aIndex.pool[id] = emoji;
|
||||
|
||||
scores[id] = score;
|
||||
}
|
||||
}
|
||||
|
||||
aIndex.results.sort((a, b) => {
|
||||
const aScore = scores[a.id],
|
||||
bScore = scores[b.id];
|
||||
|
||||
return aScore - bScore;
|
||||
});
|
||||
}
|
||||
|
||||
aPool = aIndex.pool;
|
||||
}
|
||||
|
||||
return aIndex.results;
|
||||
};
|
||||
|
||||
if (values.length > 1) {
|
||||
results = searchValue(value);
|
||||
} else {
|
||||
results = [];
|
||||
}
|
||||
|
||||
allResults = values.map(searchValue).filter(a => a);
|
||||
|
||||
if (allResults.length > 1) {
|
||||
allResults = intersect.apply(null, allResults);
|
||||
} else if (allResults.length) {
|
||||
allResults = allResults[0];
|
||||
}
|
||||
|
||||
results = uniq(results.concat(allResults));
|
||||
}
|
||||
|
||||
if (results) {
|
||||
if (emojisToShowFilter) {
|
||||
results = results.filter((result) => emojisToShowFilter(data.emojis[result.id]));
|
||||
}
|
||||
|
||||
if (results && results.length > maxResults) {
|
||||
results = results.slice(0, maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
// @ts-ignore no types
|
||||
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
|
||||
// @ts-ignore no types
|
||||
import Picker from 'emoji-mart/dist-es/components/picker/picker';
|
||||
|
||||
export {
|
||||
Picker,
|
||||
Emoji,
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
// A mapping of unicode strings to an object containing the filename
|
||||
// (i.e. the svg filename) and a shortCode intended to be shown
|
||||
// as a "title" attribute in an HTML element (aka tooltip).
|
||||
|
||||
const [
|
||||
shortCodesToEmojiData,
|
||||
skins, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
categories, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
short_names, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
emojisWithoutShortCodes,
|
||||
] = require('./emoji-compressed');
|
||||
const { unicodeToFilename } = require('./unicode-to-filename');
|
||||
|
||||
// decompress
|
||||
const unicodeMapping = {};
|
||||
|
||||
function processEmojiMapData(emojiMapData, shortCode) {
|
||||
const [ native, filename ] = emojiMapData;
|
||||
|
||||
unicodeMapping[native] = {
|
||||
shortCode,
|
||||
filename: filename || unicodeToFilename(native),
|
||||
};
|
||||
}
|
||||
|
||||
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
|
||||
const [ filenameData ] = shortCodesToEmojiData[shortCode];
|
||||
filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
|
||||
});
|
||||
emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
|
||||
|
||||
module.exports = unicodeMapping;
|
|
@ -1,253 +0,0 @@
|
|||
// This code is largely borrowed from:
|
||||
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
|
||||
|
||||
import data from './emoji-mart-data-light';
|
||||
|
||||
const buildSearch = (data) => {
|
||||
const search = [];
|
||||
|
||||
const addToSearch = (strings, split) => {
|
||||
if (!strings) {
|
||||
return;
|
||||
}
|
||||
|
||||
(Array.isArray(strings) ? strings : [strings]).forEach((string) => {
|
||||
(split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
|
||||
s = s.toLowerCase();
|
||||
|
||||
if (!search.includes(s)) {
|
||||
search.push(s);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
addToSearch(data.short_names, true);
|
||||
addToSearch(data.name, true);
|
||||
addToSearch(data.keywords, false);
|
||||
addToSearch(data.emoticons, false);
|
||||
|
||||
return search.join(',');
|
||||
};
|
||||
|
||||
const _String = String;
|
||||
|
||||
const stringFromCodePoint = _String.fromCodePoint || function() {
|
||||
const MAX_SIZE = 0x4000;
|
||||
const codeUnits = [];
|
||||
let highSurrogate;
|
||||
let lowSurrogate;
|
||||
let index = -1;
|
||||
const length = arguments.length;
|
||||
if (!length) {
|
||||
return '';
|
||||
}
|
||||
let result = '';
|
||||
while (++index < length) {
|
||||
let codePoint = Number(arguments[index]);
|
||||
if (
|
||||
!isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
|
||||
codePoint < 0 || // not a valid Unicode code point
|
||||
codePoint > 0x10FFFF || // not a valid Unicode code point
|
||||
Math.floor(codePoint) !== codePoint // not an integer
|
||||
) {
|
||||
throw RangeError('Invalid code point: ' + codePoint);
|
||||
}
|
||||
if (codePoint <= 0xFFFF) { // BMP code point
|
||||
codeUnits.push(codePoint);
|
||||
} else { // Astral code point; split in surrogate halves
|
||||
// http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
|
||||
codePoint -= 0x10000;
|
||||
highSurrogate = (codePoint >> 10) + 0xD800;
|
||||
lowSurrogate = (codePoint % 0x400) + 0xDC00;
|
||||
codeUnits.push(highSurrogate, lowSurrogate);
|
||||
}
|
||||
if (index + 1 === length || codeUnits.length > MAX_SIZE) {
|
||||
result += String.fromCharCode.apply(null, codeUnits);
|
||||
codeUnits.length = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const _JSON = JSON;
|
||||
|
||||
const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
|
||||
const SKINS = [
|
||||
'1F3FA', '1F3FB', '1F3FC',
|
||||
'1F3FD', '1F3FE', '1F3FF',
|
||||
];
|
||||
|
||||
function unifiedToNative(unified) {
|
||||
const unicodes = unified.split('-'),
|
||||
codePoints = unicodes.map((u) => `0x${u}`);
|
||||
|
||||
return stringFromCodePoint.apply(null, codePoints);
|
||||
}
|
||||
|
||||
function sanitize(emoji) {
|
||||
const { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji;
|
||||
const id = emoji.id || short_names[0];
|
||||
const colons = `:${id}:`;
|
||||
|
||||
if (custom) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
colons,
|
||||
emoticons,
|
||||
custom,
|
||||
imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
colons: skin_tone ? `${colons}:skin-tone-${skin_tone}:` : colons,
|
||||
emoticons,
|
||||
unified: unified.toLowerCase(),
|
||||
skin: skin_tone || (skin_variations ? 1 : null),
|
||||
native: unifiedToNative(unified),
|
||||
};
|
||||
}
|
||||
|
||||
function getSanitizedData() {
|
||||
return sanitize(getData(...arguments));
|
||||
}
|
||||
|
||||
function getData(emoji, skin, set) {
|
||||
let emojiData = {};
|
||||
|
||||
if (typeof emoji === 'string') {
|
||||
const matches = emoji.match(COLONS_REGEX);
|
||||
|
||||
if (matches) {
|
||||
emoji = matches[1];
|
||||
|
||||
if (matches[2]) {
|
||||
skin = parseInt(matches[2]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(data.short_names, emoji)) {
|
||||
emoji = data.short_names[emoji];
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(data.emojis, emoji)) {
|
||||
emojiData = data.emojis[emoji];
|
||||
}
|
||||
} else if (emoji.id) {
|
||||
if (Object.prototype.hasOwnProperty.call(data.short_names, emoji.id)) {
|
||||
emoji.id = data.short_names[emoji.id];
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(data.emojis, emoji.id)) {
|
||||
emojiData = data.emojis[emoji.id];
|
||||
skin = skin || emoji.skin;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(emojiData).length) {
|
||||
emojiData = emoji;
|
||||
emojiData.custom = true;
|
||||
|
||||
if (!emojiData.search) {
|
||||
emojiData.search = buildSearch(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
emojiData.emoticons = emojiData.emoticons || [];
|
||||
emojiData.variations = emojiData.variations || [];
|
||||
|
||||
if (emojiData.skin_variations && skin > 1 && set) {
|
||||
emojiData = JSON.parse(_JSON.stringify(emojiData));
|
||||
|
||||
const skinKey = SKINS[skin - 1],
|
||||
variationData = emojiData.skin_variations[skinKey];
|
||||
|
||||
if (!variationData.variations && emojiData.variations) {
|
||||
delete emojiData.variations;
|
||||
}
|
||||
|
||||
if (variationData[`has_img_${set}`]) {
|
||||
emojiData.skin_tone = skin;
|
||||
|
||||
for (const k in variationData) {
|
||||
const v = variationData[k];
|
||||
emojiData[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emojiData.variations && emojiData.variations.length) {
|
||||
emojiData = JSON.parse(_JSON.stringify(emojiData));
|
||||
emojiData.unified = emojiData.variations.shift();
|
||||
}
|
||||
|
||||
return emojiData;
|
||||
}
|
||||
|
||||
function uniq(arr) {
|
||||
return arr.reduce((acc, item) => {
|
||||
if (!acc.includes(item)) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function intersect(a, b) {
|
||||
const uniqA = uniq(a);
|
||||
const uniqB = uniq(b);
|
||||
|
||||
return uniqA.filter(item => uniqB.includes(item));
|
||||
}
|
||||
|
||||
function deepMerge(a, b) {
|
||||
const o = {};
|
||||
|
||||
for (const key in a) {
|
||||
const originalValue = a[key];
|
||||
let value = originalValue;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(b, key)) {
|
||||
value = b[key];
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
value = deepMerge(originalValue, value);
|
||||
}
|
||||
|
||||
o[key] = value;
|
||||
}
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
// https://github.com/sonicdoe/measure-scrollbar
|
||||
function measureScrollbar() {
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.style.width = '100px';
|
||||
div.style.height = '100px';
|
||||
div.style.overflow = 'scroll';
|
||||
div.style.position = 'absolute';
|
||||
div.style.top = '-9999px';
|
||||
|
||||
document.body.appendChild(div);
|
||||
const scrollbarWidth = div.offsetWidth - div.clientWidth;
|
||||
document.body.removeChild(div);
|
||||
|
||||
return scrollbarWidth;
|
||||
}
|
||||
|
||||
export {
|
||||
getData,
|
||||
getSanitizedData,
|
||||
uniq,
|
||||
intersect,
|
||||
deepMerge,
|
||||
unifiedToNative,
|
||||
measureScrollbar,
|
||||
};
|
|
@ -1,132 +0,0 @@
|
|||
import Trie from 'substring-trie';
|
||||
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
import unicodeMapping from './emoji-unicode-mapping-light';
|
||||
|
||||
const trie = new Trie(Object.keys(unicodeMapping));
|
||||
|
||||
const emojifyTextNode = (node, customEmojis, autoPlayGif = false) => {
|
||||
let str = node.textContent;
|
||||
|
||||
const fragment = new DocumentFragment();
|
||||
|
||||
for (;;) {
|
||||
let match, i = 0;
|
||||
|
||||
if (customEmojis === null) {
|
||||
while (i < str.length && !(match = trie.search(str.slice(i)))) {
|
||||
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
||||
}
|
||||
} else {
|
||||
while (i < str.length && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
|
||||
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
let rend, replacement = null;
|
||||
if (i === str.length) {
|
||||
break;
|
||||
} else if (str[i] === ':') {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
if (!(() => {
|
||||
rend = str.indexOf(':', i + 1) + 1;
|
||||
if (!rend) return false; // no pair of ':'
|
||||
const shortname = str.slice(i, rend);
|
||||
// now got a replacee as ':shortname:'
|
||||
// if you want additional emoji handler, add statements below which set replacement and return true.
|
||||
if (shortname in customEmojis) {
|
||||
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
|
||||
replacement = document.createElement('img');
|
||||
replacement.setAttribute('draggable', false);
|
||||
replacement.setAttribute('class', 'emojione custom-emoji');
|
||||
replacement.setAttribute('alt', shortname);
|
||||
replacement.setAttribute('title', shortname);
|
||||
replacement.setAttribute('src', filename);
|
||||
replacement.setAttribute('data-original', customEmojis[shortname].url);
|
||||
replacement.setAttribute('data-static', customEmojis[shortname].static_url);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()) rend = ++i;
|
||||
} else { // matched to unicode emoji
|
||||
const { filename, shortCode } = unicodeMapping[match];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
replacement = document.createElement('img');
|
||||
replacement.setAttribute('draggable', false);
|
||||
replacement.setAttribute('class', 'emojione');
|
||||
replacement.setAttribute('alt', match);
|
||||
replacement.setAttribute('title', title);
|
||||
replacement.setAttribute('src', joinPublicPath(`packs/emoji/${filename}.svg`));
|
||||
rend = i + match.length;
|
||||
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
|
||||
if (str.codePointAt(rend) === 65038) {
|
||||
rend += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fragment.append(document.createTextNode(str.slice(0, i)));
|
||||
if (replacement) {
|
||||
fragment.append(replacement);
|
||||
}
|
||||
node.textContent = str.slice(0, i);
|
||||
str = str.slice(rend);
|
||||
}
|
||||
|
||||
fragment.append(document.createTextNode(str));
|
||||
node.parentElement.replaceChild(fragment, node);
|
||||
};
|
||||
|
||||
const emojifyNode = (node, customEmojis, autoPlayGif = false) => {
|
||||
for (const child of node.childNodes) {
|
||||
switch (child.nodeType) {
|
||||
case Node.TEXT_NODE:
|
||||
emojifyTextNode(child, customEmojis, autoPlayGif);
|
||||
break;
|
||||
case Node.ELEMENT_NODE:
|
||||
if (!child.classList.contains('invisible'))
|
||||
emojifyNode(child, customEmojis, autoPlayGif);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emojify = (str, customEmojis = {}, autoPlayGif = false) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = str;
|
||||
|
||||
if (!Object.keys(customEmojis).length)
|
||||
customEmojis = null;
|
||||
|
||||
emojifyNode(wrapper, customEmojis, autoPlayGif);
|
||||
|
||||
return wrapper.innerHTML;
|
||||
};
|
||||
|
||||
export default emojify;
|
||||
|
||||
export const buildCustomEmojis = (customEmojis, autoPlayGif = false) => {
|
||||
const emojis = [];
|
||||
|
||||
customEmojis.forEach(emoji => {
|
||||
const shortcode = emoji.get('shortcode');
|
||||
const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
|
||||
const name = shortcode.replace(':', '');
|
||||
|
||||
emojis.push({
|
||||
id: name,
|
||||
name,
|
||||
short_names: [name],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: [name],
|
||||
imageUrl: url,
|
||||
custom: true,
|
||||
customCategory: emoji.get('category'),
|
||||
});
|
||||
});
|
||||
|
||||
return emojis;
|
||||
};
|
||||
|
||||
export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set(['custom']));
|
|
@ -0,0 +1,228 @@
|
|||
import split from 'graphemesplit';
|
||||
|
||||
import unicodeMapping from './mapping';
|
||||
|
||||
import type { Emoji as EmojiMart, CustomEmoji as EmojiMartCustom } from 'soapbox/features/emoji/data';
|
||||
|
||||
/*
|
||||
* TODO: Consolate emoji object types
|
||||
*
|
||||
* There are five different emoji objects currently
|
||||
* - emoji-mart's "onPickEmoji" handler
|
||||
* - emoji-mart's custom emoji types
|
||||
* - an Emoji type that is either NativeEmoji or CustomEmoji
|
||||
* - a type inside redux's `store.custom_emoji` immutablejs
|
||||
*
|
||||
* there needs to be one type for the picker handler callback
|
||||
* and one type for the emoji-mart data
|
||||
* and one type that is used everywhere that the above two are converted into
|
||||
*/
|
||||
|
||||
export interface CustomEmoji {
|
||||
id: string
|
||||
colons: string
|
||||
custom: true
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export interface NativeEmoji {
|
||||
id: string
|
||||
colons: string
|
||||
custom?: boolean
|
||||
unified: string
|
||||
native: string
|
||||
}
|
||||
|
||||
export type Emoji = CustomEmoji | NativeEmoji;
|
||||
|
||||
export function isCustomEmoji(emoji: Emoji): emoji is CustomEmoji {
|
||||
return (emoji as CustomEmoji).imageUrl !== undefined;
|
||||
}
|
||||
|
||||
export function isNativeEmoji(emoji: Emoji): emoji is NativeEmoji {
|
||||
return (emoji as NativeEmoji).native !== undefined;
|
||||
}
|
||||
|
||||
const isAlphaNumeric = (c: string) => {
|
||||
const code = c.charCodeAt(0);
|
||||
|
||||
if (!(code > 47 && code < 58) && // numeric (0-9)
|
||||
!(code > 64 && code < 91) && // upper alpha (A-Z)
|
||||
!(code > 96 && code < 123)) { // lower alpha (a-z)
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const validEmojiChar = (c: string) => {
|
||||
return isAlphaNumeric(c)
|
||||
|| c === '_'
|
||||
|| c === '-'
|
||||
|| c === '.';
|
||||
};
|
||||
|
||||
const convertCustom = (shortname: string, filename: string) => {
|
||||
return `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
|
||||
};
|
||||
|
||||
const convertUnicode = (c: string) => {
|
||||
const { unified, shortcode } = unicodeMapping[c];
|
||||
|
||||
return `<img draggable="false" class="emojione" alt="${c}" title=":${shortcode}:" src="/packs/emoji/${unified}.svg" />`;
|
||||
};
|
||||
|
||||
const convertEmoji = (str: string, customEmojis: any) => {
|
||||
if (str.length < 3) return str;
|
||||
if (str in customEmojis) {
|
||||
const emoji = customEmojis[str];
|
||||
const filename = emoji.static_url;
|
||||
|
||||
if (filename?.length > 0) {
|
||||
return convertCustom(str, filename);
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
export const emojifyText = (str: string, customEmojis = {}) => {
|
||||
let buf = '';
|
||||
let stack = '';
|
||||
let open = false;
|
||||
|
||||
const clearStack = () => {
|
||||
buf += stack;
|
||||
open = false;
|
||||
stack = '';
|
||||
};
|
||||
|
||||
for (let c of split(str)) {
|
||||
// convert FE0E selector to FE0F so it can be found in unimap
|
||||
if (c.codePointAt(c.length - 1) === 65038) {
|
||||
c = c.slice(0, -1) + String.fromCodePoint(65039);
|
||||
}
|
||||
|
||||
// unqualified emojis aren't in emoji-mart's mappings so we just add FEOF
|
||||
const unqualified = c + String.fromCodePoint(65039);
|
||||
|
||||
if (c in unicodeMapping) {
|
||||
if (open) { // unicode emoji inside colon
|
||||
clearStack();
|
||||
}
|
||||
|
||||
buf += convertUnicode(c);
|
||||
} else if (unqualified in unicodeMapping) {
|
||||
if (open) { // unicode emoji inside colon
|
||||
clearStack();
|
||||
}
|
||||
|
||||
buf += convertUnicode(unqualified);
|
||||
} else if (c === ':') {
|
||||
stack += ':';
|
||||
|
||||
// we see another : we convert it and clear the stack buffer
|
||||
if (open) {
|
||||
buf += convertEmoji(stack, customEmojis);
|
||||
stack = '';
|
||||
}
|
||||
|
||||
open = !open;
|
||||
} else {
|
||||
if (open) {
|
||||
stack += c;
|
||||
|
||||
// if the stack is non-null and we see invalid chars it's a string not emoji
|
||||
// so we push it to the return result and clear it
|
||||
if (!validEmojiChar(c)) {
|
||||
clearStack();
|
||||
}
|
||||
} else {
|
||||
buf += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// never found a closing colon so it's just a raw string
|
||||
if (open) {
|
||||
buf += stack;
|
||||
}
|
||||
|
||||
return buf;
|
||||
};
|
||||
|
||||
export const parseHTML = (str: string): { text: boolean, data: string }[] => {
|
||||
const tokens = [];
|
||||
let buf = '';
|
||||
let stack = '';
|
||||
let open = false;
|
||||
|
||||
for (const c of str) {
|
||||
if (c === '<') {
|
||||
if (open) {
|
||||
tokens.push({ text: true, data: stack });
|
||||
stack = '<';
|
||||
} else {
|
||||
tokens.push({ text: true, data: buf });
|
||||
stack = '<';
|
||||
open = true;
|
||||
}
|
||||
} else if (c === '>') {
|
||||
if (open) {
|
||||
open = false;
|
||||
tokens.push({ text: false, data: stack + '>' });
|
||||
stack = '';
|
||||
buf = '';
|
||||
} else {
|
||||
buf += '>';
|
||||
}
|
||||
|
||||
} else {
|
||||
if (open) {
|
||||
stack += c;
|
||||
} else {
|
||||
buf += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
tokens.push({ text: true, data: buf + stack });
|
||||
} else if (buf !== '') {
|
||||
tokens.push({ text: true, data: buf });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const emojify = (str: string, customEmojis = {}) => {
|
||||
return parseHTML(str)
|
||||
.map(({ text, data }) => {
|
||||
if (!text) return data;
|
||||
if (data.length === 0 || data === ' ') return data;
|
||||
|
||||
return emojifyText(data, customEmojis);
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
export default emojify;
|
||||
|
||||
export const buildCustomEmojis = (customEmojis: any) => {
|
||||
const emojis: EmojiMart<EmojiMartCustom>[] = [];
|
||||
|
||||
customEmojis.forEach((emoji: any) => {
|
||||
const shortcode = emoji.get('shortcode');
|
||||
const url = emoji.get('static_url');
|
||||
const name = shortcode.replace(':', '');
|
||||
|
||||
emojis.push({
|
||||
id: name,
|
||||
name,
|
||||
keywords: [name],
|
||||
skins: [{ src: url }],
|
||||
});
|
||||
});
|
||||
|
||||
return emojis;
|
||||
};
|
|
@ -0,0 +1,111 @@
|
|||
import data, { EmojiData } from './data';
|
||||
|
||||
const stripLeadingZeros = /^0+/;
|
||||
|
||||
function replaceAll(str: string, find: string, replace: string) {
|
||||
return str.replace(new RegExp(find, 'g'), replace);
|
||||
}
|
||||
|
||||
interface UnicodeMap {
|
||||
[s: string]: {
|
||||
unified: string
|
||||
shortcode: string
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Twemoji strips their hex codes from unicode codepoints to make it look "pretty"
|
||||
* - leading 0s are removed
|
||||
* - fe0f is removed unless it has 200d
|
||||
* - fe0f is NOT removed for 1f441-fe0f-200d-1f5e8-fe0f even though it has a 200d
|
||||
*
|
||||
* this is all wrong
|
||||
*/
|
||||
|
||||
const blacklist = {
|
||||
'1f441-fe0f-200d-1f5e8-fe0f': true,
|
||||
};
|
||||
|
||||
const tweaks = {
|
||||
'#⃣': ['23-20e3', 'hash'],
|
||||
'*⃣': ['2a-20e3', 'keycap_star'],
|
||||
'0⃣': ['30-20e3', 'zero'],
|
||||
'1⃣': ['31-20e3', 'one'],
|
||||
'2⃣': ['32-20e3', 'two'],
|
||||
'3⃣': ['33-20e3', 'three'],
|
||||
'4⃣': ['34-20e3', 'four'],
|
||||
'5⃣': ['35-20e3', 'five'],
|
||||
'6⃣': ['36-20e3', 'six'],
|
||||
'7⃣': ['37-20e3', 'seven'],
|
||||
'8⃣': ['38-20e3', 'eight'],
|
||||
'9⃣': ['39-20e3', 'nine'],
|
||||
'❤🔥': ['2764-fe0f-200d-1f525', 'heart_on_fire'],
|
||||
'❤🩹': ['2764-fe0f-200d-1fa79', 'mending_heart'],
|
||||
'👁🗨️': ['1f441-fe0f-200d-1f5e8-fe0f', 'eye-in-speech-bubble'],
|
||||
'👁️🗨': ['1f441-fe0f-200d-1f5e8-fe0f', 'eye-in-speech-bubble'],
|
||||
'👁🗨': ['1f441-fe0f-200d-1f5e8-fe0f', 'eye-in-speech-bubble'],
|
||||
'🕵♂️': ['1f575-fe0f-200d-2642-fe0f', 'male-detective'],
|
||||
'🕵️♂': ['1f575-fe0f-200d-2642-fe0f', 'male-detective'],
|
||||
'🕵♂': ['1f575-fe0f-200d-2642-fe0f', 'male-detective'],
|
||||
'🕵♀️': ['1f575-fe0f-200d-2640-fe0f', 'female-detective'],
|
||||
'🕵️♀': ['1f575-fe0f-200d-2640-fe0f', 'female-detective'],
|
||||
'🕵♀': ['1f575-fe0f-200d-2640-fe0f', 'female-detective'],
|
||||
'🏌♂️': ['1f3cc-fe0f-200d-2642-fe0f', 'man-golfing'],
|
||||
'🏌️♂': ['1f3cc-fe0f-200d-2642-fe0f', 'man-golfing'],
|
||||
'🏌♂': ['1f3cc-fe0f-200d-2642-fe0f', 'man-golfing'],
|
||||
'🏌♀️': ['1f3cc-fe0f-200d-2640-fe0f', 'woman-golfing'],
|
||||
'🏌️♀': ['1f3cc-fe0f-200d-2640-fe0f', 'woman-golfing'],
|
||||
'🏌♀': ['1f3cc-fe0f-200d-2640-fe0f', 'woman-golfing'],
|
||||
'⛹♂️': ['26f9-fe0f-200d-2642-fe0f', 'man-bouncing-ball'],
|
||||
'⛹️♂': ['26f9-fe0f-200d-2642-fe0f', 'man-bouncing-ball'],
|
||||
'⛹♂': ['26f9-fe0f-200d-2642-fe0f', 'man-bouncing-ball'],
|
||||
'⛹♀️': ['26f9-fe0f-200d-2640-fe0f', 'woman-bouncing-ball'],
|
||||
'⛹️♀': ['26f9-fe0f-200d-2640-fe0f', 'woman-bouncing-ball'],
|
||||
'⛹♀': ['26f9-fe0f-200d-2640-fe0f', 'woman-bouncing-ball'],
|
||||
'🏋♂️': ['1f3cb-fe0f-200d-2642-fe0f', 'man-lifting-weights'],
|
||||
'🏋️♂': ['1f3cb-fe0f-200d-2642-fe0f', 'man-lifting-weights'],
|
||||
'🏋♂': ['1f3cb-fe0f-200d-2642-fe0f', 'man-lifting-weights'],
|
||||
'🏋♀️': ['1f3cb-fe0f-200d-2640-fe0f', 'woman-lifting-weights'],
|
||||
'🏋️♀': ['1f3cb-fe0f-200d-2640-fe0f', 'woman-lifting-weights'],
|
||||
'🏋♀': ['1f3cb-fe0f-200d-2640-fe0f', 'woman-lifting-weights'],
|
||||
'🏳🌈': ['1f3f3-fe0f-200d-1f308', 'rainbow_flag'],
|
||||
'🏳⚧️': ['1f3f3-fe0f-200d-26a7-fe0f', 'transgender_flag'],
|
||||
'🏳️⚧': ['1f3f3-fe0f-200d-26a7-fe0f', 'transgender_flag'],
|
||||
'🏳⚧': ['1f3f3-fe0f-200d-26a7-fe0f', 'transgender_flag'],
|
||||
};
|
||||
|
||||
const stripcodes = (unified: string, native: string) => {
|
||||
const stripped = unified.replace(stripLeadingZeros, '');
|
||||
|
||||
if (unified.includes('200d') && !(unified in blacklist)) {
|
||||
return stripped;
|
||||
} else {
|
||||
return replaceAll(stripped, '-fe0f', '');
|
||||
}
|
||||
};
|
||||
|
||||
export const generateMappings = (data: EmojiData): UnicodeMap => {
|
||||
const result: UnicodeMap = {};
|
||||
const emojis = Object.values(data.emojis ?? {});
|
||||
|
||||
for (const value of emojis) {
|
||||
for (const item of value.skins) {
|
||||
const { unified, native } = item;
|
||||
const stripped = stripcodes(unified, native);
|
||||
|
||||
result[native] = { unified: stripped, shortcode: value.id };
|
||||
}
|
||||
}
|
||||
|
||||
for (const [native, [unified, shortcode]] of Object.entries(tweaks)) {
|
||||
const stripped = stripcodes(unified, native);
|
||||
|
||||
result[native] = { unified: stripped, shortcode };
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const unicodeMapping = generateMappings(data);
|
||||
|
||||
export default unicodeMapping;
|
|
@ -0,0 +1,65 @@
|
|||
import { Index } from 'flexsearch';
|
||||
|
||||
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,
|
||||
context: true,
|
||||
});
|
||||
|
||||
for (const [key, emoji] of Object.entries(data.emojis)) {
|
||||
index.add('n' + key, emoji.name);
|
||||
}
|
||||
|
||||
export interface searchOptions {
|
||||
maxResults?: number
|
||||
custom?: any
|
||||
}
|
||||
|
||||
export const addCustomToPool = (customEmojis: any[]) => {
|
||||
// @ts-ignore
|
||||
for (const key in index.register) {
|
||||
if (key[0] === 'c') {
|
||||
index.remove(key); // remove old custom emojis
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (const emoji of customEmojis) {
|
||||
index.add('c' + i++, emoji.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 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[] => {
|
||||
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();
|
||||
|
||||
return {
|
||||
id: shortcode,
|
||||
colons: ':' + shortcode + ':',
|
||||
custom: true,
|
||||
imageUrl: static_url,
|
||||
};
|
||||
}
|
||||
|
||||
const { skins } = data.emojis[(id as string).slice(1)];
|
||||
|
||||
return {
|
||||
id: (id as string).slice(1),
|
||||
colons: ':' + id.slice(1) + ':',
|
||||
unified: skins[0].unified,
|
||||
native: skins[0].native,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default search;
|
|
@ -1,26 +0,0 @@
|
|||
// taken from:
|
||||
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
|
||||
exports.unicodeToFilename = (str) => {
|
||||
let result = '';
|
||||
let charCode = 0;
|
||||
let p = 0;
|
||||
let i = 0;
|
||||
while (i < str.length) {
|
||||
charCode = str.charCodeAt(i++);
|
||||
if (p) {
|
||||
if (result.length > 0) {
|
||||
result += '-';
|
||||
}
|
||||
result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
|
||||
p = 0;
|
||||
} else if (0xD800 <= charCode && charCode <= 0xDBFF) {
|
||||
p = charCode;
|
||||
} else {
|
||||
if (result.length > 0) {
|
||||
result += '-';
|
||||
}
|
||||
result += charCode.toString(16);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
|
@ -1,21 +0,0 @@
|
|||
function padLeft(str, num) {
|
||||
while (str.length < num) {
|
||||
str = '0' + str;
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
exports.unicodeToUnifiedName = (str) => {
|
||||
let output = '';
|
||||
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
if (i > 0) {
|
||||
output += '-';
|
||||
}
|
||||
|
||||
output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
|
@ -0,0 +1,130 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupActionButton from '../group-action-button';
|
||||
|
||||
let group: Group;
|
||||
|
||||
describe('<GroupActionButton />', () => {
|
||||
describe('with no group relationship', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a private group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', true);
|
||||
});
|
||||
|
||||
it('should render the Request Access button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Request Access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a public group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', false);
|
||||
});
|
||||
|
||||
it('should render the Join Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Join Group');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no group relationship member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
member: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a private group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', true);
|
||||
});
|
||||
|
||||
it('should render the Request Access button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Request Access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a public group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', false);
|
||||
});
|
||||
|
||||
it('should render the Join Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Join Group');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user has requested to join', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
requested: true,
|
||||
member: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Cancel Request button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Cancel Request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user is an Admin', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: 'admin',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Manage Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Manage Group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user is just a member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: 'user',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Leave Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Leave Group');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupMemberCount from '../group-member-count';
|
||||
|
||||
let group: Group;
|
||||
|
||||
describe('<GroupMemberCount />', () => {
|
||||
describe('without support for "members_count"', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.queryAllByTestId('group-member-count')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with support for "members_count"', () => {
|
||||
describe('with 1 member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-member-count').textContent).toEqual('1 member');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with 2 members', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-member-count').textContent).toEqual('2 members');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with 1000 members', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-member-count').textContent).toEqual('1k members');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupPrivacy from '../group-privacy';
|
||||
|
||||
let group: Group;
|
||||
|
||||
describe('<GroupPrivacy />', () => {
|
||||
describe('with a Private group', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
locked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the correct text', () => {
|
||||
render(<GroupPrivacy group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-privacy')).toHaveTextContent('Private');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a Public group', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the correct text', () => {
|
||||
render(<GroupPrivacy group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-privacy')).toHaveTextContent('Public');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,83 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupActionButton {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
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' },
|
||||
});
|
||||
|
||||
const GroupActionButton = ({ group }: IGroupActionButton) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const isNonMember = !group.relationship || !group.relationship.member;
|
||||
const isRequested = group.relationship?.requested;
|
||||
const isAdmin = group.relationship?.role === 'admin';
|
||||
|
||||
const onJoinGroup = () => dispatch(joinGroup(group.id));
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
message: intl.formatMessage(messages.confirmationMessage),
|
||||
confirm: intl.formatMessage(messages.confirmationConfirm),
|
||||
onConfirm: () => dispatch(leaveGroup(group.id)),
|
||||
}));
|
||||
|
||||
if (isNonMember) {
|
||||
return (
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={onJoinGroup}
|
||||
>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRequested) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel Request' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
to={`/groups/${group.id}/manage`}
|
||||
>
|
||||
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.leave' defaultMessage='Leave Group' />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupActionButton;
|
|
@ -1,22 +1,23 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
import { isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
|
||||
import GroupActionButton from './group-action-button';
|
||||
import GroupMemberCount from './group-member-count';
|
||||
import GroupPrivacy from './group-privacy';
|
||||
import GroupRelationship from './group-relationship';
|
||||
|
||||
import type { Group } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
|
||||
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
|
||||
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
|
||||
});
|
||||
|
||||
interface IGroupHeader {
|
||||
|
@ -47,16 +48,6 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
);
|
||||
}
|
||||
|
||||
const onJoinGroup = () => dispatch(joinGroup(group.id));
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
message: intl.formatMessage(messages.confirmationMessage),
|
||||
confirm: intl.formatMessage(messages.confirmationConfirm),
|
||||
onConfirm: () => dispatch(leaveGroup(group.id)),
|
||||
}));
|
||||
|
||||
const onAvatarClick = () => {
|
||||
const avatar = normalizeAttachment({
|
||||
type: 'image',
|
||||
|
@ -95,6 +86,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
<StillImage
|
||||
src={group.header}
|
||||
alt={intl.formatMessage(messages.header)}
|
||||
className='h-32 w-full bg-gray-200 object-center dark:bg-gray-900/50 md:rounded-t-xl lg:h-52'
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -110,93 +102,40 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
return header;
|
||||
};
|
||||
|
||||
const makeActionButton = () => {
|
||||
if (!group.relationship || !group.relationship.member) {
|
||||
return (
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={onJoinGroup}
|
||||
>
|
||||
{group.locked ? <FormattedMessage id='group.request_join' defaultMessage='Request to join group' /> : <FormattedMessage id='group.join' defaultMessage='Join group' />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.relationship.requested) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel request' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.relationship?.role === 'admin') {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
to={`/groups/${group.id}/manage`}
|
||||
>
|
||||
<FormattedMessage id='group.manage' defaultMessage='Manage group' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.leave' defaultMessage='Leave group' />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const actionButton = makeActionButton();
|
||||
|
||||
return (
|
||||
<div className='-mx-4 -mt-4'>
|
||||
<div className='relative'>
|
||||
<div className='relative isolate flex h-32 w-full flex-col justify-center overflow-hidden bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-[200px]'>
|
||||
{renderHeader()}
|
||||
</div>
|
||||
{renderHeader()}
|
||||
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} />
|
||||
<Avatar
|
||||
className='ring-[3px] ring-white dark:ring-primary-900'
|
||||
src={group.avatar}
|
||||
size={80}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack className='p-3 pt-12' alignItems='center' space={2}>
|
||||
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
{actionButton}
|
||||
<Stack alignItems='center' space={3} className='mt-10 py-4'>
|
||||
<Text
|
||||
size='xl'
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>
|
||||
|
||||
<Stack space={1}>
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
|
||||
<GroupRelationship group={group} />
|
||||
<GroupPrivacy group={group} />
|
||||
<GroupMemberCount group={group} />
|
||||
</HStack>
|
||||
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
</Stack>
|
||||
|
||||
<GroupActionButton group={group} />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
interface IGroupMemberCount {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const GroupMemberCount = ({ group }: IGroupMemberCount) => {
|
||||
if (typeof group.members_count === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium' data-testid='group-member-count'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.member_count'
|
||||
defaultMessage='{members, plural, one {member} other {members}}'
|
||||
values={{
|
||||
members: group.members_count,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMemberCount;
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupPolicy {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const GroupPrivacy = ({ group }: IGroupPolicy) => (
|
||||
<HStack space={1} alignItems='center' data-testid='group-privacy'>
|
||||
<Icon
|
||||
className='h-4 w-4'
|
||||
src={
|
||||
group.locked
|
||||
? require('@tabler/icons/lock.svg')
|
||||
: require('@tabler/icons/world.svg')
|
||||
}
|
||||
/>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
export default GroupPrivacy;
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupRelationship {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const GroupRelationship = ({ group }: IGroupRelationship) => {
|
||||
const isAdmin = group.relationship?.role === 'admin';
|
||||
const isModerator = group.relationship?.role === 'moderator';
|
||||
|
||||
if (!isAdmin || !isModerator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon
|
||||
className='h-4 w-4'
|
||||
src={
|
||||
isAdmin
|
||||
? require('@tabler/icons/users.svg')
|
||||
: require('@tabler/icons/gavel.svg')
|
||||
}
|
||||
/>
|
||||
|
||||
<Text tag='span' weight='medium' size='sm' theme='inherit'>
|
||||
{isAdmin
|
||||
? <FormattedMessage id='group.role.admin' defaultMessage='Admin' />
|
||||
: <FormattedMessage id='group.role.moderator' defaultMessage='Moderator' />}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupRelationship;
|
|
@ -3,7 +3,6 @@ import { FormattedMessage } from 'react-intl';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { groupCompose } from 'soapbox/actions/compose';
|
||||
import { fetchGroup } from 'soapbox/actions/groups';
|
||||
import { connectGroupStream } from 'soapbox/actions/streaming';
|
||||
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
||||
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
|
||||
|
@ -31,7 +30,6 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
dispatch(expandGroupTimeline(groupId));
|
||||
|
||||
dispatch(groupCompose(`group:${groupId}`, groupId));
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar, 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 { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroup {
|
||||
group: GroupEntity
|
||||
width?: number
|
||||
}
|
||||
|
||||
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||
const { group, width = 'auto' } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
<Link to={`/groups/${group.id}`}>
|
||||
<Stack
|
||||
className='aspect-w-10 aspect-h-7 h-full w-full overflow-hidden rounded-lg'
|
||||
ref={ref}
|
||||
style={{ minHeight: 180 }}
|
||||
>
|
||||
{group.header && (
|
||||
<img
|
||||
src={group.header}
|
||||
alt='Group cover'
|
||||
className='absolute inset-0 object-cover'
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack justifyContent='end' className='z-10 p-4 text-white' space={3}>
|
||||
<Avatar
|
||||
className='ring-2 ring-white'
|
||||
src={group.avatar}
|
||||
size={44}
|
||||
/>
|
||||
|
||||
<Stack space={1}>
|
||||
<Text
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
theme='inherit'
|
||||
truncate
|
||||
/>
|
||||
|
||||
<HStack alignItems='center' space={1}>
|
||||
<GroupPrivacy group={group} />
|
||||
<span>•</span>
|
||||
<GroupMemberCount group={group} />
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<div
|
||||
className='absolute inset-x-0 bottom-0 z-0 flex justify-center rounded-b-lg bg-gradient-to-t from-gray-900 to-transparent pt-12 pb-8 transition-opacity duration-500'
|
||||
/>
|
||||
</Stack>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
theme='primary'
|
||||
block
|
||||
>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Group;
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
||||
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||
import { usePopularGroups } from 'soapbox/queries/groups';
|
||||
|
||||
import Group from './group';
|
||||
|
||||
const PopularGroups = () => {
|
||||
const { groups, isFetching } = usePopularGroups();
|
||||
|
||||
const [groupCover, setGroupCover] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Text size='xl' weight='bold'>
|
||||
Popular Groups
|
||||
</Text>
|
||||
|
||||
<Carousel
|
||||
itemWidth={250}
|
||||
itemCount={groups.length}
|
||||
controlsHeight={groupCover?.clientHeight}
|
||||
>
|
||||
{({ width }: { width: number }) => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{ width: width || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderGroupDiscover />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Group
|
||||
key={group.id}
|
||||
group={group}
|
||||
width={width}
|
||||
ref={setGroupCover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularGroups;
|
|
@ -0,0 +1,79 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
|
||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
import { groupSearchHistory } from 'soapbox/settings';
|
||||
import { clearRecentGroupSearches, saveGroupSearch } from 'soapbox/utils/groups';
|
||||
|
||||
import RecentSearches from '../recent-searches';
|
||||
|
||||
const userId = '1';
|
||||
const store = {
|
||||
me: userId,
|
||||
accounts: ImmutableMap({
|
||||
[userId]: normalizeAccount({
|
||||
id: userId,
|
||||
acct: 'justin-username',
|
||||
display_name: 'Justin L',
|
||||
avatar: 'test.jpg',
|
||||
chats_onboarded: false,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const renderApp = (children: React.ReactNode) => (
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
|
||||
{children}
|
||||
</VirtuosoMockContext.Provider>,
|
||||
undefined,
|
||||
store,
|
||||
)
|
||||
);
|
||||
|
||||
describe('<RecentSearches />', () => {
|
||||
describe('with recent searches', () => {
|
||||
beforeEach(() => {
|
||||
saveGroupSearch(userId, 'foobar');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRecentGroupSearches(userId);
|
||||
});
|
||||
|
||||
it('should render the recent searches', async () => {
|
||||
renderApp(<RecentSearches onSelect={jest.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('recent-search')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should support clearing recent searches', async () => {
|
||||
renderApp(<RecentSearches onSelect={jest.fn()} />);
|
||||
|
||||
expect(groupSearchHistory.get(userId)).toHaveLength(1);
|
||||
await userEvent.click(screen.getByTestId('clear-recent-searches'));
|
||||
expect(groupSearchHistory.get(userId)).toBeNull();
|
||||
});
|
||||
|
||||
it('should support click events on the results', async () => {
|
||||
const handler = jest.fn();
|
||||
renderApp(<RecentSearches onSelect={handler} />);
|
||||
expect(handler.mock.calls.length).toEqual(0);
|
||||
await userEvent.click(screen.getByTestId('recent-search-result'));
|
||||
expect(handler.mock.calls.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without recent searches', () => {
|
||||
it('should render the blankslate', async () => {
|
||||
renderApp(<RecentSearches onSelect={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('recent-searches-blankslate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup, normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import Search from '../search';
|
||||
|
||||
const store = {
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
};
|
||||
|
||||
const renderApp = (children: React.ReactElement) => render(children, undefined, store);
|
||||
|
||||
describe('<Search />', () => {
|
||||
describe('with no results', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups/search').reply(200, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the blankslate', async () => {
|
||||
renderApp(<Search searchValue={'some-search'} onSelect={jest.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with results', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups/search').reply(200, [
|
||||
normalizeGroup({
|
||||
display_name: 'Group',
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the results', async () => {
|
||||
renderApp(<Search searchValue={'some-search'} onSelect={jest.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('before starting a search', () => {
|
||||
it('should render the RecentSearches component', () => {
|
||||
renderApp(<Search searchValue={''} onSelect={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('recent-searches')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
export default () => (
|
||||
<Stack space={2} className='px-4 py-2' data-testid='no-results'>
|
||||
<Text weight='bold' size='lg'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.no_results.title'
|
||||
defaultMessage='No matches found'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.no_results.subtitle'
|
||||
defaultMessage='Try searching for another group.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useOwnAccount } from 'soapbox/hooks';
|
||||
import { groupSearchHistory } from 'soapbox/settings';
|
||||
import { clearRecentGroupSearches } from 'soapbox/utils/groups';
|
||||
|
||||
interface Props {
|
||||
onSelect(value: string): void
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const { onSelect } = props;
|
||||
|
||||
const me = useOwnAccount();
|
||||
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(groupSearchHistory.get(me?.id as string) || []);
|
||||
|
||||
const onClearRecentSearches = () => {
|
||||
clearRecentGroupSearches(me?.id as string);
|
||||
setRecentSearches([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space={2} data-testid='recent-searches'>
|
||||
{recentSearches.length > 0 ? (
|
||||
<>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='between'
|
||||
className='bg-white dark:bg-gray-900'
|
||||
>
|
||||
<Text theme='muted' weight='semibold' size='sm'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.recent_searches.title'
|
||||
defaultMessage='Recent searches'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<button onClick={onClearRecentSearches} data-testid='clear-recent-searches'>
|
||||
<Text theme='primary' size='sm' className='hover:underline'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.recent_searches.clear_all'
|
||||
defaultMessage='Clear all'
|
||||
/>
|
||||
</Text>
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={recentSearches}
|
||||
itemContent={(_index, recentSearch) => (
|
||||
<div key={recentSearch} data-testid='recent-search'>
|
||||
<button
|
||||
onClick={() => onSelect(recentSearch)}
|
||||
className='group flex w-full flex-col rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
data-testid='recent-search-result'
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-gray-200 p-2 dark:bg-gray-800 dark:group-hover:bg-gray-700/20'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/hash.svg')}
|
||||
className='h-5 w-5 text-gray-600'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text weight='bold' size='sm' align='left'>{recentSearch}</Text>
|
||||
</HStack>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Stack space={2} data-testid='recent-searches-blankslate'>
|
||||
<Text weight='bold' size='lg'>
|
||||
<FormattedMessage id='groups.discover.search.recent_searches.blankslate.title' defaultMessage='No recent searches' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage id='groups.discover.search.recent_searches.blankslate.subtitle' defaultMessage='Search group names, topics or keywords' />
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,169 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||
|
||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useGroupSearch } from 'soapbox/queries/groups/search';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import GroupComp from '../group';
|
||||
|
||||
interface Props {
|
||||
groupSearchResult: ReturnType<typeof useGroupSearch>
|
||||
}
|
||||
|
||||
enum Layout {
|
||||
LIST = 'LIST',
|
||||
GRID = 'GRID'
|
||||
}
|
||||
|
||||
const GridList: Components['List'] = React.forwardRef((props, ref) => {
|
||||
const { context, ...rest } = props;
|
||||
return <div ref={ref} {...rest} className='flex flex-wrap' />;
|
||||
});
|
||||
|
||||
export default (props: Props) => {
|
||||
const { groupSearchResult } = props;
|
||||
|
||||
const [layout, setLayout] = useState<Layout>(Layout.LIST);
|
||||
|
||||
const { groups, hasNextPage, isFetching, fetchNextPage } = groupSearchResult;
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroupList = useCallback((group: Group, index: number) => (
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='between'
|
||||
className={
|
||||
clsx({
|
||||
'pt-4': index !== 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Avatar
|
||||
className='ring-2 ring-white dark:ring-primary-900'
|
||||
src={group.avatar}
|
||||
size={44}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<Text
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
||||
<Icon
|
||||
className='h-4.5 w-4.5'
|
||||
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')}
|
||||
/>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{typeof group.members_count !== 'undefined' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.member_count'
|
||||
defaultMessage='{members, plural, one {member} other {members}}'
|
||||
values={{
|
||||
members: group.members_count,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<Button theme='primary'>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||
</Button>
|
||||
</HStack>
|
||||
), []);
|
||||
|
||||
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
||||
<div className='pb-4'>
|
||||
<GroupComp group={group} />
|
||||
</div>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<Stack space={4} data-testid='results'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Text weight='semibold'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.groups'
|
||||
defaultMessage='Groups'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<HStack alignItems='center'>
|
||||
<button onClick={() => setLayout(Layout.LIST)}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/layout-list.svg')}
|
||||
className={
|
||||
clsx('h-5 w-5 text-gray-600', {
|
||||
'text-primary-600': layout === Layout.LIST,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button onClick={() => setLayout(Layout.GRID)}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/layout-grid.svg')}
|
||||
className={
|
||||
clsx('h-5 w-5 text-gray-600', {
|
||||
'text-primary-600': layout === Layout.GRID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{layout === Layout.LIST ? (
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={groups}
|
||||
itemContent={(index, group) => renderGroupList(group, index)}
|
||||
endReached={handleLoadMore}
|
||||
/>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
useWindowScroll
|
||||
data={groups}
|
||||
itemContent={(index, group) => renderGroupGrid(group, index)}
|
||||
components={{
|
||||
Item: (props) => (
|
||||
<div {...props} className='w-1/2 flex-none' />
|
||||
),
|
||||
List: GridList,
|
||||
}}
|
||||
endReached={handleLoadMore}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,64 @@
|
|||
import React, { useEffect } from 'react';
|
||||
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
|
||||
import { useDebounce, useOwnAccount } from 'soapbox/hooks';
|
||||
import { useGroupSearch } from 'soapbox/queries/groups/search';
|
||||
import { saveGroupSearch } from 'soapbox/utils/groups';
|
||||
|
||||
import NoResultsBlankslate from './no-results-blankslate';
|
||||
import RecentSearches from './recent-searches';
|
||||
import Results from './results';
|
||||
|
||||
interface Props {
|
||||
onSelect(value: string): void
|
||||
searchValue: string
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const { onSelect, searchValue } = props;
|
||||
|
||||
const me = useOwnAccount();
|
||||
const debounce = useDebounce;
|
||||
|
||||
const debouncedValue = debounce(searchValue as string, 300);
|
||||
const debouncedValueToSave = debounce(searchValue as string, 1000);
|
||||
|
||||
const groupSearchResult = useGroupSearch(debouncedValue);
|
||||
const { groups, isFetching, isFetched } = groupSearchResult;
|
||||
|
||||
const hasSearchResults = isFetched && groups.length > 0;
|
||||
const hasNoSearchResults = isFetched && groups.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedValueToSave && debouncedValueToSave.length >= 0) {
|
||||
saveGroupSearch(me?.id as string, debouncedValueToSave);
|
||||
}
|
||||
}, [debouncedValueToSave]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<PlaceholderGroupSearch />
|
||||
<PlaceholderGroupSearch />
|
||||
<PlaceholderGroupSearch />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNoSearchResults) {
|
||||
return <NoResultsBlankslate />;
|
||||
}
|
||||
|
||||
if (hasSearchResults) {
|
||||
return (
|
||||
<Results
|
||||
groupSearchResult={groupSearchResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RecentSearches onSelect={onSelect} />
|
||||
);
|
||||
};
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
||||
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||
import { useSuggestedGroups } from 'soapbox/queries/groups';
|
||||
|
||||
import Group from './group';
|
||||
|
||||
const SuggestedGroups = () => {
|
||||
const { groups, isFetching } = useSuggestedGroups();
|
||||
|
||||
const [groupCover, setGroupCover] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Text size='xl' weight='bold'>
|
||||
Suggested For You
|
||||
</Text>
|
||||
|
||||
<Carousel
|
||||
itemWidth={250}
|
||||
itemCount={groups.length}
|
||||
controlsHeight={groupCover?.clientHeight}
|
||||
>
|
||||
{({ width }: { width: number }) => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{ width: width || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderGroupDiscover />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Group
|
||||
key={group.id}
|
||||
group={group}
|
||||
width={width}
|
||||
ref={setGroupCover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuggestedGroups;
|
|
@ -0,0 +1,41 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Tabs } from 'soapbox/components/ui';
|
||||
|
||||
import type { Item } from 'soapbox/components/ui/tabs/tabs';
|
||||
|
||||
export enum TabItems {
|
||||
MY_GROUPS = 'MY_GROUPS',
|
||||
FIND_GROUPS = 'FIND_GROUPS'
|
||||
}
|
||||
|
||||
interface ITabBar {
|
||||
activeTab: TabItems
|
||||
}
|
||||
|
||||
const TabBar = ({ activeTab }: ITabBar) => {
|
||||
const history = useHistory();
|
||||
|
||||
const tabItems: Item[] = useMemo(() => ([
|
||||
{
|
||||
text: 'My Groups',
|
||||
action: () => history.push('/groups'),
|
||||
name: TabItems.MY_GROUPS,
|
||||
},
|
||||
{
|
||||
text: 'Find Groups',
|
||||
action: () => history.push('/groups/discover'),
|
||||
name: TabItems.FIND_GROUPS,
|
||||
},
|
||||
]), []);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
activeItem={activeTab}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabBar;
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui';
|
||||
|
||||
import PopularGroups from './components/discover/popular-groups';
|
||||
import Search from './components/discover/search/search';
|
||||
import SuggestedGroups from './components/discover/suggested-groups';
|
||||
import TabBar, { TabItems } from './components/tab-bar';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'groups.discover.search.placeholder', defaultMessage: 'Search' },
|
||||
});
|
||||
|
||||
const Discover: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||
const [value, setValue] = useState<string>('');
|
||||
|
||||
const hasSearchValue = value && value.length > 0;
|
||||
|
||||
const cancelSearch = () => {
|
||||
clearValue();
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const clearValue = () => setValue('');
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<TabBar activeTab={TabItems.FIND_GROUPS} />
|
||||
|
||||
<Stack space={6}>
|
||||
<HStack alignItems='center'>
|
||||
{isSearching ? (
|
||||
<IconButton
|
||||
src={require('@tabler/icons/arrow-left.svg')}
|
||||
iconClassName='mr-2 h-5 w-5 fill-current text-gray-600'
|
||||
onClick={cancelSearch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
data-testid='search'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onFocus={() => setIsSearching(true)}
|
||||
outerClassName='mt-0 w-full'
|
||||
theme='search'
|
||||
append={
|
||||
<button onClick={clearValue}>
|
||||
<Icon
|
||||
src={hasSearchValue ? require('@tabler/icons/x.svg') : require('@tabler/icons/search.svg')}
|
||||
className='h-4 w-4 text-gray-700 dark:text-gray-600'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{isSearching ? (
|
||||
<Search
|
||||
searchValue={value}
|
||||
onSelect={(newValue) => setValue(newValue)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PopularGroups />
|
||||
<SuggestedGroups />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Discover;
|
|
@ -1,78 +1,65 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { fetchGroups } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { Button, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { useGroups } from 'soapbox/queries/groups';
|
||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||
|
||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import TabBar, { TabItems } from './components/tab-bar';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const getOrderedGroups = createSelector([
|
||||
(state: RootState) => state.groups.items,
|
||||
(state: RootState) => state.groups.isLoading,
|
||||
(state: RootState) => state.group_relationships,
|
||||
], (groups, isLoading, group_relationships) => ({
|
||||
groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||
.map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||
.filter((item) => item.relationship?.member)
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||
isLoading,
|
||||
}));
|
||||
// const getOrderedGroups = createSelector([
|
||||
// (state: RootState) => state.groups.items,
|
||||
// (state: RootState) => state.group_relationships,
|
||||
// ], (groups, group_relationships) => ({
|
||||
// groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||
// .map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||
// .filter((item) => item.relationship?.member)
|
||||
// .sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||
// }));
|
||||
|
||||
const EmptyMessage = () => (
|
||||
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const Groups: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const { groups, isLoading } = useAppSelector((state) => getOrderedGroups(state));
|
||||
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroups());
|
||||
}, []);
|
||||
const { groups, isLoading } = useGroups();
|
||||
|
||||
const createGroup = () => {
|
||||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
if (!groups) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = (
|
||||
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className='gap-4'>
|
||||
<Stack space={4}>
|
||||
{canCreateGroup && (
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end xl:hidden'
|
||||
|
@ -81,15 +68,20 @@ const Groups: React.FC = () => {
|
|||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create Group' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{features.groupsDiscovery && (
|
||||
<TabBar activeTab={TabItems.MY_GROUPS} />
|
||||
)}
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='groups'
|
||||
emptyMessage={emptyMessage}
|
||||
emptyMessage={<EmptyMessage />}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !groups.count()}
|
||||
showLoading={isLoading && groups.length === 0}
|
||||
placeholderComponent={PlaceholderGroupCard}
|
||||
placeholderCount={3}
|
||||
>
|
||||
|
|
|
@ -5,23 +5,26 @@ import { HStack, Stack, Text } from 'soapbox/components/ui';
|
|||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderGroupCard = () => {
|
||||
const groupNameLength = randomIntFromInterval(5, 25);
|
||||
const roleLength = randomIntFromInterval(5, 15);
|
||||
const privacyLength = randomIntFromInterval(5, 15);
|
||||
const groupNameLength = randomIntFromInterval(12, 20);
|
||||
|
||||
return (
|
||||
<div className='animate-pulse overflow-hidden'>
|
||||
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
|
||||
<div className='relative m-[-1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<div className='h-16 w-16 rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900' />
|
||||
</div>
|
||||
<div className='animate-pulse'>
|
||||
<Stack className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'>
|
||||
{/* Group Cover Image */}
|
||||
<div className='relative grow basis-1/2 rounded-t-lg bg-gray-300 dark:bg-gray-800' />
|
||||
|
||||
{/* Group Avatar */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<div className='h-16 w-16 rounded-full bg-gray-500 ring-2 ring-white dark:bg-primary-800 dark:ring-primary-900' />
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold'>{generateText(groupNameLength)}</Text>
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
<span>{generateText(roleLength)}</span>
|
||||
<span>{generateText(privacyLength)}</span>
|
||||
|
||||
{/* Group Info */}
|
||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||
<Text size='lg' theme='subtle' weight='bold'>{generateText(groupNameLength)}</Text>
|
||||
|
||||
<HStack className='text-gray-400 dark:text-gray-600' space={3} wrap>
|
||||
<span>{generateText(6)}</span>
|
||||
<span>{generateText(6)}</span>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderGroupDiscover = () => {
|
||||
const groupNameLength = randomIntFromInterval(12, 20);
|
||||
|
||||
return (
|
||||
<Stack space={2} className='animate-pulse'>
|
||||
<Stack className='aspect-w-10 aspect-h-7 h-full w-full overflow-hidden rounded-lg'>
|
||||
{/* Group Cover Image */}
|
||||
<div className='absolute inset-0 rounded-t-lg bg-gray-300 object-cover dark:bg-gray-800' />
|
||||
|
||||
<Stack justifyContent='end' className='z-10 p-4 text-white' space={3}>
|
||||
{/* Group Avatar */}
|
||||
<div className='h-11 w-11 rounded-full bg-gray-500 dark:bg-gray-700 dark:ring-primary-900' />
|
||||
|
||||
{/* Group Info */}
|
||||
<Stack space={1} className='text-gray-500 dark:text-gray-700'>
|
||||
<Text theme='inherit' weight='bold' truncate>{generateText(groupNameLength)}</Text>
|
||||
|
||||
<HStack space={3} wrap>
|
||||
<Text tag='span' theme='inherit'>{generateText(6)}</Text>
|
||||
<Text tag='span' theme='inherit'>{generateText(6)}</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Join Group Button */}
|
||||
<div className='h-10 w-full rounded-full bg-gray-300 dark:bg-gray-800' />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderGroupDiscover;
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
export default () => {
|
||||
const groupNameLength = randomIntFromInterval(12, 20);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='between'
|
||||
className='animate-pulse'
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
{/* Group Avatar */}
|
||||
<div className='h-11 w-11 rounded-full bg-gray-500 dark:bg-gray-700 dark:ring-primary-900' />
|
||||
|
||||
<Stack className='text-gray-500 dark:text-gray-700'>
|
||||
<Text theme='inherit' weight='bold'>
|
||||
{generateText(groupNameLength)}
|
||||
</Text>
|
||||
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{generateText(6)}
|
||||
</Text>
|
||||
|
||||
<span>•</span>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{generateText(6)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
{/* Join Group Button */}
|
||||
<div className='h-10 w-36 rounded-full bg-gray-300 dark:bg-gray-800' />
|
||||
</HStack>
|
||||
);
|
||||
};
|
|
@ -13,10 +13,10 @@ const messages = defineMessages({
|
|||
|
||||
interface IIconPickerDropdown {
|
||||
value: string
|
||||
onPickEmoji: React.ChangeEventHandler
|
||||
onPickIcon: (icon: string) => void
|
||||
}
|
||||
|
||||
const IconPickerDropdown: React.FC<IIconPickerDropdown> = ({ value, onPickEmoji }) => {
|
||||
const IconPickerDropdown: React.FC<IIconPickerDropdown> = ({ value, onPickIcon }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
@ -73,9 +73,9 @@ const IconPickerDropdown: React.FC<IIconPickerDropdown> = ({ value, onPickEmoji
|
|||
|
||||
<Overlay show={active} placement={placement} target={target.current}>
|
||||
<IconPickerMenu
|
||||
customEmojis={forkAwesomeIcons}
|
||||
icons={forkAwesomeIcons}
|
||||
onClose={onHideDropdown}
|
||||
onPick={onPickEmoji}
|
||||
onPick={onPickIcon}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
|
|
|
@ -1,31 +1,25 @@
|
|||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
// @ts-ignore
|
||||
import Picker from 'emoji-mart/dist-es/components/picker/picker';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'icon_button.label', defaultMessage: 'Select icon' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
|
||||
emoji_not_found: { id: 'icon_button.not_found', defaultMessage: 'No icons!! (╯°□°)╯︵ ┻━┻' },
|
||||
custom: { id: 'icon_button.icons', defaultMessage: 'Icons' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
});
|
||||
|
||||
const backgroundImageFn = () => '';
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
const categoriesSort = ['custom'];
|
||||
|
||||
interface IIconPickerMenu {
|
||||
customEmojis: Record<string, Array<string>>
|
||||
icons: Record<string, Array<string>>
|
||||
onClose: () => void
|
||||
onPick: any
|
||||
onPick: (icon: string) => void
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const IconPickerMenu: React.FC<IIconPickerMenu> = ({ customEmojis, onClose, onPick, style }) => {
|
||||
const IconPickerMenu: React.FC<IIconPickerMenu> = ({ icons, onClose, onPick, style }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const node = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -60,70 +54,42 @@ const IconPickerMenu: React.FC<IIconPickerMenu> = ({ customEmojis, onClose, onPi
|
|||
});
|
||||
};
|
||||
|
||||
const getI18n = () => {
|
||||
|
||||
return {
|
||||
search: intl.formatMessage(messages.emoji_search),
|
||||
notfound: intl.formatMessage(messages.emoji_not_found),
|
||||
categories: {
|
||||
search: intl.formatMessage(messages.search_results),
|
||||
custom: intl.formatMessage(messages.custom),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleClick = (emoji: Record<string, any>) => {
|
||||
emoji.native = emoji.colons;
|
||||
|
||||
const handleClick = (icon: string) => {
|
||||
onClose();
|
||||
onPick(emoji);
|
||||
onPick(icon);
|
||||
};
|
||||
|
||||
const buildIcons = () => {
|
||||
const emojis: Record<string, any> = [];
|
||||
const renderIcon = (icon: string) => {
|
||||
const name = icon.replace('fa fa-', '');
|
||||
|
||||
Object.values(customEmojis).forEach((category) => {
|
||||
category.forEach((icon) => {
|
||||
const name = icon.replace('fa fa-', '');
|
||||
if (icon !== 'email' && icon !== 'memo') {
|
||||
emojis.push({
|
||||
id: name,
|
||||
name,
|
||||
short_names: [name],
|
||||
emoticons: [],
|
||||
keywords: [name],
|
||||
imageUrl: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return emojis;
|
||||
return (
|
||||
<li key={icon} className='col-span-1 inline-block'>
|
||||
<button
|
||||
className='flex items-center justify-center rounded-full p-1.5 hover:bg-gray-50 dark:hover:bg-primary-800'
|
||||
aria-label={name}
|
||||
title={name}
|
||||
onClick={() => handleClick(name)}
|
||||
>
|
||||
<i className={clsx(icon, 'h-[1.375rem] w-[1.375rem] text-lg leading-[1.15]')} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const data = { compressed: true, categories: [], aliases: [], emojis: [] };
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
|
||||
return (
|
||||
<div className={clsx('font-icon-picker emoji-picker-dropdown__menu')} style={style} ref={setRef}>
|
||||
<Picker
|
||||
perLine={8}
|
||||
emojiSize={22}
|
||||
include={categoriesSort}
|
||||
sheetSize={32}
|
||||
custom={buildIcons()}
|
||||
color=''
|
||||
emoji=''
|
||||
set=''
|
||||
title={title}
|
||||
i18n={getI18n()}
|
||||
onClick={handleClick}
|
||||
showPreview={false}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
emojiTooltip
|
||||
noShowAnchors
|
||||
data={data}
|
||||
/>
|
||||
<div
|
||||
className={clsx('absolute z-[101] -my-0.5')}
|
||||
style={{ transform: 'translateX(calc(-1 * env(safe-area-inset-right)))', ...style }}
|
||||
ref={setRef}
|
||||
>
|
||||
<div className='h-[270px] overflow-x-hidden overflow-y-scroll rounded bg-white p-1.5 text-gray-900 dark:bg-primary-900 dark:text-gray-100' aria-label={title}>
|
||||
<Text className='px-1.5 py-1'><FormattedMessage id='icon_button.icons' defaultMessage='Icons' /></Text>
|
||||
<ul className='grid grid-cols-8'>
|
||||
{Object.values(icons).flat().map(icon => renderIcon(icon))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,15 +4,13 @@ import IconPickerDropdown from './icon-picker-dropdown';
|
|||
|
||||
interface IIconPicker {
|
||||
value: string
|
||||
onChange: React.ChangeEventHandler
|
||||
onChange: (icon: string) => void
|
||||
}
|
||||
|
||||
const IconPicker: React.FC<IIconPicker> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className='relative mt-1 rounded-md border border-solid border-gray-300 shadow-sm dark:border-gray-600 dark:bg-gray-800'>
|
||||
<IconPickerDropdown value={value} onPickEmoji={onChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const IconPicker: React.FC<IIconPicker> = ({ value, onChange }) => (
|
||||
<div className='relative mt-1 rounded-md border border-solid border-gray-300 shadow-sm dark:border-gray-600 dark:bg-gray-800'>
|
||||
<IconPickerDropdown value={value} onPickIcon={onChange} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IconPicker;
|
||||
|
|
|
@ -17,8 +17,8 @@ const messages = defineMessages({
|
|||
const PromoPanelInput: StreamfieldComponent<PromoPanelItem> = ({ value, onChange }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleIconChange = (icon: any) => {
|
||||
onChange(value.set('icon', icon.id));
|
||||
const handleIconChange = (icon: string) => {
|
||||
onChange(value.set('icon', icon));
|
||||
};
|
||||
|
||||
const handleChange = (key: 'text' | 'url'): React.ChangeEventHandler<HTMLInputElement> => {
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import { logOut } from 'soapbox/actions/auth';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { useSoapboxConfig, useOwnAccount, useFeatures, useAppDispatch } from 'soapbox/hooks';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ const NewGroupPanel = () => {
|
|||
<Stack space={2}>
|
||||
<Stack>
|
||||
<Text size='lg' weight='bold'>
|
||||
<FormattedMessage id='new_group_panel.title' defaultMessage='Create New Group' />
|
||||
<FormattedMessage id='new_group_panel.title' defaultMessage='Create Group' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' size='sm'>
|
||||
|
@ -30,12 +30,11 @@ const NewGroupPanel = () => {
|
|||
</Stack>
|
||||
|
||||
<Button
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create Group' />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -116,6 +116,7 @@ import {
|
|||
EventDiscussion,
|
||||
Events,
|
||||
Groups,
|
||||
GroupsDiscover,
|
||||
GroupMembers,
|
||||
GroupTimeline,
|
||||
ManageGroup,
|
||||
|
@ -285,6 +286,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||
|
||||
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
||||
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
||||
|
@ -575,7 +577,7 @@ const UI: React.FC<IUI> = ({ children }) => {
|
|||
|
||||
// @ts-ignore
|
||||
hotkeys.current.__mousetrap__.stopCallback = (_e, element) => {
|
||||
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
|
||||
return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export function EmojiPicker() {
|
||||
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji-picker');
|
||||
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/components/emoji-picker');
|
||||
}
|
||||
|
||||
export function Notifications() {
|
||||
|
@ -546,6 +546,10 @@ export function Groups() {
|
|||
return import(/* webpackChunkName: "features/groups" */'../../groups');
|
||||
}
|
||||
|
||||
export function GroupsDiscover() {
|
||||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
|
||||
}
|
||||
|
||||
export function GroupMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeAccount, normalizeGroup, normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import { useGroupsPath } from '../useGroupsPath';
|
||||
|
||||
describe('useGroupsPath()', () => {
|
||||
test('without the groupsDiscovery feature', () => {
|
||||
const store = {
|
||||
instance: normalizeInstance({
|
||||
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
||||
}),
|
||||
};
|
||||
|
||||
const { result } = renderHook(useGroupsPath, undefined, store);
|
||||
|
||||
expect(result.current).toEqual('/groups');
|
||||
});
|
||||
|
||||
describe('with the "groupsDiscovery" feature', () => {
|
||||
let store: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const userId = '1';
|
||||
store = {
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
me: userId,
|
||||
accounts: ImmutableMap({
|
||||
[userId]: normalizeAccount({
|
||||
id: userId,
|
||||
acct: 'justin-username',
|
||||
display_name: 'Justin L',
|
||||
avatar: 'test.jpg',
|
||||
chats_onboarded: false,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('when the user has no groups', () => {
|
||||
test('should default to the discovery page', () => {
|
||||
const { result } = renderHook(useGroupsPath, undefined, store);
|
||||
|
||||
expect(result.current).toEqual('/groups/discover');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user has groups', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [
|
||||
normalizeGroup({
|
||||
display_name: 'Group',
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should default to the discovery page', async () => {
|
||||
const { result } = renderHook(useGroupsPath, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual('/groups');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,6 +5,7 @@ export { useAppSelector } from './useAppSelector';
|
|||
export { useClickOutside } from './useClickOutside';
|
||||
export { useCompose } from './useCompose';
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useGroupsPath } from './useGroupsPath';
|
||||
export { useDimensions } from './useDimensions';
|
||||
export { useFeatures } from './useFeatures';
|
||||
export { useInstance } from './useInstance';
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { useGroups } from 'soapbox/queries/groups';
|
||||
|
||||
import { useFeatures } from './useFeatures';
|
||||
|
||||
/**
|
||||
* Determine the correct URL to use for /groups.
|
||||
* If the user does not have any Groups, let's default to the discovery tab.
|
||||
* Otherwise, let's default to My Groups.
|
||||
*
|
||||
* @returns String (as link)
|
||||
*/
|
||||
const useGroupsPath = () => {
|
||||
const features = useFeatures();
|
||||
const { groups } = useGroups();
|
||||
|
||||
if (!features.groupsDiscovery) {
|
||||
return '/groups';
|
||||
}
|
||||
|
||||
return groups.length > 0 ? '/groups' : '/groups/discover';
|
||||
};
|
||||
|
||||
export { useGroupsPath };
|
|
@ -3,14 +3,14 @@
|
|||
"accordion.collapse": "طيّ",
|
||||
"accordion.expand": "توسيع",
|
||||
"account.add_or_remove_from_list": "إضافة أو إزالة من القوائم",
|
||||
"account.badges.bot": "بوت",
|
||||
"account.badges.bot": "آلي",
|
||||
"account.birthday": "ولد في {date}",
|
||||
"account.birthday_today": "اليوم يوم ميلاد صاحب الحساب!",
|
||||
"account.block": "حظر @{name}",
|
||||
"account.block_domain": "إخفاء كل ما يتعلق بالنطاق {domain}",
|
||||
"account.blocked": "محظور",
|
||||
"account.chat": "دردشة مع @{name}",
|
||||
"account.deactivated": "تم تعطيله",
|
||||
"account.deactivated": "عُطِّلَ",
|
||||
"account.direct": "رسالة خاصة إلى @{name}",
|
||||
"account.domain_blocked": "النطاق مخفي",
|
||||
"account.edit_profile": "تعديل الملف الشخصي",
|
||||
|
@ -18,7 +18,7 @@
|
|||
"account.endorse.success": "أنت الآن تقوم بالتوصية بـ @{acct} في ملفك الشخصي",
|
||||
"account.familiar_followers": "يُتابعه {accounts}",
|
||||
"account.familiar_followers.empty": "لا أحد تعرفه يتابع {name}.",
|
||||
"account.familiar_followers.more": "{count} {count, plural, one {other} other {others}} أنت تتباعهم",
|
||||
"account.familiar_followers.more": "{count، plural، واحد {# other} آخر {# others}} تتبعه",
|
||||
"account.follow": "متابعة",
|
||||
"account.followers": "متابعون",
|
||||
"account.followers.empty": "لا يوجد متابعين لهذا الحساب.",
|
||||
|
@ -26,7 +26,7 @@
|
|||
"account.follows.empty": "هذا الحساب لا يُتابع أي شخص.",
|
||||
"account.follows_you": "يتابعك",
|
||||
"account.header.alt": "ترويسة الحساب",
|
||||
"account.hide_reblogs": "إخفاء المناشير المعاد نشرها من @{name}",
|
||||
"account.hide_reblogs": "إخفاء المنشورات المعاد نشرها من @{name}",
|
||||
"account.last_status": "آخر تواجد",
|
||||
"account.link_verified_on": "أُكِّدت ملكية الحساب في {date}",
|
||||
"account.locked_info": "هذا الحساب خاص. يجب الموافقة على طلب المتابعة من قِبل صاحب الحساب.",
|
||||
|
@ -35,30 +35,30 @@
|
|||
"account.member_since": "إنضم بتاريخ {date}",
|
||||
"account.mention": "إشارة",
|
||||
"account.mute": "كتم @{name}",
|
||||
"account.muted": "تم كتمه",
|
||||
"account.muted": "كُتِمَ",
|
||||
"account.never_active": "أبدًا",
|
||||
"account.posts": "منشورات",
|
||||
"account.posts_with_replies": "المنشورات والردود",
|
||||
"account.profile": "الملف الشخصي",
|
||||
"account.profile_external": "عرض الملف الشخصي في {domain}",
|
||||
"account.register": "التسجيل",
|
||||
"account.register": "إنشاء حساب",
|
||||
"account.remote_follow": "متابعة على خادم خارجي",
|
||||
"account.remove_from_followers": "حذف هذا المتابع",
|
||||
"account.report": "ابلِغ عن @{name}",
|
||||
"account.requested": "في انتظار الموافقة. اضغط لإلغاء طلب المتابعة",
|
||||
"account.requested_small": "في انتظار الموافقة",
|
||||
"account.rss_feed": "الاشتراك في تغذية RSS",
|
||||
"account.search": "البحث انطلاقًا من @{name}",
|
||||
"account.rss_feed": "اشترك في موجز RSS",
|
||||
"account.search": "البحث في منشورات @{name}",
|
||||
"account.search_self": "البحث في منشوراتك",
|
||||
"account.share": "مشاركة حساب @{name}",
|
||||
"account.show_reblogs": "عرض المنشورات المُعاد نشرها من @{name}",
|
||||
"account.subscribe": "متابعة الإشعارات من طرف @{name}",
|
||||
"account.subscribe.failure": "حدث خلل أثناء الاشتراك بالإشعارات من هذا الحساب.",
|
||||
"account.subscribe.success": "لقد اشتركت بالإشعارات من هذا الحساب",
|
||||
"account.subscribe.success": "لقد اشتركت في هذا الحساب.",
|
||||
"account.unblock": "إلغاء الحظر عن @{name}",
|
||||
"account.unblock_domain": "إلغاء إخفاء {domain}",
|
||||
"account.unendorse": "الإزالة من ملفك الشخصي",
|
||||
"account.unendorse.success": "تمت إزالة @{acct} من ملفك الشخصي",
|
||||
"account.unendorse.success": "أُزيل @{acct} من ملفك الشخصي",
|
||||
"account.unfollow": "إلغاء المتابعة",
|
||||
"account.unmute": "إلغاء كتم @{name}",
|
||||
"account.unsubscribe": "إلغاء متابعة الإشعارات من طرف @{name}",
|
||||
|
@ -78,17 +78,23 @@
|
|||
"account_moderation_modal.roles.moderator": "مشرف",
|
||||
"account_moderation_modal.roles.user": "مستخدم",
|
||||
"account_moderation_modal.title": "الإشراف على @{acct}",
|
||||
"account_note.hint": "يمكنك الاحتفاظ بملاحظات حول هذا المستخدم لنفسك (لن تتم مشاركتها معهم):",
|
||||
"account_note.hint": "يمكنك الاحتفاظ بملاحظات حول هذا المستخدم لنفسك (لن تُشارَكَ معهم):",
|
||||
"account_note.placeholder": "لا توجد تعليقات",
|
||||
"account_note.save": "حِفط",
|
||||
"account_note.target": "ملاحظة لـ @{target}",
|
||||
"account_search.placeholder": "ابحث عن حساب",
|
||||
"account_search.placeholder": "البحث عن حساب",
|
||||
"actualStatus.edited": "تم تعديله بتاريخ {date}",
|
||||
"actualStatuses.quote_tombstone": "المنشور غير متوفر",
|
||||
"admin.announcements.action": "إنشاء إعلان",
|
||||
"admin.announcements.all_day": "طوال اليوم",
|
||||
"admin.announcements.delete": "إزالة",
|
||||
"admin.announcements.edit": "تعديل",
|
||||
"admin.announcements.ends_at": "ينتهي عند:",
|
||||
"admin.announcements.starts_at": "يبدأ عند:",
|
||||
"admin.awaiting_approval.approved_message": "تمت الموافقة على {acct}!",
|
||||
"admin.awaiting_approval.empty_message": "ليس هناك حسابات جديدة للموافقة عليها. عندما يقوم شخص ما بالتسجيل، ستتمكن من مراجعة الحساب هنا.",
|
||||
"admin.awaiting_approval.rejected_message": "تم رفض {acct}!",
|
||||
"admin.dashboard.registration_mode.approval_hint": "يمكن للمستخدمين إنشاء الحسابات، لكن يتم تفعيل حساباتهم عند قبولها من طرف المدير.",
|
||||
"admin.awaiting_approval.rejected_message": "رُفِضَ {acct}!",
|
||||
"admin.dashboard.registration_mode.approval_hint": "يمكن للمستخدمين إنشاء الحسابات، لكن تُفعَّل حساباتهم عند قبولها من طرف المدير.",
|
||||
"admin.dashboard.registration_mode.approval_label": "يتطلب الموافقة",
|
||||
"admin.dashboard.registration_mode.closed_hint": "لا يمكن لأحد إنشاء حساب جديد، ولكن مازال بإمكانك دعوتهم.",
|
||||
"admin.dashboard.registration_mode.closed_label": "مُغلق",
|
||||
|
@ -103,6 +109,18 @@
|
|||
"admin.dashcounters.user_count_label": "إجمالي الأعضاء",
|
||||
"admin.dashwidgets.email_list_header": "قائمة البريد الإلكتروني",
|
||||
"admin.dashwidgets.software_header": "النظام",
|
||||
"admin.edit_announcement.created": "تم إنشاء الإعلان",
|
||||
"admin.edit_announcement.deleted": "حُذِفَ الإعلان",
|
||||
"admin.edit_announcement.fields.all_day_hint": "عند التحديد ، سيتم عرض تواريخ النطاق الزمني فقط",
|
||||
"admin.edit_announcement.fields.all_day_label": "حدث طوال اليوم",
|
||||
"admin.edit_announcement.fields.content_label": "المحتوى",
|
||||
"admin.edit_announcement.fields.content_placeholder": "محتوى الإعلان",
|
||||
"admin.edit_announcement.fields.end_time_label": "تاريخ الانتهاء",
|
||||
"admin.edit_announcement.fields.end_time_placeholder": "الإعلان ينتهي في:",
|
||||
"admin.edit_announcement.fields.start_time_label": "تاريخ البَدْء",
|
||||
"admin.edit_announcement.fields.start_time_placeholder": "الإعلان يبدأ في:",
|
||||
"admin.edit_announcement.save": "حفظ",
|
||||
"admin.edit_announcement.updated": "عُدِّلَ الإعلان",
|
||||
"admin.latest_accounts_panel.more": "إضغط لعرض {count} {count, plural, one {حساب} other {حسابات}}",
|
||||
"admin.latest_accounts_panel.title": "أحدث الحسابات",
|
||||
"admin.moderation_log.empty_message": "لم تنفِّذ أيّ عملية إشرافٍ بعد. عندما تفعل سيظهر سجلٌّ لها هنا.",
|
||||
|
@ -140,7 +158,7 @@
|
|||
"admin_nav.awaiting_approval": "في انتظار الموافقة",
|
||||
"admin_nav.dashboard": "لوحة التحكم",
|
||||
"admin_nav.reports": "البلاغات",
|
||||
"age_verification.body": "منصة {siteTitle} تستوجب أن تبلغ في الأقل {ageMinimum} عامًا للتسجيل واستخدام المنصة. أي شخص يبلغ أقل من {ageMinimum} عام لا يُسمح له بالتسجيل.",
|
||||
"age_verification.body": "يتطلب {siteTitle} أن يكون عمر المستخدمين على الأقل {ageMinimum، plural،one {# year} آخر {# years}} عام للوصول إلى النظام الأساسي الخاص به. لا يمكن لأي شخص يقل عمره عن {ageMinimum، plural، one {# year} other {# years}} الوصول إلى هذا النظام الأساسي.",
|
||||
"age_verification.fail": "يجب أن تبلغ {ageMinimum, plural, one {سنةً} two {سنتين} few {# سنواتٍ} many {# سنةً} other {# سنةً}} أو أكثر.",
|
||||
"age_verification.header": "رجاءً أدخل تاريخ ميلادك",
|
||||
"alert.unexpected.body": "نأسف للمقاطعة. إذا استمرت هذه المشكلة، يرجى التواصل مع فريق الدعم لدينا. يمكنك أيضًا محاولة {clearCookies} (سيؤدي هذا إلى تسجيل خروجك).",
|
||||
|
@ -170,6 +188,7 @@
|
|||
"app_create.scopes_placeholder": "مثلاً: (قراءة كتابة متابعة)",
|
||||
"app_create.submit": "إنشاء تطبيق",
|
||||
"app_create.website_label": "الموقع",
|
||||
"auth.awaiting_approval": "حسابك ينتظر الموافقة",
|
||||
"auth.invalid_credentials": "اسم المستخدم أو كلمة المرور خاطئة",
|
||||
"auth.logged_out": "سُجِّل الخروج.",
|
||||
"auth_layout.register": "إنشاء حساب",
|
||||
|
@ -190,77 +209,85 @@
|
|||
"bundle_modal_error.retry": "إعادة المحاولة",
|
||||
"card.back.label": "العودة",
|
||||
"chat.actions.send": "إرسال",
|
||||
"chat.failed_to_send": "الرسائل التي فشل إرسالها.",
|
||||
"chat.input.placeholder": "أكتب رسالة",
|
||||
"chat.failed_to_send": "فشل إرسال الرسالة.",
|
||||
"chat.input.placeholder": "اكتب رسالة",
|
||||
"chat.new_message.title": "رسالة جديدة",
|
||||
"chat.page_settings.accepting_messages.label": "السماح للمستخدمين بمراسلتك",
|
||||
"chat.page_settings.play_sounds.label": "تشغيل صوت عند وصول رسالة جديدة",
|
||||
"chat.page_settings.accepting_messages.label": "السماح للمستخدمين ببدء محادثة جديدة معك",
|
||||
"chat.page_settings.play_sounds.label": "تفعيل إشعار صوتي عند وصول رسالة جديدة",
|
||||
"chat.page_settings.preferences": "التفضيلات",
|
||||
"chat.page_settings.privacy": "الخصوصية",
|
||||
"chat.page_settings.submit": "حفظ",
|
||||
"chat.page_settings.title": "إعدادات الرسائل",
|
||||
"chat.retry": "المحاولة مجدداً؟",
|
||||
"chat.welcome.accepting_messages.label": "السماح للمستخدمين بمراسلتك",
|
||||
"chat.retry": "إعادة المحاولة؟",
|
||||
"chat.welcome.accepting_messages.label": "السماح للمستخدمين ببدء محادثة جديدة معك",
|
||||
"chat.welcome.notice": "يمكنك تعديل هذه الإعدادات لاحقاً.",
|
||||
"chat.welcome.submit": "الحفظ والمتابعة",
|
||||
"chat.welcome.submit": "حفظ ومتابعة",
|
||||
"chat.welcome.subtitle": "تبادل الرسائل الخاصة مع الأعضاء الآخرين.",
|
||||
"chat.welcome.title": "مرحباً بك في مراسلات {br}!",
|
||||
"chat_composer.unblock": "فك الحظر",
|
||||
"chat_list_item.blocked_you": "قام هذا المستخدم بحظرك",
|
||||
"chat_list_item.blocking": "لقد قمت بحظر هذا المستخدم",
|
||||
"chat_message_list.blocked": "لقد قمت بحظر هذا المستخدم",
|
||||
"chat_composer.unblock": "رفع الحظر",
|
||||
"chat_list_item.blocked_you": "حظرك هذا المستخدم",
|
||||
"chat_list_item.blocking": "لقد حَظَرْتَ هذا المستخدم",
|
||||
"chat_message_list.blocked": "لقد حَظَرْتَ هذا المستخدم",
|
||||
"chat_message_list.blockedBy": "أنت محظور من قبل",
|
||||
"chat_message_list.network_failure.action": "حاول مجدداً",
|
||||
"chat_message_list.network_failure.subtitle": "هناك عطل في الشبكة.",
|
||||
"chat_message_list.network_failure.title": "المعذرة!",
|
||||
"chat_message_list_intro.actions.accept": "قبول",
|
||||
"chat_message_list_intro.actions.leave_chat": "الخروج من الدردشة",
|
||||
"chat_message_list_intro.actions.message_lifespan": "يتم حذف الرسائل الأقدم من {day} يوم.",
|
||||
"chat_message_list_intro.actions.message_lifespan": "تُحْذَفُ الرسائل الأقدم من {day، plural، one {# day} other {# days}}.",
|
||||
"chat_message_list_intro.actions.report": "تبليغ",
|
||||
"chat_message_list_intro.intro": "يرغب في مراسلتك",
|
||||
"chat_message_list_intro.intro": "يريد مراسلتك",
|
||||
"chat_message_list_intro.leave_chat.confirm": "الخروج من الدردشة",
|
||||
"chat_message_list_intro.leave_chat.heading": "الخروج من الدردشة",
|
||||
"chat_message_list_intro.leave_chat.message": "متأكد من رغبتك في الخروج من هذه الدردشة؟ سيتم حذف الرسائل من حسابك وإزالة الدردشة من قائمة الرسائل.",
|
||||
"chat_message_list_intro.leave_chat.message": "أمتأكد من رغبتك في الخروج من هذه الدردشة؟ سيتم حذف الرسائل من حسابك وإزالة الدردشة من قائمة الرسائل.",
|
||||
"chat_search.blankslate.body": "البحث عن عضو للدردشة معه.",
|
||||
"chat_search.blankslate.title": "بدء دردشة",
|
||||
"chat_search.empty_results_blankslate.action": "قم بمراسلة أحدهم",
|
||||
"chat_search.empty_results_blankslate.body": "حاول أن تبحث عن شخص آخر.",
|
||||
"chat_search.empty_results_blankslate.title": "لم يتم العثور على نتائج",
|
||||
"chat_search.blankslate.title": "بَدْء دردشة",
|
||||
"chat_search.empty_results_blankslate.action": "رَاسِلْ أحدهم",
|
||||
"chat_search.empty_results_blankslate.body": "حاول البحث عن اسم آخر.",
|
||||
"chat_search.empty_results_blankslate.title": "لم يُعْثَرْ على نتائج",
|
||||
"chat_search.placeholder": "أدخل اسم",
|
||||
"chat_search.title": "المراسلات",
|
||||
"chat_settings.auto_delete.14days": "14 يوم",
|
||||
"chat_settings.auto_delete.14days": "14 يوماً",
|
||||
"chat_settings.auto_delete.2minutes": "دقيقتين",
|
||||
"chat_settings.auto_delete.30days": "30 يوم",
|
||||
"chat_settings.auto_delete.30days": "30 يوماً",
|
||||
"chat_settings.auto_delete.7days": "7 أيام",
|
||||
"chat_settings.auto_delete.90days": "90 يوم",
|
||||
"chat_settings.auto_delete.days": "{day} يوم",
|
||||
"chat_settings.auto_delete.hint": "سيتم حذف الرسائل المرسلة بشكل تلقائي بعد انقضاء الفترة المحددة",
|
||||
"chat_settings.auto_delete.90days": "90 يوماً",
|
||||
"chat_settings.auto_delete.days": "{number, plural, one {# يوم} other {# أيام}}",
|
||||
"chat_settings.auto_delete.hint": "سَتُحْذَفُ الرسائل المرسلة بشكل تلقائي بعد انقضاء الفترة المحددة",
|
||||
"chat_settings.auto_delete.label": "الحذف التلقائي للرسائل",
|
||||
"chat_settings.block.confirm": "حظر",
|
||||
"chat_settings.block.heading": "حظر @{acct}",
|
||||
"chat_settings.block.message": "حظر الحساب سيتسبب بمنعه من مشاهدة محتواك ومراسلتك. يمكنك فك الحظر لاحقاً.",
|
||||
"chat_settings.leave.confirm": "الخروج من الدردشة",
|
||||
"chat_settings.leave.heading": "الخروج من الدردشة",
|
||||
"chat_settings.leave.message": "متأكد من رغبتك في الخروج من هذه الدردشة؟ سيتم حذف الرسائل من حسابك وإزالة الدردشة من قائمة الرسائل.",
|
||||
"chat_settings.leave.message": "أمتأكد من رغبتك في الخروج من هذه الدردشة؟ سيتم حذف الرسائل من حسابك وإزالة الدردشة من قائمة الرسائل.",
|
||||
"chat_settings.options.block_user": "حظر @{acct}",
|
||||
"chat_settings.options.leave_chat": "الخروج من الدردشة",
|
||||
"chat_settings.options.report_user": "التبليغ عن @{acct}",
|
||||
"chat_settings.options.unblock_user": "فك الحظر عن @{acct}",
|
||||
"chat_settings.options.unblock_user": "رفع الحظر عن @{acct}",
|
||||
"chat_settings.title": "تفاصيل المحادثة",
|
||||
"chat_settings.unblock.confirm": "فك الحظر",
|
||||
"chat_settings.unblock.heading": "فك الحظر عن @{acct}",
|
||||
"chat_settings.unblock.confirm": "رفع الحظر",
|
||||
"chat_settings.unblock.heading": "رفع الحظر عن @{acct}",
|
||||
"chat_settings.unblock.message": "سيسمح إلغاء الحظر لهذا المِلَفّ الشخصي بتوجيه رسالة إليك وعرض المحتوى الخاص بك.",
|
||||
"chat_window.auto_delete_label": "حذف تلقائي بعد {day، plural، one {# يوم} other {# أيام}}",
|
||||
"chat_window.auto_delete_tooltip": "ضُبِطَتْ رسائل الدردشة على الحذف التلقائي بعد {day، plural، one {# يوم} other {# أيام}} عند الإرسال.",
|
||||
"chats.actions.copy": "نسخ",
|
||||
"chats.actions.delete": "حذف الرسالة",
|
||||
"chats.actions.deleteForMe": "الحذف لي",
|
||||
"chats.actions.deleteForMe": "الحذف عندي",
|
||||
"chats.actions.more": "المزيد",
|
||||
"chats.actions.report": "الإبلاغ عن المستخدم",
|
||||
"chats.dividers.today": "اليوم",
|
||||
"chats.main.blankslate.new_chat": "مراسلة شخص ما",
|
||||
"chats.main.blankslate.new_chat": "رَاسِلْ أحدهم",
|
||||
"chats.main.blankslate.subtitle": "البحث عن عضو للدردشة معه",
|
||||
"chats.main.blankslate.title": "لا يوجد رسائل",
|
||||
"chats.main.blankslate.title": "لا توجد رسائل حتى الآن",
|
||||
"chats.main.blankslate_with_chats.subtitle": "اختر من إحدى الدردشات المفتوحة أو أنشئ رسالة جديدة.",
|
||||
"chats.main.blankslate_with_chats.title": "اختيار دردشة",
|
||||
"chats.search_placeholder": "بَدْء دردشة مع…",
|
||||
"column.admin.announcements": "الإعلانات",
|
||||
"column.admin.awaiting_approval": "في انتظار الموافقة",
|
||||
"column.admin.create_announcement": "إنشاء إعلان",
|
||||
"column.admin.dashboard": "لوحة تحكم",
|
||||
"column.admin.edit_announcement": "تعديل الإعلان",
|
||||
"column.admin.moderation_log": "سجل الإشراف",
|
||||
"column.admin.reports": "التبليغات",
|
||||
"column.admin.reports.menu.moderation_log": "سجل الإشراف",
|
||||
|
@ -314,10 +341,14 @@
|
|||
"column.follow_requests": "طلبات المتابعة",
|
||||
"column.followers": "المتابعين",
|
||||
"column.following": "يتابع",
|
||||
"column.group_blocked_members": "الأعضاء المحظورين",
|
||||
"column.group_pending_requests": "الطلبات المعلقة",
|
||||
"column.groups": "مجموعات",
|
||||
"column.home": "الرئيسية",
|
||||
"column.import_data": "استيراد البيانات",
|
||||
"column.info": "معلومات عن الخادم",
|
||||
"column.lists": "القوائم",
|
||||
"column.manage_group": "إدارة المجموعة",
|
||||
"column.mentions": "الإشارات",
|
||||
"column.mfa": "المصادقة المتعددة",
|
||||
"column.mfa_cancel": "الغاء",
|
||||
|
@ -349,7 +380,9 @@
|
|||
"compose.submit_success": "تم إرسال المنشور",
|
||||
"compose_event.create": "إنشاء",
|
||||
"compose_event.edit_success": "تم تعديل الحدث",
|
||||
"compose_event.fields.approval_required": "أرغب في الموافقة على طلبات المشاركة يدويًا",
|
||||
"compose_event.fields.banner_label": "صورة الحدث",
|
||||
"compose_event.fields.description_hint": "صيغة ماركداون مدعومة",
|
||||
"compose_event.fields.description_label": "وصف الحدث",
|
||||
"compose_event.fields.description_placeholder": "الوصف",
|
||||
"compose_event.fields.end_time_label": "الحدث والتاريخ",
|
||||
|
@ -369,7 +402,9 @@
|
|||
"compose_event.tabs.edit": "تعديل التفاصيل",
|
||||
"compose_event.tabs.pending": "إدارة الطلبات",
|
||||
"compose_event.update": "تحديث",
|
||||
"compose_event.upload_banner": "تحميل لافتة الحدث",
|
||||
"compose_form.direct_message_warning": "لن يظهر منشورك إلا للمستخدمين المذكورين.",
|
||||
"compose_form.event_placeholder": "أضف إلى هذا الحدث",
|
||||
"compose_form.hashtag_warning": "هذا المنشور لن يُدرَج تحت أي وسم كان، بما أنه غير مُدرَج. لا يُسمح بالبحث إلّا عن المنشورات العمومية عن طريق وسومها.",
|
||||
"compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص رؤية منشوراتك الخاصة",
|
||||
"compose_form.lock_disclaimer.lock": "مقفل",
|
||||
|
@ -402,6 +437,9 @@
|
|||
"confirmations.admin.deactivate_user.confirm": "تعطيل @{name}",
|
||||
"confirmations.admin.deactivate_user.heading": "تعطيل @{acct}",
|
||||
"confirmations.admin.deactivate_user.message": "أنت على وشك تعطيل @{acct}. يمكنك تفعيله في أي وقت.",
|
||||
"confirmations.admin.delete_announcement.confirm": "حذف",
|
||||
"confirmations.admin.delete_announcement.heading": "حذف الإعلان",
|
||||
"confirmations.admin.delete_announcement.message": "هل أنت متأكد أنك تريد حذف الإعلان؟",
|
||||
"confirmations.admin.delete_local_user.checkbox": "أنا على وعي أنني سأقوم بحذف حساب محلي.",
|
||||
"confirmations.admin.delete_status.confirm": "حذف المنشور",
|
||||
"confirmations.admin.delete_status.heading": "حذف المنشور",
|
||||
|
@ -422,26 +460,47 @@
|
|||
"confirmations.block.confirm": "حجب",
|
||||
"confirmations.block.heading": "حجب @{name}",
|
||||
"confirmations.block.message": "هل تود حقًا حظر {name}؟",
|
||||
"confirmations.block_from_group.confirm": "حظر",
|
||||
"confirmations.block_from_group.heading": "حظر عضو المجموعة",
|
||||
"confirmations.block_from_group.message": "هل أنت متأكد من أنك تريد منع @{name} من التفاعل مع هذه المجموعة؟",
|
||||
"confirmations.cancel.confirm": "تجاهل",
|
||||
"confirmations.cancel.heading": "تجاهل المنشور",
|
||||
"confirmations.cancel.message": "هل أنت متأكد أنك تريد إلغاء إنشاء هذا المنشور؟",
|
||||
"confirmations.cancel_editing.confirm": "إلغاء التعديل",
|
||||
"confirmations.cancel_editing.heading": "إلغاء تعديل المنشور",
|
||||
"confirmations.cancel_editing.message": "هل أنت متأكد من رغبتك في إلغاء التعديل؟ ستخسر جميع التغييرات.",
|
||||
"confirmations.cancel_event_editing.heading": "إلغاء تعديل الحدث",
|
||||
"confirmations.cancel_event_editing.message": "هل أنت متأكد من رغبتك في إلغاء التعديل؟ ستخسر جميع التغييرات.",
|
||||
"confirmations.delete.confirm": "إزالة",
|
||||
"confirmations.delete.heading": "حذف المنشور",
|
||||
"confirmations.delete.message": "هل تود حقًا حذف هذا المنشور؟",
|
||||
"confirmations.delete_event.confirm": "حذف",
|
||||
"confirmations.delete_event.heading": "حذف الحدث",
|
||||
"confirmations.delete_event.message": "متأكد من رغبتك في حذف هذا الحدث؟",
|
||||
"confirmations.delete_from_group.heading": "حذف من المجموعة",
|
||||
"confirmations.delete_from_group.message": "هل أنت متأكد من أنك تريد حذف منشور @{name}؟",
|
||||
"confirmations.delete_group.confirm": "إزالة",
|
||||
"confirmations.delete_group.heading": "حذف المجموعة",
|
||||
"confirmations.delete_group.message": "هل أنت متأكد أنك تريد حذف هذه المجموعة؟ هذا إجراء دائم لا يمكن التراجع عنه.",
|
||||
"confirmations.delete_list.confirm": "حذف",
|
||||
"confirmations.delete_list.heading": "إزالة القائمة",
|
||||
"confirmations.delete_list.message": "هل تود حقا حذف هذه القائمة ؟",
|
||||
"confirmations.domain_block.confirm": "حجب النطاق",
|
||||
"confirmations.domain_block.heading": "حجب {domain}",
|
||||
"confirmations.domain_block.message": "هل تود حظر النطاق {domain} بالكامل؟ في غالب الأحيان يُستَحسَن كتم أو حظر بعض الحسابات بدلا من حظر نطاق بالكامل.\nلن تتمكن مِن رؤية محتوى هذا النطاق لا على خيوطك العمومية و لا في إشعاراتك. سيُزيل ذلك كافة متابعيك المنتمين إلى هذا النطاق.",
|
||||
"confirmations.kick_from_group.confirm": "طرد",
|
||||
"confirmations.kick_from_group.heading": "طرد عضو المجموعة",
|
||||
"confirmations.kick_from_group.message": "هل أنت متأكد أنك تريد طرد @ {name} من هذه المجموعة؟",
|
||||
"confirmations.leave_event.confirm": "الخروج من الحدث",
|
||||
"confirmations.leave_event.message": "إذا كنت تريد إعادة الانضمام إلى الحدث ، فستتم مراجعة الطلب يدويًا مرة أخرى. هل انت متأكد انك تريد المتابعة؟",
|
||||
"confirmations.leave_group.confirm": "ترك",
|
||||
"confirmations.leave_group.heading": "مغادرة المجموعة",
|
||||
"confirmations.leave_group.message": "أنت على وشك مغادرة المجموعة هل تريد الاستمرار؟?",
|
||||
"confirmations.mute.confirm": "كتم",
|
||||
"confirmations.mute.heading": "كتم @{name}",
|
||||
"confirmations.mute.message": "هل تود حقًا حجب {name}؟",
|
||||
"confirmations.promote_in_group.confirm": "ترقية",
|
||||
"confirmations.promote_in_group.message": "هل أنت متأكد أنك تريد الترقية لـ @ {name}؟ لن تكون قادرًا على تخفيض رتبتهم.",
|
||||
"confirmations.redraft.confirm": "إزالة و إعادة الصياغة",
|
||||
"confirmations.redraft.heading": "إزالة وإعادة الصياغة",
|
||||
"confirmations.redraft.message": "هل تود حقًّا حذف المنشور وإعادة صياغته؟ ستفقد التفاعلات والمشاركات المتعلّقة به وستظهر الردود كمنشورات منفصلة. ",
|
||||
|
@ -486,6 +545,7 @@
|
|||
"developers.navigation.network_error_label": "خطأ في الشبكة",
|
||||
"developers.navigation.service_worker_label": "عامل الخدمة",
|
||||
"developers.navigation.settings_store_label": "مجمع الاعدادات",
|
||||
"developers.navigation.show_toast": "الإخطارات العاجلة",
|
||||
"developers.navigation.test_timeline_label": "تجربة الخط الزمني",
|
||||
"developers.settings_store.advanced": "إعدادات متقدمة",
|
||||
"developers.settings_store.hint": "يمكنك تعديل إعدادت المستخدم من هنا. لكن كن حذرا! تعديل هذا القسم قد يتسبب في تعطيل الحساب، وستتمكن فقط من استرجاع البيانات عن طريق الـ API.",
|
||||
|
@ -577,6 +637,7 @@
|
|||
"empty_column.account_favourited_statuses": "هذا المستخدم لم يحصل على أي إعجاب على منشوراته حتى الآن.",
|
||||
"empty_column.account_timeline": "ليس هناك منشورات!",
|
||||
"empty_column.account_unavailable": "الملف الشخصي غير متوفر",
|
||||
"empty_column.admin.announcements": "لا توجد إعلانات حتى الآن.",
|
||||
"empty_column.aliases": "لم تقم بإنشاء أية أسماء مستعارة لحسابك حتى الآن.",
|
||||
"empty_column.aliases.suggestions": "لا توجد حسابات مُقترحة للكلمة المُدخلة.",
|
||||
"empty_column.blocks": "لم تقم بحظر أي مستخدِم بعد.",
|
||||
|
@ -584,11 +645,16 @@
|
|||
"empty_column.community": " لا توجد منشورات في بسّام بعد. أكتب شيئا ما للعامة كبداية!",
|
||||
"empty_column.direct": "لم تتلقَ أي رسالة خاصة مباشرة بعد. ستعرض الرسائل المباشرة هنا في حال أرسلت أو تلقيت بعضها.",
|
||||
"empty_column.domain_blocks": "ليس هناك نطاقات مخفية بعد.",
|
||||
"empty_column.event_participant_requests": "لا توجد طلبات معلقة للمشاركة في الحدث.",
|
||||
"empty_column.event_participants": "لم ينضم أحد إلى هذا الحدث حتى الآن. عندما يفعل شخص ما ، سوف يظهر هنا.",
|
||||
"empty_column.favourited_statuses": "لم تقم بالإعجاب بأي منشور بعد. عندما تقوم بالإعجاب بواحد، سيظهر هنا.",
|
||||
"empty_column.favourites": "لم يتفاعل أحد مع هذا المنشور. عندما يتفاعل أحد ما سيظهر هنا.",
|
||||
"empty_column.filters": "لم ترشِّح أيّ كلمة بعد.",
|
||||
"empty_column.follow_recommendations": "يبدو أنه لا يمكن إنشاء اقتراحات مناسبة لك. يمكنك استخدام خانة البحث للعثور على أشخاص قد تعرفهم أو استكشاف المواضيع الشائعة عن طريق الوُسوم.",
|
||||
"empty_column.follow_requests": "ليس لديك أي طلب للمتابعة بعد. سوف تظهر الطلبات هنا عندما تتلقى البعض منها.",
|
||||
"empty_column.group": "لا توجد مشاركات في هذه المجموعة حتى الآن.",
|
||||
"empty_column.group_blocks": "لم تحظر المجموعة أي مستخدمين حتى الآن.",
|
||||
"empty_column.group_membership_requests": "لا توجد طلبات عضوية معلقة لهذه المجموعة.",
|
||||
"empty_column.hashtag": "لا يوجد محتوى له عَلاقة بهذا الوسم",
|
||||
"empty_column.home": "لا توجد منشورات على صفحتك الرئسية. قم بزيارة {public} أو استخدم مربع البحث لتكتشف مستخدمين جدد.",
|
||||
"empty_column.home.local_tab": "قم بزيارة الخيط المحلي ل{site_title}",
|
||||
|
@ -600,12 +666,16 @@
|
|||
"empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
|
||||
"empty_column.notifications_filtered": "لم تتلقَ أيّ إشعارٍ من هذا النوع بعد.",
|
||||
"empty_column.public": "لا يوجد أي شيء هنا! قم بنشر شيء ما، أو اتبع المستخدمين الآخرين لملء الخط الزمني",
|
||||
"empty_column.quotes": "لم يتم اقتباس هذا المنشور بعد.",
|
||||
"empty_column.remote": "لا يوجد أي شيء هنا، قم بمتابعة أحد المستخدمين من {instance} لملئ الفراغ.",
|
||||
"empty_column.scheduled_statuses": "ليس لديك أي حالات مجدولة حتى الآن. ستظهر هنا عندما تضيفها.",
|
||||
"empty_column.search.accounts": "لم يتم العثور على تطابق مع {term}",
|
||||
"empty_column.search.groups": "لم يُعْثَرْ على منشورات لـ \"{term}\"",
|
||||
"empty_column.search.hashtags": "لم يتم العثور على وُسوم لـ \"{term}\"",
|
||||
"empty_column.search.statuses": "لم يتم العثور على منشورات لـ \"{term}\"",
|
||||
"empty_column.test": "الخط الزمني للاختبار فارغ.",
|
||||
"event.banner": "لافتة الأحداث",
|
||||
"event.copy": "نسخ الرابط إلى الحدث",
|
||||
"event.date": "التاريخ",
|
||||
"event.description": "الوصف",
|
||||
"event.discussion.empty": "ليس هناك تعليقات. عندما يقوم أحدهم بالتعليق، سيظهر التعليق هنا.",
|
||||
|
@ -613,16 +683,23 @@
|
|||
"event.external": "العرض في {domain}",
|
||||
"event.join_state.accept": "ذاهب",
|
||||
"event.join_state.empty": "اشتراك",
|
||||
"event.join_state.pending": "معلقة",
|
||||
"event.join_state.rejected": "ذاهب",
|
||||
"event.location": "الموقع",
|
||||
"event.manage": "إدارة",
|
||||
"event.organized_by": "قام {name} بتنسيق الحدث",
|
||||
"event.participants": "{count} {rawCount, plural, one {person} آخرون {people}} يتحدثون",
|
||||
"event.quote": "اقتباس الحدث",
|
||||
"event.reblog": "إعادة نشر الحدث",
|
||||
"event.show_on_map": "العرض على الخريطة",
|
||||
"event.unreblog": "حدث لم يُعَدْ نشره",
|
||||
"event.website": "روابط خارجية",
|
||||
"event_map.navigate": "التنقل",
|
||||
"events.create_event": "إنشاء حدث",
|
||||
"events.joined_events": "الأحداث المشترك بها",
|
||||
"events.joined_events.empty": "لم تشترك في أي حدث بعد.",
|
||||
"events.recent_events": "الأحداث الأخيرة",
|
||||
"events.recent_events.empty": "لا توجد أحداث عامة حتى الآن.",
|
||||
"export_data.actions.export": "تصدير",
|
||||
"export_data.actions.export_blocks": "تصدير قائمة الحظر ",
|
||||
"export_data.actions.export_follows": "تصدير المتابعين",
|
||||
|
@ -667,6 +744,40 @@
|
|||
"gdpr.message": "يستخدم {siteTitle} ملفات الكوكيز لدعم الجلسات وهي تعتبر حيوية لكي يعمل الموقع بشكل صحيح.",
|
||||
"gdpr.title": "موقع {siteTitle} يستخدم الكوكيز",
|
||||
"getting_started.open_source_notice": "{code_name} هو برنامَج مفتوح المصدر. يمكنك المساهمة أو الإبلاغ عن الأخطاء على {code_link} (الإصدار {code_version}).",
|
||||
"group.admin_subheading": "مسؤولي المجموعة",
|
||||
"group.cancel_request": "إلغاء الطلب",
|
||||
"group.group_mod_authorize": "قبول",
|
||||
"group.group_mod_authorize.success": "قُبِلَ @ {name} في المجموعة",
|
||||
"group.group_mod_block": "حظر @{name} من المجموعة",
|
||||
"group.group_mod_block.success": "حُظِرَ @{name} من المجموعة",
|
||||
"group.group_mod_demote": "خفض الرتبة",
|
||||
"group.group_mod_demote.success": "خفض رتبة @{name} إلى مستخدم المجموعة",
|
||||
"group.group_mod_kick": "طرد @{name} من المجموعة",
|
||||
"group.group_mod_kick.success": "طُرِدَ @{name} من المجموعة",
|
||||
"group.group_mod_promote_admin": "ترقية @{name} إلى مسؤول المجموعة",
|
||||
"group.group_mod_promote_admin.success": "تمت ترقية @{name} إلى مسؤول المجموعة",
|
||||
"group.group_mod_promote_mod": "ترقية @{name} إلى مشرف المجموعة",
|
||||
"group.group_mod_promote_mod.success": "تمت ترقية @{name} إلى مشرف المجموعة",
|
||||
"group.group_mod_reject": "رفض",
|
||||
"group.group_mod_reject.success": "مرفوض @{name} من المجموعة",
|
||||
"group.group_mod_unblock": "رفع الحظر",
|
||||
"group.group_mod_unblock.success": "أُلْغِيَّ الحظر على @ {name} من المجموعة",
|
||||
"group.header.alt": "غلاف المجموعة",
|
||||
"group.join.request_success": "طلب الانضمام للمجموعة",
|
||||
"group.join.success": "الإنضمام إلى المجموعة",
|
||||
"group.leave": "غادر المجموعة",
|
||||
"group.leave.success": "غادر المجموعة",
|
||||
"group.manage": "إدارة المجموعة",
|
||||
"group.moderator_subheading": "مشرفو المجموعة",
|
||||
"group.privacy.locked": "خاص",
|
||||
"group.privacy.public": "عام",
|
||||
"group.role.admin": "مسؤول",
|
||||
"group.role.moderator": "مشرف",
|
||||
"group.tabs.all": "الكل",
|
||||
"group.tabs.members": "الأعضاء",
|
||||
"group.user_subheading": "المستخدمون",
|
||||
"groups.empty.subtitle": "ابدأ في اكتشاف مجموعات للانضمام إليها أو إنشاء مجموعاتك الخاصة.",
|
||||
"groups.empty.title": "لا توجد مجموعات حتى الآن",
|
||||
"hashtag.column_header.tag_mode.all": "و {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "أو {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "بدون {additional}",
|
||||
|
@ -681,7 +792,6 @@
|
|||
"home.column_settings.show_replies": "عرض الردود",
|
||||
"icon_button.icons": "الأيقونات",
|
||||
"icon_button.label": "تحديد أيقونة",
|
||||
"icon_button.not_found": "لا يوجد أيقونات (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "إستيراد",
|
||||
"import_data.actions.import_blocks": "استيراد قائمة الحظر",
|
||||
"import_data.actions.import_follows": "استيراد قائمة المتابعين",
|
||||
|
@ -701,6 +811,7 @@
|
|||
"intervals.full.days": "{number, plural, one {# يوم} other {# أيام}}",
|
||||
"intervals.full.hours": "{number, plural, one {# ساعة} other {# ساعات}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# دقيقة} other {# دقائق}}",
|
||||
"join_event.hint": "يمكنك إخبار المنظم لماذا تريد المشاركة في هذا الحدث:",
|
||||
"join_event.join": "طلب الانضمام",
|
||||
"join_event.placeholder": "مراسلة منسّق الحدث",
|
||||
"join_event.request_success": "طلب الانضمام للحدث",
|
||||
|
@ -769,6 +880,27 @@
|
|||
"login_external.errors.instance_fail": "حدث خلل ما.",
|
||||
"login_external.errors.network_fail": "فشل الاتصال، ربما هناك إضافة في متصفحك تقوم بحظر الاتصال؟",
|
||||
"login_form.header": "تسجيل الدخول",
|
||||
"manage_group.blocked_members": "الأعضاء المحظورين",
|
||||
"manage_group.create": "إنشاء",
|
||||
"manage_group.delete_group": "حذف المجموعة",
|
||||
"manage_group.edit_group": "تحرير المجموعة",
|
||||
"manage_group.edit_success": "تم تحرير المجموعة",
|
||||
"manage_group.fields.description_label": "الوصف",
|
||||
"manage_group.fields.description_placeholder": "الوصف",
|
||||
"manage_group.fields.name_label": "اسم المجموعة (مطلوبة)",
|
||||
"manage_group.fields.name_placeholder": "اسم المجموعة",
|
||||
"manage_group.get_started": "لنبدأ!",
|
||||
"manage_group.next": "التالي",
|
||||
"manage_group.pending_requests": "الطلبات المعلقة",
|
||||
"manage_group.privacy.hint": "لا يمكن تغيير هذه الإعدادات لاحقا.",
|
||||
"manage_group.privacy.label": "إعدادات الخصوصية",
|
||||
"manage_group.privacy.private.hint": "قابل للاكتشاف. يمكن للمستخدمين الانضمام بعد الموافقة على طلبهم.",
|
||||
"manage_group.privacy.private.label": "خاص (مطلوب موافقة المالك)",
|
||||
"manage_group.privacy.public.hint": "قابل للاكتشاف. يمكن لأي شخص الانضمام.",
|
||||
"manage_group.privacy.public.label": "عام",
|
||||
"manage_group.submit_success": "تم إنشاء المجموعة",
|
||||
"manage_group.tagline": "تربطك المجموعات بالآخرين على أساس الاهتمامات المشتركة.",
|
||||
"manage_group.update": "تحديث",
|
||||
"media_panel.empty_message": "لم يُعثر على أيّة وسائط.",
|
||||
"media_panel.title": "الوسائط",
|
||||
"mfa.confirm.success_message": "تم تأكيد إعدادات المصادقة المتعددة",
|
||||
|
@ -804,6 +936,7 @@
|
|||
"missing_indicator.label": "غير موجود",
|
||||
"missing_indicator.sublabel": "العنصر المطلوب غير موجود",
|
||||
"modals.policy.submit": "القبول والمتابعة",
|
||||
"modals.policy.updateTitle": "لقد حصلت على أحدث إصدار من {siteTitle}! يُرجى تخصيص بعض الوقت لمراجعة الأشياء الجديدة والمثيرة التي كنا نعمل عليها.",
|
||||
"moderation_overlay.contact": "جهات الاتصال",
|
||||
"moderation_overlay.hide": "إخفاء المحتوى",
|
||||
"moderation_overlay.show": "عرض المحتوى",
|
||||
|
@ -834,7 +967,9 @@
|
|||
"navigation_bar.compose_quote": "اقتباس المنشور",
|
||||
"navigation_bar.compose_reply": "الرد على المنشور",
|
||||
"navigation_bar.create_event": "إنشاء حدث جديد",
|
||||
"navigation_bar.create_group": "إنشاء مجموعة",
|
||||
"navigation_bar.domain_blocks": "النطاقات المخفية",
|
||||
"navigation_bar.edit_group": "تحرير المجموعة",
|
||||
"navigation_bar.favourites": "المفضلة",
|
||||
"navigation_bar.filters": "المُرشِّحات",
|
||||
"navigation_bar.follow_requests": "طلبات المتابعة",
|
||||
|
@ -846,6 +981,12 @@
|
|||
"navigation_bar.preferences": "التفضيلات",
|
||||
"navigation_bar.profile_directory": "قائمة الحسابات",
|
||||
"navigation_bar.soapbox_config": "إعدادات Soapbox",
|
||||
"new_event_panel.action": "انشاء حدث",
|
||||
"new_event_panel.subtitle": "ألا تستطيع إيجاد ما تبحث عنه؟ جدولة الحدث الخاص بك.",
|
||||
"new_event_panel.title": "إنشاء حدث جديد",
|
||||
"new_group_panel.action": "إنشاء مجموعة",
|
||||
"new_group_panel.subtitle": "ألا تستطيع إيجاد ما تبحث عنه؟ ابدأ مجموعتك الخاصة أو العامة.",
|
||||
"new_group_panel.title": "إنشاء مجموعة جديدة",
|
||||
"notification.favourite": "أُعجِب {name} بمنشورك",
|
||||
"notification.follow": "قام {name} بمتابعتك",
|
||||
"notification.follow_request": "طلب {name} متابعتك",
|
||||
|
@ -856,7 +997,9 @@
|
|||
"notification.others": "+ {count} {count, plural, one {other} other {others}}",
|
||||
"notification.pleroma:chat_mention": "{name} أرسل لك رسالة",
|
||||
"notification.pleroma:emoji_reaction": "تفاعل {name} مع منشورك",
|
||||
"notification.pleroma:event_reminder": "يبدأ الحدث الذي تشارك فيه قريبًا",
|
||||
"notification.pleroma:participation_accepted": "تمت الموافقة على طلب انضمامك للحدث",
|
||||
"notification.pleroma:participation_request": "{name} يريد الانضمام إلى الحدث الخاص بك",
|
||||
"notification.poll": "لقد انتهى استفتاء شاركت فيه",
|
||||
"notification.reblog": "قام {name} بمشاركة منشورك",
|
||||
"notification.status": "{name} نشر للتو",
|
||||
|
@ -930,6 +1073,7 @@
|
|||
"preferences.fields.content_type_label": "صيغة المنشور الافتراضية",
|
||||
"preferences.fields.delete_modal_label": "أظهار إشعار لتأكيد حذف المنشور قبل التنفيذ",
|
||||
"preferences.fields.demetricator_label": "إخفاء إحصاءات المنشورات",
|
||||
"preferences.fields.demo_hint": "استخدم شعار Soapbox ونظام الألوان الافتراضي. مناسب لأخذ لقطات الشاشة.",
|
||||
"preferences.fields.demo_label": "وضع العرض",
|
||||
"preferences.fields.display_media.default": "اخف الوسائط المصنفة بحساس",
|
||||
"preferences.fields.display_media.hide_all": "اخف جميع الوسائط",
|
||||
|
@ -1018,6 +1162,7 @@
|
|||
"remote_interaction.account_placeholder": "أدخل اسم المستخدم@عنوان الخادم الذي تريد التفاعل منه",
|
||||
"remote_interaction.divider": "أو",
|
||||
"remote_interaction.event_join": "المتابعة في الانضمام",
|
||||
"remote_interaction.event_join_title": "الانضمام إلى حدث عن بعد",
|
||||
"remote_interaction.favourite": "المتابعة في الإعجاب",
|
||||
"remote_interaction.favourite_title": "الإعجاب بمنشور غير محلّي",
|
||||
"remote_interaction.follow": "المضي قدما في المتابعة",
|
||||
|
@ -1039,6 +1184,7 @@
|
|||
"reply_mentions.reply_empty": "الرد على منشور",
|
||||
"report.block": "حظر {target}",
|
||||
"report.block_hint": "أتريد حظر هذا الحساب أيضًا؟",
|
||||
"report.chatMessage.context": "عند الإبلاغ عن رسالة مستخدم، سيتم تمرير الرسائل الخمس السابقة والخمس التي تليها إلى فريق الإشراف لدينا لمعرفة السياق.",
|
||||
"report.chatMessage.title": "التبليغ عن الرسالة",
|
||||
"report.confirmation.content": "في حال وجدنا أن هذا الحساب ينتهك {link}، سوف نتخذ أجراءات أخرى. ",
|
||||
"report.confirmation.title": "شكرًا لتقديمك للبلاغ.",
|
||||
|
@ -1071,6 +1217,7 @@
|
|||
"search.placeholder": "بحث",
|
||||
"search_results.accounts": "أشخاص",
|
||||
"search_results.filter_message": "أنت تبحث في @{acct} عن منشورات ",
|
||||
"search_results.groups": "المجموعات",
|
||||
"search_results.hashtags": "الوسوم",
|
||||
"search_results.statuses": "المنشورات",
|
||||
"security.codes.fail": "فشك تحميل رموز النسخ الإحتياطي",
|
||||
|
@ -1161,20 +1308,28 @@
|
|||
"soapbox_config.hints.promo_panel_icons.link": "قائمة أيقونات بسّام",
|
||||
"soapbox_config.home_footer.meta_fields.label_placeholder": "خانة",
|
||||
"soapbox_config.home_footer.meta_fields.url_placeholder": "الرابط",
|
||||
"soapbox_config.media_preview_hint": "توفر بعض الخلفيات نسخة محسّنة من الوسائط لعرضها في الجداول الزمنية. ومع ذلك ، قد تكون صور المعاينة هذه صغيرة جدًا بدون تكوين إضافي.",
|
||||
"soapbox_config.media_preview_label": "تفضيل معاينة الوسائط للصور المصغرة",
|
||||
"soapbox_config.promo_panel.meta_fields.icon_placeholder": "أيقونة",
|
||||
"soapbox_config.promo_panel.meta_fields.label_placeholder": "خانة",
|
||||
"soapbox_config.promo_panel.meta_fields.url_placeholder": "الرابط",
|
||||
"soapbox_config.raw_json_hint": "تحرير ملف JSON بشكل مباشر. التعديلات التي ستقوم بها هناك سوف تطغى وتستبدل الحقول في الأعلى. قم بالضغط على حفظ لتطبيق التغييرات.",
|
||||
"soapbox_config.raw_json_invalid": "غير صالح",
|
||||
"soapbox_config.raw_json_label": "متقدم: تحرير ملف JSON",
|
||||
"soapbox_config.redirect_root_no_login_hint": "مسار لإعادة توجيه الصفحة الرئيسية عندما لا يقوم المستخدم بتسجيل الدخول.",
|
||||
"soapbox_config.redirect_root_no_login_label": "إعادة توجيه الصفحة الرئيسية",
|
||||
"soapbox_config.save": "حفظ",
|
||||
"soapbox_config.saved": "تم حفظ إعدادات Soapbox",
|
||||
"soapbox_config.tile_server_attribution_label": "إسناد مربعات الخرائط",
|
||||
"soapbox_config.tile_server_label": "خادم نظام الخرائط",
|
||||
"soapbox_config.verified_can_edit_name_label": "السماح للحسابات الموثّقة بتغيير أسمائهم.",
|
||||
"sponsored.info.message": "{siteTitle} يعرض اعلانات لتمويل الخدمة",
|
||||
"sponsored.info.title": "لماذا أرى هذا الإعلان؟",
|
||||
"sponsored.subtitle": "منشور ترويجي",
|
||||
"status.admin_account": "افتح الواجهة الإدارية لـ @{name}",
|
||||
"status.admin_status": "افتح هذا المنشور في واجهة الإشراف",
|
||||
"status.approval.pending": "بانتظار الموافقة",
|
||||
"status.approval.rejected": "مرفوض",
|
||||
"status.bookmark": "المحفوظات",
|
||||
"status.bookmarked": "تمت الإضافة للمحفوظات.",
|
||||
"status.cancel_reblog_private": "إلغاء المشاركة",
|
||||
|
@ -1184,12 +1339,18 @@
|
|||
"status.delete": "حذف",
|
||||
"status.detailed_status": "عرض مُفصّل للمحادثة",
|
||||
"status.direct": "رسالة خاصة إلى @{name}",
|
||||
"status.disabled_replies.group_membership": "يمكن لأعضاء المجموعة فقط الرد",
|
||||
"status.edit": "تحرير",
|
||||
"status.embed": "تضمين",
|
||||
"status.external": "العرض على {domain}",
|
||||
"status.favourite": "تفاعل مع المنشور",
|
||||
"status.filtered": "رُشِّح",
|
||||
"status.group": "نُشِرَ في {مجموعة}",
|
||||
"status.group_mod_block": "حظر @{name} من المجموعة",
|
||||
"status.group_mod_delete": "حذف المشاركة من المجموعة",
|
||||
"status.group_mod_kick": "طرد @{name} من المجموعة",
|
||||
"status.interactions.favourites": "{count, plural, one {إعجاب واحد} other {إعجاب}}",
|
||||
"status.interactions.quotes": "{count, plural, one {# صوت} other {# أصوات}}",
|
||||
"status.interactions.reblogs": "{count, plural, one {مشاركة} other {مشاركات}}",
|
||||
"status.load_more": "تحميل المزيد",
|
||||
"status.mention": "ذِكر @{name}",
|
||||
|
@ -1248,6 +1409,7 @@
|
|||
"tabs_bar.all": "الكل",
|
||||
"tabs_bar.dashboard": "لوحة التحكم",
|
||||
"tabs_bar.fediverse": "الكون الفيدرالي الإجتماعي",
|
||||
"tabs_bar.groups": "المجموعات",
|
||||
"tabs_bar.home": "الرئيسية",
|
||||
"tabs_bar.local": "الخط المحلي",
|
||||
"tabs_bar.more": "المزيد",
|
||||
|
@ -1256,7 +1418,13 @@
|
|||
"tabs_bar.search": "البحث",
|
||||
"tabs_bar.settings": "الإعدادات",
|
||||
"theme_editor.Reset": "إعادة تعيين",
|
||||
"theme_toggle.dark": "غامق",
|
||||
"theme_editor.export": "تصدير السمة",
|
||||
"theme_editor.import": "استيراد السمة",
|
||||
"theme_editor.import_success": "تم استيراد السمة بنجاح!",
|
||||
"theme_editor.restore": "استعادة السمة الافتراضية",
|
||||
"theme_editor.save": "حفظ السمة",
|
||||
"theme_editor.saved": "تم تحديث السمة!",
|
||||
"theme_toggle.dark": "داكن",
|
||||
"theme_toggle.light": "فاتح",
|
||||
"theme_toggle.system": "النظام",
|
||||
"thread_login.login": "تسجيل الدخول",
|
||||
|
@ -1284,7 +1452,7 @@
|
|||
"upload_form.description": "وصف لمن لديهم إعاقة بصرية",
|
||||
"upload_form.preview": "مراجعة",
|
||||
"upload_form.undo": "حذف",
|
||||
"upload_progress.label": "جارِ التحميل...",
|
||||
"upload_progress.label": "جارِ الرفع…",
|
||||
"video.close": "إغلاق المقطع",
|
||||
"video.download": "تنزيل الملف",
|
||||
"video.exit_fullscreen": "الخروج من وضع ملئ الشاشة",
|
||||
|
@ -1292,10 +1460,10 @@
|
|||
"video.fullscreen": "ملئ الشاشة",
|
||||
"video.hide": "إخفاء المقطع",
|
||||
"video.mute": "كتم الصوت",
|
||||
"video.pause": "ايقاف مؤقت",
|
||||
"video.pause": "إيقاف مؤقت",
|
||||
"video.play": "تشغيل",
|
||||
"video.unmute": "تفعيل الصوت",
|
||||
"waitlist.actions.verify_number": "توثيق رقم الهاتف",
|
||||
"waitlist.body": "مرحباً بعودتك {title}! لقد كنت على قائمة الانتظار. يرجى تأكيد رقم الهاتف الخاص بك للحصول على وصول فوري لحسابك!",
|
||||
"waitlist.actions.verify_number": "توثيق رَقْم الهاتف",
|
||||
"waitlist.body": "مرحباً بعودتك {title}! لقد كنت على قائمة الانتظار. يرجى تأكيد رَقْم هاتفك للوصول الفوري لحسابك!",
|
||||
"who_to_follow.title": "حسابات مقترحة"
|
||||
}
|
||||
|
|
|
@ -294,7 +294,6 @@
|
|||
"home.column_settings.show_replies": "Mostrar respostes",
|
||||
"icon_button.icons": "Icones",
|
||||
"icon_button.label": "Selecciona la icona",
|
||||
"icon_button.not_found": "Sense icones!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Importació",
|
||||
"import_data.actions.import_blocks": "Importa blocs",
|
||||
"import_data.actions.import_follows": "Importa seguits",
|
||||
|
|
|
@ -564,7 +564,6 @@
|
|||
"home.column_settings.show_replies": "Dangos ymatebion",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
"account.posts": "Beiträge",
|
||||
"account.posts_with_replies": "Beiträge und Antworten",
|
||||
"account.profile": "Profil",
|
||||
"account.profile_external": "Auf Heimdomäne",
|
||||
"account.profile_external": "Auf Originalseite öffnen",
|
||||
"account.register": "Registrieren",
|
||||
"account.remote_follow": "Von anderer Instanz folgen",
|
||||
"account.remove_from_followers": "Follower entfernen",
|
||||
|
@ -737,7 +737,7 @@
|
|||
"group.group_mod_unblock": "Entblocken",
|
||||
"group.group_mod_unblock.success": "@{name} in der Gruppe entblockt",
|
||||
"group.header.alt": "Gruppentitel",
|
||||
"group.join": "Gruppe beitreten",
|
||||
"group.join.public": "Gruppe beitreten",
|
||||
"group.join.request_success": "Mitgliedschaft in der Gruppe angefragt",
|
||||
"group.join.success": "Gruppe beigetreten",
|
||||
"group.leave": "Gruppe verlassen",
|
||||
|
@ -746,7 +746,7 @@
|
|||
"group.moderator_subheading": "Moderator:innen der Gruppe",
|
||||
"group.privacy.locked": "Privat",
|
||||
"group.privacy.public": "Öffentlich",
|
||||
"group.request_join": "Mitgliedschaft in der Gruppe anfragen",
|
||||
"group.join.private": "Mitgliedschaft in der Gruppe anfragen",
|
||||
"group.role.admin": "Administrator:in",
|
||||
"group.role.moderator": "Moderator:in",
|
||||
"group.tabs.all": "Alle",
|
||||
|
@ -768,7 +768,6 @@
|
|||
"home.column_settings.show_replies": "Antworten anzeigen",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Icons auswählen",
|
||||
"icon_button.not_found": "Keine Icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Importieren",
|
||||
"import_data.actions.import_blocks": "Blockliste importieren",
|
||||
"import_data.actions.import_follows": "Nutzer, denen du folgst, importieren",
|
||||
|
|
|
@ -564,7 +564,6 @@
|
|||
"home.column_settings.show_replies": "Εμφάνιση απαντήσεων",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
|
|
@ -564,7 +564,6 @@
|
|||
"home.column_settings.show_replies": "𐑖𐑴 𐑮𐑦𐑐𐑤𐑲𐑟",
|
||||
"icon_button.icons": "𐑲𐑒𐑪𐑯𐑟",
|
||||
"icon_button.label": "𐑕𐑦𐑤𐑧𐑒𐑑 𐑲𐑒𐑪𐑯",
|
||||
"icon_button.not_found": "𐑯𐑴 𐑲𐑒𐑪𐑯𐑟!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "𐑦𐑥𐑐𐑹𐑑",
|
||||
"import_data.actions.import_blocks": "𐑦𐑥𐑐𐑹𐑑 𐑚𐑤𐑪𐑒𐑕",
|
||||
"import_data.actions.import_follows": "𐑦𐑥𐑐𐑹𐑑 𐑓𐑪𐑤𐑴𐑟",
|
||||
|
|
|
@ -630,6 +630,7 @@
|
|||
"email_verifilcation.exists": "This email has already been taken.",
|
||||
"embed.instructions": "Embed this post on your website by copying the code below.",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.add_custom": "Add custom emoji",
|
||||
"emoji_button.custom": "Custom",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
|
@ -637,10 +638,19 @@
|
|||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.not_found": "No emojis found.",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.oh_no": "Oh no!",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.pick": "Pick an emoji…",
|
||||
"emoji_button.recent": "Frequently used",
|
||||
"emoji_button.search": "Search…",
|
||||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.skins_1": "Default",
|
||||
"emoji_button.skins_2": "Light",
|
||||
"emoji_button.skins_3": "Medium-Light",
|
||||
"emoji_button.skins_4": "Medium",
|
||||
"emoji_button.skins_5": "Medium-Dark",
|
||||
"emoji_button.skins_6": "Dark",
|
||||
"emoji_button.skins_choose": "Choose default skin tone",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.account_blocked": "You are blocked by @{accountUsername}.",
|
||||
|
@ -758,7 +768,7 @@
|
|||
"gdpr.title": "{siteTitle} uses cookies",
|
||||
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
|
||||
"group.admin_subheading": "Group administrators",
|
||||
"group.cancel_request": "Cancel request",
|
||||
"group.cancel_request": "Cancel Request",
|
||||
"group.group_mod_authorize": "Accept",
|
||||
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
||||
"group.group_mod_block": "Block @{name} from group",
|
||||
|
@ -776,21 +786,30 @@
|
|||
"group.group_mod_unblock": "Unblock",
|
||||
"group.group_mod_unblock.success": "Unblocked @{name} from group",
|
||||
"group.header.alt": "Group header",
|
||||
"group.join": "Join group",
|
||||
"group.join.private": "Request Access",
|
||||
"group.join.public": "Join Group",
|
||||
"group.join.request_success": "Requested to join the group",
|
||||
"group.join.success": "Joined the group",
|
||||
"group.leave": "Leave group",
|
||||
"group.leave": "Leave Group",
|
||||
"group.leave.success": "Left the group",
|
||||
"group.manage": "Manage group",
|
||||
"group.manage": "Manage Group",
|
||||
"group.moderator_subheading": "Group moderators",
|
||||
"group.privacy.locked": "Private",
|
||||
"group.privacy.public": "Public",
|
||||
"group.request_join": "Request to join group",
|
||||
"group.role.admin": "Admin",
|
||||
"group.role.moderator": "Moderator",
|
||||
"group.tabs.all": "All",
|
||||
"group.tabs.members": "Members",
|
||||
"group.user_subheading": "Users",
|
||||
"groups.discover.search.no_results.subtitle": "Try searching for another group.",
|
||||
"groups.discover.search.no_results.title": "No matches found",
|
||||
"groups.discover.search.placeholder": "Search",
|
||||
"groups.discover.search.recent_searches.blankslate.subtitle": "Search group names, topics or keywords",
|
||||
"groups.discover.search.recent_searches.blankslate.title": "No recent searches",
|
||||
"groups.discover.search.recent_searches.clear_all": "Clear all",
|
||||
"groups.discover.search.recent_searches.title": "Recent searches",
|
||||
"groups.discover.search.results.groups": "Groups",
|
||||
"groups.discover.search.results.member_count": "{members, plural, one {member} other {members}}",
|
||||
"groups.empty.subtitle": "Start discovering groups to join or create your own.",
|
||||
"groups.empty.title": "No Groups yet",
|
||||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||
|
@ -807,7 +826,6 @@
|
|||
"home.column_settings.show_replies": "Show replies",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
@ -1000,9 +1018,9 @@
|
|||
"new_event_panel.action": "Create event",
|
||||
"new_event_panel.subtitle": "Can't find what you're looking for? Schedule your own event.",
|
||||
"new_event_panel.title": "Create New Event",
|
||||
"new_group_panel.action": "Create group",
|
||||
"new_group_panel.action": "Create Group",
|
||||
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
|
||||
"new_group_panel.title": "Create New Group",
|
||||
"new_group_panel.title": "Create Group",
|
||||
"notification.favourite": "{name} liked your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
|
|
|
@ -614,7 +614,6 @@
|
|||
"home.column_settings.show_replies": "Mostrar respuestas",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
|
|
@ -85,6 +85,12 @@
|
|||
"account_search.placeholder": "Buscar una cuenta",
|
||||
"actualStatus.edited": "Editado {date}",
|
||||
"actualStatuses.quote_tombstone": "La publicación no está disponible.",
|
||||
"admin.announcements.action": "Crear anuncio",
|
||||
"admin.announcements.all_day": "Todos los días",
|
||||
"admin.announcements.delete": "Borrar",
|
||||
"admin.announcements.edit": "Editar",
|
||||
"admin.announcements.ends_at": "Acaba en:",
|
||||
"admin.announcements.starts_at": "Empieza en:",
|
||||
"admin.awaiting_approval.approved_message": "¡{acct} ha sido aprobado!",
|
||||
"admin.awaiting_approval.empty_message": "No hay nadie esperando aprobación. Cuando se registre un nuevo usuario, puedes revisarlo aquí.",
|
||||
"admin.awaiting_approval.rejected_message": "{acct} fue rechazado.",
|
||||
|
@ -103,6 +109,18 @@
|
|||
"admin.dashcounters.user_count_label": "usuarios totales",
|
||||
"admin.dashwidgets.email_list_header": "Email list",
|
||||
"admin.dashwidgets.software_header": "Software",
|
||||
"admin.edit_announcement.created": "Anuncio creado",
|
||||
"admin.edit_announcement.deleted": "Anuncio borrado",
|
||||
"admin.edit_announcement.fields.all_day_hint": "Si está marcada, sólo se mostrarán las fechas del intervalo de tiempo",
|
||||
"admin.edit_announcement.fields.all_day_label": "Todo el día",
|
||||
"admin.edit_announcement.fields.content_label": "Contenido",
|
||||
"admin.edit_announcement.fields.content_placeholder": "Contenido del anuncio",
|
||||
"admin.edit_announcement.fields.end_time_label": "Fecha límite",
|
||||
"admin.edit_announcement.fields.end_time_placeholder": "El anuncio termina el:",
|
||||
"admin.edit_announcement.fields.start_time_label": "Fecha de inicio",
|
||||
"admin.edit_announcement.fields.start_time_placeholder": "El anuncio empieza el:",
|
||||
"admin.edit_announcement.save": "Guardar",
|
||||
"admin.edit_announcement.updated": "Anuncio editado",
|
||||
"admin.latest_accounts_panel.more": "Haga clic para ver {contar, plural, una {# cuenta} other {# cuentas}}",
|
||||
"admin.latest_accounts_panel.title": "Cuentas más recientes",
|
||||
"admin.moderation_log.empty_message": "Aún no has realizado ninguna acción de moderación. Cuando lo hagas, se mostrará un historial aquí.",
|
||||
|
@ -265,8 +283,11 @@
|
|||
"chats.main.blankslate_with_chats.subtitle": "Selecciona uno de tus chats abiertos o escribe un mensaje nuevo.",
|
||||
"chats.main.blankslate_with_chats.title": "Seleccionar un chat",
|
||||
"chats.search_placeholder": "Start a chat with…",
|
||||
"column.admin.announcements": "Anuncios",
|
||||
"column.admin.awaiting_approval": "En espera de aprobación",
|
||||
"column.admin.create_announcement": "Crear un anuncio",
|
||||
"column.admin.dashboard": "Dashboard",
|
||||
"column.admin.edit_announcement": "Editar el anuncio",
|
||||
"column.admin.moderation_log": "Registro de moderación",
|
||||
"column.admin.reports": "Reports",
|
||||
"column.admin.reports.menu.moderation_log": "Registro de moderación",
|
||||
|
@ -416,6 +437,9 @@
|
|||
"confirmations.admin.deactivate_user.confirm": "Deactivate @{name}",
|
||||
"confirmations.admin.deactivate_user.heading": "Deactivate @{acct}",
|
||||
"confirmations.admin.deactivate_user.message": "You are about to deactivate @{acct}. Deactivating a user is a reversible action.",
|
||||
"confirmations.admin.delete_announcement.confirm": "Borrar",
|
||||
"confirmations.admin.delete_announcement.heading": "Borrar el anuncio",
|
||||
"confirmations.admin.delete_announcement.message": "¿Seguro que quieres borrar el anuncio?",
|
||||
"confirmations.admin.delete_local_user.checkbox": "I understand that I am about to delete a local user.",
|
||||
"confirmations.admin.delete_status.confirm": "Delete post",
|
||||
"confirmations.admin.delete_status.heading": "Delete post",
|
||||
|
@ -596,6 +620,7 @@
|
|||
"email_verifilcation.exists": "This email has already been taken.",
|
||||
"embed.instructions": "Añade este toot a tu sitio web con el siguiente código.",
|
||||
"emoji_button.activity": "Actividad",
|
||||
"emoji_button.add_custom": "Añade un emoji personalizado",
|
||||
"emoji_button.custom": "Personalizado",
|
||||
"emoji_button.flags": "Marcas",
|
||||
"emoji_button.food": "Comida y bebida",
|
||||
|
@ -603,16 +628,26 @@
|
|||
"emoji_button.nature": "Naturaleza",
|
||||
"emoji_button.not_found": "No se encontraron emojis :( .",
|
||||
"emoji_button.objects": "Objetos",
|
||||
"emoji_button.oh_no": "¡Ay, no!",
|
||||
"emoji_button.people": "Gente",
|
||||
"emoji_button.pick": "Escoge un emoji…",
|
||||
"emoji_button.recent": "Usados frecuentemente",
|
||||
"emoji_button.search": "Buscar…",
|
||||
"emoji_button.search_results": "Resultados de búsqueda",
|
||||
"emoji_button.skins_1": "Por defecto",
|
||||
"emoji_button.skins_2": "Claro",
|
||||
"emoji_button.skins_3": "Claro Medio",
|
||||
"emoji_button.skins_4": "Medio",
|
||||
"emoji_button.skins_5": "Oscuro Medio",
|
||||
"emoji_button.skins_6": "Negro",
|
||||
"emoji_button.skins_choose": "Elige el tono de la skin predeterminado",
|
||||
"emoji_button.symbols": "Símbolos",
|
||||
"emoji_button.travel": "Viajes y lugares",
|
||||
"empty_column.account_blocked": "You are blocked by @{accountUsername}.",
|
||||
"empty_column.account_favourited_statuses": "This user doesn't have any liked posts yet.",
|
||||
"empty_column.account_timeline": "¡No hay toots aquí!",
|
||||
"empty_column.account_unavailable": "Perfil no disponible",
|
||||
"empty_column.admin.announcements": "Todavía no hay anuncios.",
|
||||
"empty_column.aliases": "You haven't created any account alias yet.",
|
||||
"empty_column.aliases.suggestions": "There are no account suggestions available for the provided term.",
|
||||
"empty_column.blocks": "Aún no has bloqueado a ningún usuario.",
|
||||
|
@ -720,7 +755,7 @@
|
|||
"gdpr.title": "{siteTitle} uses cookies",
|
||||
"getting_started.open_source_notice": "{code_name} es software libre. Puedes contribuir o reportar errores en {code_link} (v{code_version}).",
|
||||
"group.admin_subheading": "Administradores del grupo",
|
||||
"group.cancel_request": "Cancelar petición",
|
||||
"group.cancel_request": "Cancelar solicitud",
|
||||
"group.group_mod_authorize": "Aceptar",
|
||||
"group.group_mod_authorize.success": "Aceptado @{name} al grupo",
|
||||
"group.group_mod_block": "Bloquear a @{name} del grupo",
|
||||
|
@ -738,21 +773,30 @@
|
|||
"group.group_mod_unblock": "Desbloquear",
|
||||
"group.group_mod_unblock.success": "Desbloquear a @{name} del grupo",
|
||||
"group.header.alt": "Encabezado del grupo",
|
||||
"group.join": "Unirse al grupo",
|
||||
"group.join.private": "Solicitar Acceso",
|
||||
"group.join.public": "Únete al grupo",
|
||||
"group.join.request_success": "Solicitud de unión al grupo",
|
||||
"group.join.success": "Se unió al grupo",
|
||||
"group.leave": "Dejar el grupo",
|
||||
"group.leave": "Dejar el Grupo",
|
||||
"group.leave.success": "Abandonó el grupo",
|
||||
"group.manage": "Gestionar el grupo",
|
||||
"group.manage": "Gestionar el Grupo",
|
||||
"group.moderator_subheading": "Moderadores del grupo",
|
||||
"group.privacy.locked": "Privado",
|
||||
"group.privacy.public": "Público",
|
||||
"group.request_join": "Solicitud de ingreso en el grupo",
|
||||
"group.role.admin": "Administrador",
|
||||
"group.role.moderator": "Moderador",
|
||||
"group.tabs.all": "Todos",
|
||||
"group.tabs.members": "Miembros",
|
||||
"group.user_subheading": "Usuarios",
|
||||
"groups.discover.search.no_results.subtitle": "Intenta buscar otro grupo.",
|
||||
"groups.discover.search.no_results.title": "Sin coincidencias",
|
||||
"groups.discover.search.placeholder": "Buscar",
|
||||
"groups.discover.search.recent_searches.blankslate.subtitle": "Buscar los nombres de los grupos, temas o palabras clave",
|
||||
"groups.discover.search.recent_searches.blankslate.title": "Sin búsquedas recientes",
|
||||
"groups.discover.search.recent_searches.clear_all": "Borrar todo",
|
||||
"groups.discover.search.recent_searches.title": "Últimas búsquedas",
|
||||
"groups.discover.search.results.groups": "Grupos",
|
||||
"groups.discover.search.results.member_count": "{miembros, plural, un {miembro} otro {miembros}}",
|
||||
"groups.empty.subtitle": "Empieza a descubrir los grupos a los que unirte o crea el tuyo propio.",
|
||||
"groups.empty.title": "Aún no hay grupos",
|
||||
"hashtag.column_header.tag_mode.all": "y {additional}",
|
||||
|
@ -769,7 +813,6 @@
|
|||
"home.column_settings.show_replies": "Mostrar respuestas",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
@ -962,9 +1005,9 @@
|
|||
"new_event_panel.action": "Crear un evento",
|
||||
"new_event_panel.subtitle": "¿No encuentra lo que busca? Programe su propio evento.",
|
||||
"new_event_panel.title": "Crear un nuevo evento",
|
||||
"new_group_panel.action": "Crear un grupo",
|
||||
"new_group_panel.action": "Crear un Grupo",
|
||||
"new_group_panel.subtitle": "¿No encuentra lo que busca? Crea tu propio grupo privado o público.",
|
||||
"new_group_panel.title": "Crear un nuevo grupo",
|
||||
"new_group_panel.title": "Crear un Grupo",
|
||||
"notification.favourite": "{name} marcó tu estado como favorito",
|
||||
"notification.follow": "{name} te empezó a seguir",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
|
@ -1442,6 +1485,6 @@
|
|||
"video.play": "Reproducir",
|
||||
"video.unmute": "Dejar de silenciar sonido",
|
||||
"waitlist.actions.verify_number": "Verify phone number",
|
||||
"waitlist.body": "Welcome back to {title}! You were previously placed on our waitlist. Please verify your phone number to receive immediate access to your account!",
|
||||
"waitlist.body": "¡Bienvenido de nuevo a {title}! Usted fue colocado previamente en nuestra lista de espera. ¡Por favor, verifique su número de teléfono para recibir acceso inmediato a su cuenta!",
|
||||
"who_to_follow.title": "Who To Follow"
|
||||
}
|
||||
|
|
|
@ -564,7 +564,6 @@
|
|||
"home.column_settings.show_replies": "نمایش پاسخها",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
|
|
@ -705,7 +705,6 @@
|
|||
"home.column_settings.show_replies": "Afficher les réponses",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "Pas d'icônes ! ! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
|
|
@ -564,7 +564,6 @@
|
|||
"home.column_settings.show_replies": "Show replies",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
|
|
@ -564,7 +564,6 @@
|
|||
"home.column_settings.show_replies": "הצגת תגובות",
|
||||
"icon_button.icons": "סמלים",
|
||||
"icon_button.label": "בחר סמל",
|
||||
"icon_button.not_found": "אין סימנים!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "יבא",
|
||||
"import_data.actions.import_blocks": "יבא חסימות",
|
||||
"import_data.actions.import_follows": "יבא מעקבים",
|
||||
|
|
|
@ -564,7 +564,6 @@
|
|||
"home.column_settings.show_replies": "Show replies",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
|
|
@ -702,7 +702,6 @@
|
|||
"home.column_settings.show_replies": "Prikaži odgovore",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Uvezi",
|
||||
"import_data.actions.import_blocks": "Uvezi blokirane korisnike",
|
||||
"import_data.actions.import_follows": "Uvezi korisnike koje pratiš",
|
||||
|
|
|
@ -564,7 +564,6 @@
|
|||
"home.column_settings.show_replies": "Válaszok mutatása",
|
||||
"icon_button.icons": "Icons",
|
||||
"icon_button.label": "Select icon",
|
||||
"icon_button.not_found": "No icons!! (╯°□°)╯︵ ┻━┻",
|
||||
"import_data.actions.import": "Import",
|
||||
"import_data.actions.import_blocks": "Import blocks",
|
||||
"import_data.actions.import_follows": "Import follows",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue