Merge remote-tracking branch 'origin/develop' into create-group

environments/review-create-gro-d59al6/deployments/2762
Alex Gleason 2023-03-06 12:10:06 -06:00
commit d16bce0ecc
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
102 zmienionych plików z 4437 dodań i 2182 usunięć

Wyświetl plik

@ -157,11 +157,11 @@ docker:
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
script:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
- docker build -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE
only:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
rules:
- if: $CI_COMMIT_TAG
interruptible: false
release:
stage: release

Wyświetl plik

@ -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';
@ -277,7 +278,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
const idempotencyKey = compose.idempotencyKey;
const params = {
const params: Record<string, any> = {
status,
in_reply_to_id: compose.in_reply_to,
quote_id: compose.quote,
@ -289,9 +290,10 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
poll: compose.poll,
scheduled_at: compose.schedule,
to,
group_id: compose.privacy === 'group' ? compose.group_id : null,
};
if (compose.privacy === 'group') params.group_id = compose.group_id;
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) {
routerHistory.push('/messages');
@ -515,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));
};
@ -560,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));

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -152,7 +152,14 @@ const Item: React.FC<IItem> = ({
);
return (
<div className={clsx('media-gallery__item', { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
<div
className={clsx('media-gallery__item', {
standalone,
'rounded-md': total > 1,
})}
key={attachment.id}
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
>
<a className='media-gallery__item-thumbnail' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}>
<Blurhash hash={attachment.blurhash} className='media-gallery__preview' />
<span className='media-gallery__item__icons'>{attachmentIcon}</span>
@ -245,7 +252,14 @@ const Item: React.FC<IItem> = ({
}
return (
<div className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
<div
className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, {
standalone,
'rounded-md': total > 1,
})}
key={attachment.id}
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
>
{last && total > ATTACHMENT_LIMIT && (
<div className='media-gallery__item-overflow'>
+{total - ATTACHMENT_LIMIT + 1}
@ -260,7 +274,7 @@ const Item: React.FC<IItem> = ({
);
};
interface IMediaGallery {
export interface IMediaGallery {
sensitive?: boolean
media: ImmutableList<Attachment>
height?: number
@ -270,13 +284,15 @@ interface IMediaGallery {
visible?: boolean
onToggleVisibility?: () => void
displayMedia?: string
compact: boolean
compact?: boolean
className?: string
}
const MediaGallery: React.FC<IMediaGallery> = (props) => {
const {
media,
defaultWidth = 0,
className,
onOpenMedia,
cacheWidth,
compact,
@ -546,7 +562,11 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
}, [node.current]);
return (
<div className={clsx('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.style} ref={node}>
<div
className={clsx(className, 'media-gallery', { 'media-gallery--compact': compact })}
style={sizeData.style}
ref={node}
>
{children}
</div>
);

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -112,6 +112,7 @@ const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, chi
referenceElement={referenceElement}
onReact={handleReact}
visible={visible}
onClose={() => setVisible(false)}
/>
</Portal>
)}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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
}
@ -209,7 +211,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>

Wyświetl plik

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

Wyświetl plik

@ -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';
@ -23,6 +23,7 @@ import ChatMessageReaction from './chat-message-reaction';
import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper';
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
import type { IMediaGallery } from 'soapbox/components/media-gallery';
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const messages = defineMessages({
@ -112,8 +113,12 @@ const ChatMessage = (props: IChatMessage) => {
return (
<Bundle fetchComponent={MediaGallery}>
{(Component: any) => (
{(Component: React.FC<IMediaGallery>) => (
<Component
className={clsx({
'rounded-br-sm': isMyMessage && content,
'rounded-bl-sm': !isMyMessage && content,
})}
media={chatMessage.media_attachments}
onOpenMedia={onOpenMedia}
visible
@ -385,4 +390,4 @@ const ChatMessage = (props: IChatMessage) => {
);
};
export default ChatMessage;
export default ChatMessage;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -16,6 +16,7 @@ const ConversationsList: React.FC = () => {
const conversations = useAppSelector((state) => state.conversations.items);
const isLoading = useAppSelector((state) => state.conversations.isLoading);
const hasMore = useAppSelector((state) => state.conversations.hasMore);
const getCurrentIndex = (id: string) => conversations.findIndex(x => x.id === id);
@ -50,6 +51,7 @@ const ConversationsList: React.FC = () => {
return (
<ScrollableList
hasMore={hasMore}
onLoadMore={handleLoadOlder}
id='direct-list'
scrollKey='direct'

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,14 +1,15 @@
import React, { forwardRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
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';
import { shortNumberFormat } from 'soapbox/utils/numbers';
interface IGroup {
group: GroupEntity
width: number
width?: number
}
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
@ -22,69 +23,56 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
width,
}}
>
<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'
/>
)}
<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
<Stack justifyContent='end' className='z-10 p-4 text-white' space={3}>
<Avatar
className='ring-2 ring-white'
src={group.avatar}
size={44}
/>
<HStack 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')}
<Stack space={1}>
<Text
weight='bold'
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
theme='inherit'
truncate
/>
{typeof group.members_count === 'undefined' ? (
<Text theme='inherit' tag='span' size='sm'>
{group.locked ? (
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
) : (
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
)}
</Text>
) : (
<Text theme='inherit' tag='span' size='sm'>
{shortNumberFormat(group.members_count)}
{' '}
members
</Text>
)}
</HStack>
<HStack alignItems='center' space={1}>
<GroupPrivacy group={group} />
<span>&bull;</span>
<GroupMemberCount group={group} />
</HStack>
</Stack>
</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>
<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
>
Join Group
{group.locked
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
</Button>
</div>
);

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,19 +1,78 @@
import React from 'react';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui';
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}>
<PopularGroups />
<SuggestedGroups />
<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>
);

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -620,6 +620,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",
@ -627,10 +628,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}.",
@ -745,7 +755,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",
@ -763,21 +773,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}",
@ -794,7 +813,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",

Wyświetl plik

@ -738,7 +738,7 @@
"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.public": "Unirse al grupo",
"group.join.request_success": "Solicitud de unión al grupo",
"group.join.success": "Se unió al grupo",
"group.leave": "Dejar el grupo",
@ -747,7 +747,7 @@
"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.join.private": "Solicitud de ingreso en el grupo",
"group.role.admin": "Administrador",
"group.role.moderator": "Moderador",
"group.tabs.all": "Todos",

Wyświetl plik

@ -738,7 +738,7 @@
"group.group_mod_unblock": "Sblocca",
"group.group_mod_unblock.success": "Hai sbloccato @{name} dal gruppo",
"group.header.alt": "Testata del gruppo",
"group.join": "Entra nel gruppo",
"group.join.public": "Entra nel gruppo",
"group.join.request_success": "Richiesta di partecipazione",
"group.join.success": "Partecipazione nel gruppo",
"group.leave": "Abbandona il gruppo",
@ -747,7 +747,7 @@
"group.moderator_subheading": "Moderazione del gruppo",
"group.privacy.locked": "Privato",
"group.privacy.public": "Pubblico",
"group.request_join": "Richiesta di partecipazione",
"group.join.private": "Richiesta di partecipazione",
"group.role.admin": "Amministrazione",
"group.role.moderator": "Moderazione",
"group.tabs.all": "Tutto",

Wyświetl plik

@ -591,13 +591,13 @@
"getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).",
"group.admin_subheading": "Administratorzy grupy",
"group.header.alt": "Nagłówek grupy",
"group.join": "Dołącz do grupy",
"group.join.public": "Dołącz do grupy",
"group.leave": "Opuść grupę",
"group.manage": "Edytuj grupę",
"group.moderator_subheading": "Moderatorzy grupy",
"group.privacy.locked": "Prywatna",
"group.privacy.public": "Publiczna",
"group.request_join": "Poproś o dołączenie do grupy",
"group.join.private": "Poproś o dołączenie do grupy",
"group.role.admin": "Administrator",
"group.role.moderator": "Moderator",
"group.tabs.all": "Wszystko",

Wyświetl plik

@ -738,7 +738,7 @@
"group.group_mod_unblock": "解除屏蔽",
"group.group_mod_unblock.success": "已从群组中解除屏蔽 @{name}",
"group.header.alt": "群组标题",
"group.join": "加入群组",
"group.join.public": "加入群组",
"group.join.request_success": "已请求加入群组",
"group.join.success": "已加入群组",
"group.leave": "离开群组",
@ -747,7 +747,7 @@
"group.moderator_subheading": "群组监察员",
"group.privacy.locked": "私有",
"group.privacy.public": "公开",
"group.request_join": "请求加入群组",
"group.join.private": "请求加入群组",
"group.role.admin": "管理员",
"group.role.moderator": "监察员",
"group.tabs.all": "全部",

Wyświetl plik

@ -11,7 +11,7 @@ import {
fromJS,
} from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import emojify from 'soapbox/features/emoji';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { unescapeHTML } from 'soapbox/utils/html';
import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';

Wyświetl plik

@ -10,7 +10,7 @@ import {
fromJS,
} from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import emojify from 'soapbox/features/emoji';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { makeEmojiMap } from 'soapbox/utils/normalizers';

Wyświetl plik

@ -50,11 +50,21 @@ const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap<string, any
}
};
/** Rewrite `<p></p>` to empty string. */
const fixContent = (chatMessage: ImmutableMap<string, any>) => {
if (chatMessage.get('content') === '<p></p>') {
return chatMessage.set('content', '');
} else {
return chatMessage;
}
};
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
return ChatMessageRecord(
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
normalizeMedia(chatMessage);
normalizeChatMessageEmojiReaction(chatMessage);
fixContent(chatMessage);
}),
);
};

Wyświetl plik

@ -10,7 +10,7 @@ import {
fromJS,
} from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import emojify from 'soapbox/features/emoji';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { unescapeHTML } from 'soapbox/utils/html';
import { makeEmojiMap } from 'soapbox/utils/normalizers';
@ -128,6 +128,11 @@ const normalizeFqn = (group: ImmutableMap<string, any>) => {
return group.set('fqn', fqn);
};
const normalizeLocked = (group: ImmutableMap<string, any>) => {
const locked = group.get('locked') || group.get('group_visibility') === 'members_only';
return group.set('locked', locked);
};
/** Rewrite `<p></p>` to empty string. */
const fixNote = (group: ImmutableMap<string, any>) => {
@ -145,6 +150,7 @@ export const normalizeGroup = (group: Record<string, any>) => {
normalizeAvatar(group);
normalizeHeader(group);
normalizeFqn(group);
normalizeLocked(group);
fixDisplayName(group);
fixNote(group);
addInternalFields(group);

Wyświetl plik

@ -11,7 +11,7 @@ import {
fromJS,
} from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import emojify from 'soapbox/features/emoji';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { makeEmojiMap } from 'soapbox/utils/normalizers';

Wyświetl plik

@ -9,7 +9,7 @@ import {
fromJS,
} from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import emojify from 'soapbox/features/emoji';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizePoll } from 'soapbox/normalizers/poll';

Wyświetl plik

@ -205,6 +205,15 @@ const normalizeEvent = (status: ImmutableMap<string, any>) => {
}
};
/** Rewrite `<p></p>` to empty string. */
const fixContent = (status: ImmutableMap<string, any>) => {
if (status.get('content') === '<p></p>') {
return status.set('content', '');
} else {
return status;
}
};
export const normalizeStatus = (status: Record<string, any>) => {
return StatusRecord(
ImmutableMap(fromJS(status)).withMutations(status => {
@ -219,6 +228,7 @@ export const normalizeStatus = (status: Record<string, any>) => {
fixFiltered(status);
fixSensitivity(status);
normalizeEvent(status);
fixContent(status);
}),
);
};

Wyświetl plik

@ -1,10 +1,8 @@
import React, { useCallback, useEffect } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useRouteMatch } from 'react-router-dom';
import { fetchGroup } from 'soapbox/actions/groups';
import MissingIndicator from 'soapbox/components/missing-indicator';
import { Column, Layout } from 'soapbox/components/ui';
import { Column, Icon, Layout, Stack, Text } from 'soapbox/components/ui';
import GroupHeader from 'soapbox/features/group/components/group-header';
import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
@ -13,8 +11,8 @@ import {
GroupMediaPanel,
SignUpPanel,
} from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetGroup } from 'soapbox/selectors';
import { useOwnAccount } from 'soapbox/hooks';
import { useGroup } from 'soapbox/queries/groups';
import { Tabs } from '../components/ui';
@ -34,23 +32,20 @@ interface IGroupPage {
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
const intl = useIntl();
const match = useRouteMatch();
const dispatch = useAppDispatch();
const me = useOwnAccount();
const id = params?.id || '';
const getGroup = useCallback(makeGetGroup(), []);
const group = useAppSelector(state => getGroup(state, id));
const me = useAppSelector(state => state.me);
const { group } = useGroup(id);
useEffect(() => {
dispatch(fetchGroup(id));
}, [id]);
const isNonMember = !group?.relationship || !group.relationship.member;
const isPrivate = group?.locked;
if ((group as any) === false) {
return (
<MissingIndicator />
);
}
// if ((group as any) === false) {
// return (
// <MissingIndicator />
// );
// }
const items = [
{
@ -76,7 +71,18 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
activeItem={match.path}
/>
{children}
{(isNonMember && isPrivate) ? (
<Stack space={4} className='py-10' alignItems='center'>
<div className='rounded-full bg-gray-200 p-3'>
<Icon src={require('@tabler/icons/eye-off.svg')} className='h-6 w-6 text-gray-600' />
</div>
<Text theme='muted'>
Content is only visible to group members
</Text>
</Stack>
) : children}
</Column>
{!me && (

Wyświetl plik

@ -9,6 +9,7 @@ import { Group } from 'soapbox/types/entities';
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
const GroupKeys = {
group: (id: string) => ['groups', 'group', id] as const,
myGroups: (userId: string) => ['groups', userId] as const,
popularGroups: ['groups', 'popular'] as const,
suggestedGroups: ['groups', 'suggested'] as const,
@ -18,9 +19,10 @@ const useGroups = () => {
const api = useApi();
const account = useOwnAccount();
const dispatch = useAppDispatch();
const features = useFeatures();
const getGroups = async (pageParam?: any): Promise<PaginatedResult<Group>> => {
const endpoint = '/api/mock/groups'; // '/api/v1/groups';
const endpoint = '/api/v1/groups';
const nextPageLink = pageParam?.link;
const uri = nextPageLink || endpoint;
const response = await api.get<Group[]>(uri);
@ -45,7 +47,7 @@ const useGroups = () => {
GroupKeys.myGroups(account?.id as string),
({ pageParam }: any) => getGroups(pageParam),
{
enabled: !!account,
enabled: !!account && features.groups,
keepPreviousData: true,
getNextPageParam: (config) => {
if (config?.hasMore) {
@ -69,7 +71,7 @@ const usePopularGroups = () => {
const features = useFeatures();
const getQuery = async () => {
const { data } = await api.get<Group[]>('/api/mock/groups'); // '/api/v1/truth/trends/groups'
const { data } = await api.get<Group[]>('/api/v1/groups/search?q=group'); // '/api/v1/truth/trends/groups'
const result = data.map(normalizeGroup);
return result;
@ -108,4 +110,23 @@ const useSuggestedGroups = () => {
};
};
export { useGroups, usePopularGroups, useSuggestedGroups };
const useGroup = (id: string) => {
const api = useApi();
const features = useFeatures();
const getGroup = async () => {
const { data } = await api.get(`/api/v1/groups/${id}`);
return normalizeGroup(data);
};
const queryInfo = useQuery(GroupKeys.group(id), getGroup, {
enabled: features.groups && !!id,
});
return {
...queryInfo,
group: queryInfo.data,
};
};
export { useGroups, useGroup, usePopularGroups, useSuggestedGroups };

Wyświetl plik

@ -0,0 +1,67 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { getNextLink } from 'soapbox/api';
import { useApi, useFeatures } from 'soapbox/hooks';
import { normalizeGroup } from 'soapbox/normalizers';
import { Group } from 'soapbox/types/entities';
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
const GroupSearchKeys = {
search: (query?: string) => query ? ['groups', 'search', query] : ['groups', 'search'] as const,
};
type PageParam = {
link: string
}
const useGroupSearch = (search?: string) => {
const api = useApi();
const features = useFeatures();
const getSearchResults = async (pageParam: PageParam): Promise<PaginatedResult<Group>> => {
const nextPageLink = pageParam?.link;
const uri = nextPageLink || '/api/v1/groups/search';
const response = await api.get<Group[]>(uri, {
params: search ? {
q: search,
} : undefined,
});
const { data } = response;
const link = getNextLink(response);
const hasMore = !!link;
const result = data.map(normalizeGroup);
return {
result,
hasMore,
link,
};
};
const queryInfo = useInfiniteQuery(
GroupSearchKeys.search(search),
({ pageParam }) => getSearchResults(pageParam),
{
keepPreviousData: true,
enabled: features.groups && !!search,
getNextPageParam: (config) => {
if (config.hasMore) {
return { link: config.link };
}
return undefined;
},
});
const data = flattenPages(queryInfo.data);
return {
...queryInfo,
groups: data || [],
};
};
export {
useGroupSearch,
};

Wyświetl plik

@ -118,7 +118,7 @@ describe('statuses reducer', () => {
const status = require('soapbox/__fixtures__/status-custom-emoji.json');
const action = { type: STATUS_IMPORT, status };
const expected = 'Hello <img draggable="false" class="emojione custom-emoji" alt=":ablobcathyper:" title=":ablobcathyper:" src="https://gleasonator.com/emoji/blobcat/ablobcathyper.png" data-original="https://gleasonator.com/emoji/blobcat/ablobcathyper.png" data-static="https://gleasonator.com/emoji/blobcat/ablobcathyper.png"> <img draggable="false" class="emojione custom-emoji" alt=":ageblobcat:" title=":ageblobcat:" src="https://gleasonator.com/emoji/blobcat/ageblobcat.png" data-original="https://gleasonator.com/emoji/blobcat/ageblobcat.png" data-static="https://gleasonator.com/emoji/blobcat/ageblobcat.png"> <img draggable="false" class="emojione" alt="😂" title=":joy:" src="/packs/emoji/1f602.svg"> world <img draggable="false" class="emojione" alt="😋" title=":yum:" src="/packs/emoji/1f60b.svg"> test <img draggable="false" class="emojione custom-emoji" alt=":blobcatphoto:" title=":blobcatphoto:" src="https://gleasonator.com/emoji/blobcat/blobcatphoto.png" data-original="https://gleasonator.com/emoji/blobcat/blobcatphoto.png" data-static="https://gleasonator.com/emoji/blobcat/blobcatphoto.png">';
const expected = 'Hello <img draggable="false" class="emojione" alt=":ablobcathyper:" title=":ablobcathyper:" src="https://gleasonator.com/emoji/blobcat/ablobcathyper.png"> <img draggable="false" class="emojione" alt=":ageblobcat:" title=":ageblobcat:" src="https://gleasonator.com/emoji/blobcat/ageblobcat.png"> <img draggable="false" class="emojione" alt="😂" title=":joy:" src="/packs/emoji/1f602.svg"> world <img draggable="false" class="emojione" alt="😋" title=":yum:" src="/packs/emoji/1f60b.svg"> test <img draggable="false" class="emojione" alt=":blobcatphoto:" title=":blobcatphoto:" src="https://gleasonator.com/emoji/blobcat/blobcatphoto.png">';
const result = reducer(undefined, action).getIn(['AGm7uC9DaAIGUa4KYK', 'contentHtml']);
expect(result).toBe(expected);

Wyświetl plik

@ -1,6 +1,7 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord, fromJS } from 'immutable';
import { v4 as uuid } from 'uuid';
import { isNativeEmoji } from 'soapbox/features/emoji';
import { tagHistory } from 'soapbox/settings';
import { PLEROMA } from 'soapbox/utils/features';
import { hasIntegerMediaIds } from 'soapbox/utils/status';
@ -58,7 +59,7 @@ import { normalizeAttachment } from '../normalizers/attachment';
import { unescapeHTML } from '../utils/html';
import type { AnyAction } from 'redux';
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
import type { Emoji } from 'soapbox/features/emoji';
import type {
Account as AccountEntity,
APIEntity,
@ -192,7 +193,8 @@ const updateSuggestionTags = (compose: Compose, token: string, currentTrends: Im
const insertEmoji = (compose: Compose, position: number, emojiData: Emoji, needsSpace: boolean) => {
const oldText = compose.text;
const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native;
const emojiText = isNativeEmoji(emojiData) ? emojiData.native : emojiData.colons;
const emoji = needsSpace ? ' ' + emojiText : emojiText;
return compose.merge({
text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`,

Wyświetl plik

@ -1,15 +1,15 @@
import { List as ImmutableList, Map as ImmutableMap, fromJS } from 'immutable';
import { emojis as emojiData } from 'soapbox/features/emoji/emoji-mart-data-light';
import { addCustomToPool } from 'soapbox/features/emoji/emoji-mart-search-light';
import { buildCustomEmojis } from 'soapbox/features/emoji';
import emojiData from 'soapbox/features/emoji/data';
import { addCustomToPool } from 'soapbox/features/emoji/search';
import { CUSTOM_EMOJIS_FETCH_SUCCESS } from '../actions/custom-emojis';
import { buildCustomEmojis } from '../features/emoji/emoji';
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
const initialState = ImmutableList();
const initialState = ImmutableList<ImmutableMap<string, string>>();
// Populate custom emojis for composer autosuggest
const autosuggestPopulate = (emojis: ImmutableList<ImmutableMap<string, string>>) => {
@ -22,7 +22,7 @@ const importEmojis = (customEmojis: APIEntity[]) => {
// Otherwise it breaks EmojiMart.
// https://gitlab.com/soapbox-pub/soapbox/-/issues/610
const shortcode = emoji.get('shortcode', '').toLowerCase();
return !emojiData[shortcode];
return !emojiData.emojis[shortcode];
});
autosuggestPopulate(emojis);

Wyświetl plik

@ -13,7 +13,7 @@ import {
FE_NAME,
} from '../actions/settings';
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
import type { Emoji } from 'soapbox/features/emoji';
import type { APIEntity } from 'soapbox/types/entities';
type State = ImmutableMap<string, any>;

Wyświetl plik

@ -1,7 +1,7 @@
import escapeTextContentForBrowser from 'escape-html';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import emojify from 'soapbox/features/emoji';
import { normalizeStatus } from 'soapbox/normalizers';
import { simulateEmojiReact, simulateUnEmojiReact } from 'soapbox/utils/emoji-reacts';
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html';

Wyświetl plik

@ -53,3 +53,6 @@ export const pushNotificationsSetting = new Settings('soapbox_push_notification_
/** Remember hashtag usage. */
export const tagHistory = new Settings('soapbox_tag_history');
/** Remember group usage. */
export const groupSearchHistory = new Settings('soapbox_group_search_history');

Wyświetl plik

@ -0,0 +1,35 @@
import { groupSearchHistory } from 'soapbox/settings';
const RECENT_SEARCHES_KEY = 'soapbox:recent-group-searches';
const clearRecentGroupSearches = (currentUserId: string) => groupSearchHistory.remove(currentUserId);
const saveGroupSearch = (currentUserId: string, search: string) => {
let currentSearches: string[] = [];
if (groupSearchHistory.get(currentUserId)) {
currentSearches = groupSearchHistory.get(currentUserId);
}
if (currentSearches.indexOf(search) === -1) {
currentSearches.unshift(search);
if (currentSearches.length > 10) {
currentSearches.pop();
}
groupSearchHistory.set(currentUserId, currentSearches);
return currentSearches;
} else {
// The search term has already been searched. Move it to the beginning
// of the cached list.
const indexOfSearch = currentSearches.indexOf(search);
const nextCurrentSearches = [...currentSearches];
nextCurrentSearches.splice(0, 0, ...nextCurrentSearches.splice(indexOfSearch, 1));
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(nextCurrentSearches));
return nextCurrentSearches;
}
};
export { clearRecentGroupSearches, saveGroupSearch };

Wyświetl plik

@ -1,21 +1,21 @@
$media-compact-size: 50px;
.media-gallery {
@apply rounded-lg;
box-sizing: border-box;
overflow: hidden;
border-radius: 10px;
isolation: isolate;
position: relative;
width: 100%;
height: auto;
&__item {
@apply rounded-sm;
border: 0;
box-sizing: border-box;
display: block;
float: left;
position: relative;
border-radius: 10px;
overflow: hidden;
&__icons {

Wyświetl plik

@ -1,296 +1,10 @@
.emoji-mart,
.emoji-mart * {
box-sizing: border-box;
line-height: 1.15;
em-emoji-picker {
--rgb-background: 255 255 255;
--rgb-accent: var(--color-primary-600);
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.emoji-mart {
@apply text-base inline-block text-gray-900 dark:text-gray-100 rounded bg-white dark:bg-primary-900 shadow-lg;
}
.emoji-mart .emoji-mart-emoji {
@apply p-1.5 align-middle;
}
.emoji-mart-bar {
@apply border-0 border-solid border-gray-200 dark:border-gray-800;
}
.emoji-mart-bar:first-child {
border-bottom-width: 1px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.emoji-mart-bar:last-child {
border-top-width: 1px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
.emoji-mart-anchors {
@apply flex flex-row justify-between px-1.5;
}
.emoji-mart-anchor {
@apply relative block flex-auto text-gray-700 dark:text-gray-600 text-center overflow-hidden transition-colors py-3 px-1;
}
.emoji-mart-anchor:focus { outline: 0; }
.emoji-mart-anchor:hover,
.emoji-mart-anchor:focus,
.emoji-mart-anchor-selected {
@apply text-gray-600 dark:text-gray-300;
}
.emoji-mart-anchor-selected .emoji-mart-anchor-bar {
@apply bottom-0;
}
.emoji-mart-anchor-bar {
@apply absolute -bottom-0.5 left-0 w-11/12 h-0.5 bg-primary-600;
}
.emoji-mart-anchors i {
@apply inline-block w-full;
max-width: 22px;
}
.emoji-mart-anchors svg,
.emoji-mart-anchors img {
fill: currentcolor;
height: 18px;
width: 18px;
}
.emoji-mart-scroll {
overflow-y: scroll;
overflow-x: hidden;
height: 270px;
padding: 0 6px 6px;
will-change: transform; /* avoids "repaints on scroll" in mobile Chrome */
}
.emoji-mart-search {
@apply relative mt-1.5 p-2.5 pr-12 bg-white dark:bg-primary-900;
}
.emoji-mart-search input {
@apply text-sm pr-9 block w-full border-gray-300 dark:bg-transparent dark:border-gray-800 rounded-full focus:ring-primary-500 focus:border-primary-500;
&::-moz-focus-inner {
border: 0;
}
&::-webkit-search-cancel-button {
@apply hidden;
}
&::-moz-focus-inner,
&:focus,
&:active {
outline: 0 !important;
}
}
.emoji-mart-search input,
.emoji-mart-search input::-webkit-search-decoration,
.emoji-mart-search input::-webkit-search-cancel-button,
.emoji-mart-search input::-webkit-search-results-button,
.emoji-mart-search input::-webkit-search-results-decoration {
/* remove webkit/blink styles for <input type="search">
* via https://stackoverflow.com/a/9422689 */
appearance: none;
}
.emoji-mart-search-icon {
@apply absolute z-10 border-0;
top: 20px;
right: 56px;
padding: 2px 5px 1px;
}
.emoji-mart-search-icon svg {
@apply fill-gray-700 dark:fill-gray-600;
}
.emoji-mart-search-icon:hover svg {
@apply stroke-gray-800;
}
.emoji-mart-category .emoji-mart-emoji span {
@apply relative text-center;
z-index: 1;
}
.emoji-mart-category .emoji-mart-emoji:hover::before {
@apply bg-gray-50 dark:bg-primary-800;
z-index: 0;
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 100%;
}
.emoji-mart-category-label {
z-index: 2;
position: relative;
position: sticky;
top: 0;
}
.emoji-mart-category-label span {
@apply bg-white dark:bg-primary-900;
display: block;
width: 100%;
font-weight: 500;
padding: 5px 6px;
}
.emoji-mart-category-list {
margin: 0;
padding: 0;
}
.emoji-mart-category-list li {
list-style: none;
margin: 0;
padding: 0;
display: inline-block;
}
.emoji-mart-emoji {
position: relative;
display: inline-block;
font-size: 0;
margin: 0;
padding: 0;
border: none;
background: none;
box-shadow: none;
}
.emoji-mart-emoji-native {
font-family: 'Segoe UI Emoji', 'Segoe UI Symbol', 'Segoe UI', 'Apple Color Emoji', 'Twemoji Mozilla', 'Noto Color Emoji', 'Android Emoji', sans-serif;
}
.emoji-mart-no-results {
@apply text-sm text-center text-gray-600 dark:text-gray-300;
padding-top: 70px;
}
.emoji-mart-no-results-img {
display: block;
margin-left: auto;
margin-right: auto;
width: 50%;
}
.emoji-mart-no-results .emoji-mart-category-label {
display: none;
}
.emoji-mart-no-results .emoji-mart-no-results-label {
margin-top: 0.2em;
}
.emoji-mart-no-results .emoji-mart-emoji:hover::before {
content: none;
}
.emoji-mart-preview {
@apply hidden;
}
/* For screenreaders only, via https://stackoverflow.com/a/19758620 */
.emoji-mart-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.emoji-picker-dropdown__menu {
@apply rounded-lg absolute mt-1.5;
transform: translateX(calc(-1 * env(safe-area-inset-right))); /* iOS PWA */
z-index: 20000;
.emoji-mart-scroll {
transition: opacity 200ms ease;
}
&.selecting .emoji-mart-scroll {
opacity: 0.5;
}
}
.emoji-picker-dropdown__modifiers {
position: absolute;
top: 65px;
right: 14px;
cursor: pointer;
}
.emoji-picker-dropdown__modifiers__menu {
@apply absolute bg-white dark:bg-primary-900 rounded-3xl shadow overflow-hidden;
z-index: 4;
top: -4px;
left: -8px;
button {
@apply block cursor-pointer border-0 px-2 py-1 bg-transparent;
&:hover,
&:focus,
&:active {
@apply bg-gray-300 dark:bg-primary-600;
}
}
.emoji-mart-emoji {
height: 22px;
}
}
.font-icon-picker {
.emoji-mart-search {
// Search doesn't work. Hide it for now.
display: none;
padding: 10px !important;
}
.emoji-mart-category-label > span {
padding: 9px 6px 5px;
}
.emoji-mart-scroll {
border-radius: 4px;
}
.emoji-mart-search-icon {
right: 18px;
}
.emoji-mart-bar {
display: none;
}
.fa {
font-size: 18px;
width: 22px;
height: 22px;
text-align: center;
}
.fa-hack {
margin: 0 auto;
}
.dark em-emoji-picker {
--rgb-background: var(--color-primary-900);
}

Wyświetl plik

@ -11,6 +11,7 @@
&__link {
@apply px-2 py-2.5 space-y-1 flex flex-col flex-1 items-center text-gray-600 text-lg;
// padding: 8px 10px;
// display: flex;
// flex-direction: column;

Wyświetl plik

@ -15,7 +15,9 @@ NODE_ENV=development
- `yarn build` - Compile without a dev server, into `/static` directory.
## Translations
- `yarn manage:translations` - Normalizes translation files. Should always be run after editing i18n strings.
- `yarn i18n` - Rebuilds app and updates English locale to prepare for translations in other languages. Should always be run after editing i18n strings.
- `yarn manage:translations` - A low-level translations manager utility.
## Tests
- `yarn test:all` - Runs all tests and linters.

Wyświetl plik

@ -22,7 +22,6 @@ module.exports = {
'app/soapbox/**/*.mjs',
'app/soapbox/**/*.ts',
'app/soapbox/**/*.tsx',
'!app/soapbox/features/emoji/emoji-compressed.js',
'!app/soapbox/service-worker/entry.ts',
'!app/soapbox/jest/test-setup.ts',
'!app/soapbox/jest/test-helpers.ts',

Some files were not shown because too many files have changed in this diff Show More